Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
564 lines
22 KiB
Rust
564 lines
22 KiB
Rust
//! Read-model storage for submitted answers: the KV projection of the
|
|
//! event log (`events::store`, `aggregates`) for every bucket with a
|
|
//! state graph in `aggregates.yaml`. A bucket with no graph still
|
|
//! works - direct KV mutate, no event log, no CAS - so content isn't
|
|
//! forced to declare one before it needs it.
|
|
|
|
use leptos::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// An answer's starting state - content decides what it may transition
|
|
/// to (`ResourceSpec.transitions`), never this file.
|
|
pub const OPEN_STATE: &str = "open";
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct Answer {
|
|
/// The submission's chain hash - stable, unique, and already what
|
|
/// ties this answer back into the DAG a transition later extends.
|
|
pub id: String,
|
|
pub question_id: String,
|
|
pub alternative: String,
|
|
/// The submitted form fields, verbatim - the runtime never inspects
|
|
/// these keys, only content (via its own copy/labels) gives them
|
|
/// meaning.
|
|
pub responses: serde_json::Value,
|
|
pub state: String,
|
|
pub submitted_ms: i64,
|
|
/// The storyline the record was sent from, so the mail it gets
|
|
/// tells the same one (see `content/variants.rs`).
|
|
#[serde(default)]
|
|
pub storyline: u32,
|
|
#[serde(default)]
|
|
pub decided_ms: Option<i64>,
|
|
#[serde(default)]
|
|
pub decided_by: Option<String>,
|
|
/// Replies that came back by mail, oldest first (see
|
|
/// `mail::note_for`). A note never moves a record; it is the
|
|
/// conversation beside the case, which is the thing a desk could
|
|
/// not see before.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub notes: Vec<Note>,
|
|
}
|
|
|
|
/// One reply on a record. It lives here rather than with the mail
|
|
/// code because it is part of what a record is: the desks render it,
|
|
/// and the browser has to know its shape even though it will never
|
|
/// speak to a mail server.
|
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct Note {
|
|
pub id: String,
|
|
pub from: String,
|
|
#[serde(default)]
|
|
pub subject: String,
|
|
pub text: String,
|
|
pub received_ms: i64,
|
|
}
|
|
|
|
/// Writes a submitted answer into `bucket`, creating it on first use -
|
|
/// there's no fixed, pre-declared list of buckets, since content can
|
|
/// name any.
|
|
#[cfg(feature = "ssr")]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn store_answer(
|
|
js: &async_nats::jetstream::Context,
|
|
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
|
bucket: &str,
|
|
id: String,
|
|
question_id: &str,
|
|
alternative: &str,
|
|
responses: &serde_json::Value,
|
|
submitted_ms: i64,
|
|
storyline: u32,
|
|
) -> anyhow::Result<()> {
|
|
let store = match js.get_key_value(bucket).await {
|
|
Ok(store) => store,
|
|
Err(_) => {
|
|
js.create_key_value(async_nats::jetstream::kv::Config {
|
|
bucket: bucket.to_string(),
|
|
..Default::default()
|
|
})
|
|
.await?
|
|
}
|
|
};
|
|
// A schema-backed bucket's projection must agree with its event
|
|
// log about where a record starts - organizations begin at
|
|
// "prospect", not "open". Only schemaless buckets default to open.
|
|
let initial_state = aggregates
|
|
.get(bucket)
|
|
.map(|s| s.initial.clone())
|
|
.unwrap_or_else(|| OPEN_STATE.to_string());
|
|
let answer = Answer {
|
|
id: id.clone(),
|
|
question_id: question_id.to_string(),
|
|
alternative: alternative.to_string(),
|
|
responses: responses.clone(),
|
|
state: initial_state,
|
|
submitted_ms,
|
|
storyline,
|
|
decided_ms: None,
|
|
notes: Vec::new(),
|
|
decided_by: None,
|
|
};
|
|
let data = serde_json::to_vec(&answer)?;
|
|
store.put(id.clone(), data.into()).await?;
|
|
|
|
// Also seed the event log for any bucket that has a declared state
|
|
// graph - best-effort, same "a hiccup here shouldn't fail a
|
|
// submission the NATS notification event has already recorded"
|
|
// policy as the KV write above.
|
|
if let Some(schema) = aggregates.get(bucket) {
|
|
if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await {
|
|
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whose a record is, on an alternative that keeps one per person
|
|
/// (`one_per_person`): the signed-in person's username, written by the
|
|
/// server into the record's fields, never taken from the form.
|
|
pub const OWNER_FIELD: &str = "by";
|
|
|
|
/// Every record in `bucket` that belongs to `username`, newest first.
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
|
|
use futures::TryStreamExt;
|
|
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
|
|
let keys: Vec<String> = match store.keys().await {
|
|
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
|
Err(_) => return Vec::new(),
|
|
};
|
|
let mut own = Vec::new();
|
|
for key in keys {
|
|
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
|
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
|
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
|
|
own.push(answer);
|
|
}
|
|
}
|
|
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
|
|
own
|
|
}
|
|
|
|
/// A person keeps one card: of their records still in the bucket's
|
|
/// first state, only the newest stays in the list, and the rest leave
|
|
/// it (the event log keeps them). Run after every store, so two sends
|
|
/// racing each other - a double click - both settle on the same one.
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
|
|
let own = own_records(js, bucket, username).await;
|
|
let Ok(store) = js.get_key_value(bucket).await else { return };
|
|
for older in own.iter().filter(|a| a.state == initial).skip(1) {
|
|
if let Err(e) = store.delete(&older.id).await {
|
|
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fills the form of every `one_per_person` alternative with what the
|
|
/// signed-in person's own newest record says, so sending it again is
|
|
/// editing their card. A file field shows the file already there, at
|
|
/// the address a list would open it from, and may be left empty to
|
|
/// keep it.
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
|
|
let Some(user) = user else { return };
|
|
for alt in &mut question.alternatives {
|
|
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
|
|
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
|
|
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
|
|
if req.kind == "hidden" {
|
|
continue;
|
|
}
|
|
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
|
|
if req.kind == "file" {
|
|
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
|
|
req.optional = true;
|
|
} else {
|
|
req.value = Some(value.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One selected row's pending transition. `feature_name` is per-item
|
|
/// so a batch can span more than one resource feature on the same
|
|
/// alternative.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct TransitionItem {
|
|
pub feature_name: String,
|
|
pub item_id: String,
|
|
pub to: String,
|
|
}
|
|
|
|
/// Moves every selected row to its chosen target state in one call
|
|
/// (the shared per-alternative Confirm button). Items are independent
|
|
/// - per-item CAS, no cross-item transaction - so one failure doesn't
|
|
/// roll back the others; failures are collected and reported together.
|
|
#[server(endpoint = "transition_answers")]
|
|
pub async fn transition_answers(
|
|
question_id: String,
|
|
alternative: String,
|
|
items: Vec<TransitionItem>,
|
|
) -> Result<(), ServerFnError> {
|
|
use crate::auth::{User, SESSION_USER_KEY};
|
|
use crate::server::AppState;
|
|
|
|
let state = expect_context::<AppState>();
|
|
let storyline = crate::server::storyline::current().await;
|
|
let alt = state
|
|
.questions
|
|
.load()
|
|
.at(storyline)
|
|
.get(&question_id)
|
|
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
|
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
|
|
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
|
let user = session
|
|
.get::<User>(SESSION_USER_KEY)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
|
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
|
|
|
let mut errors = Vec::new();
|
|
for item in items {
|
|
let item_id = item.item_id.clone();
|
|
if let Err(e) = apply_transition(&state, &question_id, &alt, &user, item).await {
|
|
errors.push(format!("{item_id}: {e}"));
|
|
}
|
|
}
|
|
if errors.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(ServerFnError::new(errors.join("; ")))
|
|
}
|
|
}
|
|
|
|
/// `aggregates::transition`, with one recovery path: an aggregate the
|
|
/// event log has never heard of but whose KV projection exists (the
|
|
/// log was purged, or predates event-sourcing) gets a minimal history
|
|
/// reseeded from the projection's own current state, then the
|
|
/// transition retries. Keeps "zap the whole event stream" a safe
|
|
/// operation instead of one that strands every pre-wipe record.
|
|
#[cfg(feature = "ssr")]
|
|
pub(crate) async fn transition_or_reseed(
|
|
js: &async_nats::jetstream::Context,
|
|
schema: &crate::aggregates::AggregateSchema,
|
|
item_id: &str,
|
|
to: &str,
|
|
current_state: &str,
|
|
payload: serde_json::Value,
|
|
occurred_at_ms: i64,
|
|
) -> Result<(), crate::aggregates::TransitionError> {
|
|
match crate::aggregates::transition(js, schema, item_id, to, payload.clone(), occurred_at_ms)
|
|
.await
|
|
{
|
|
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
|
|
tracing::warn!(
|
|
bucket = %schema.bucket, item_id, current_state,
|
|
"no event history for a projected record - reseeding from the projection"
|
|
);
|
|
crate::aggregates::reseed(js, schema, item_id, current_state, occurred_at_ms).await?;
|
|
crate::aggregates::transition(js, schema, item_id, to, payload, occurred_at_ms)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
r => r.map(|_| ()),
|
|
}
|
|
}
|
|
|
|
/// Same alternative-scoped feature lookup as `get_resource`, and for
|
|
/// the same reason - a feature name isn't unique across a whole
|
|
/// question, only within its own alternative.
|
|
#[cfg(feature = "ssr")]
|
|
async fn apply_transition(
|
|
state: &crate::server::AppState,
|
|
question_id: &str,
|
|
alt: &crate::content::Alternative,
|
|
user: &crate::auth::User,
|
|
item: TransitionItem,
|
|
) -> Result<(), ServerFnError> {
|
|
use crate::chain::hash_node;
|
|
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
|
|
|
let feature = alt
|
|
.features
|
|
.iter()
|
|
.find(|f| f.name == item.feature_name)
|
|
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
|
|
let resource = feature
|
|
.resource
|
|
.as_ref()
|
|
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
|
let bucket = resource
|
|
.bucket()
|
|
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
|
|
let store = state
|
|
.jetstream
|
|
.get_key_value(bucket)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
|
let bytes = store
|
|
.get(&item.item_id)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
|
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
|
|
let mut answer: Answer =
|
|
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
// Matched on (from, to), not to alone: two declared transitions may
|
|
// share a target (open -> declined, in_dialogue -> declined), and
|
|
// only the one starting from the row's actual state may fire.
|
|
let transition = resource
|
|
.transitions
|
|
.iter()
|
|
.find(|t| t.to == item.to && t.from == answer.state)
|
|
.ok_or_else(|| {
|
|
ServerFnError::new(format!(
|
|
"no transition to {:?} from this item's current state {:?}",
|
|
item.to, answer.state
|
|
))
|
|
})?
|
|
.clone();
|
|
// The policy holds exactly the buttons this desk offers to this
|
|
// group, keyed on (from, to); it is the one check.
|
|
let decision = crate::access::decide_and_publish(
|
|
&state.nats,
|
|
&state.access.load(),
|
|
&crate::access::Principal::from_user(Some(user)),
|
|
crate::access::Action::Transition,
|
|
&crate::access::Resource::Bucket { name: bucket.to_string() },
|
|
serde_json::json!({ "from": answer.state, "to": item.to }),
|
|
);
|
|
if !decision.allow {
|
|
return Err(ServerFnError::new("not authorized"));
|
|
}
|
|
|
|
let decided_ms = chrono::Utc::now().timestamp_millis();
|
|
let decision_payload_for_event =
|
|
serde_json::json!({ "to": item.to, "item": item.item_id, "by": user.username });
|
|
|
|
// CAS on the aggregate's replayed sequence guards two concurrent
|
|
// decisions on the same item; a schemaless bucket falls back to the
|
|
// plain KV mutate below.
|
|
if let Some(schema) = state.aggregates.load().get(bucket) {
|
|
transition_or_reseed(
|
|
&state.jetstream,
|
|
schema,
|
|
&item.item_id,
|
|
&item.to,
|
|
&answer.state,
|
|
decision_payload_for_event.clone(),
|
|
decided_ms,
|
|
)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
}
|
|
|
|
answer.state = item.to.clone();
|
|
answer.decided_ms = Some(decided_ms);
|
|
answer.decided_by = Some(user.username.clone());
|
|
|
|
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
store
|
|
.put(&item.item_id, data.into())
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses, answer.storyline).await;
|
|
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
|
// An expired invite sent again: the move back is the asking.
|
|
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
|
crate::people::on_reopened(state, &item.item_id).await;
|
|
}
|
|
|
|
// The decision extends the DAG as a child of the answer's own
|
|
// submission hash, published like any other answer.
|
|
let parent_hashes = vec![item.item_id.clone()];
|
|
let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
|
let event = AnswerSubmitted {
|
|
chain_hash,
|
|
parent_hashes,
|
|
question_id: question_id.to_string(),
|
|
alternative: transition.label,
|
|
responses: decision_payload_for_event,
|
|
timestamp_ms: decided_ms,
|
|
storyline: answer.storyline,
|
|
};
|
|
emit_answer_submitted(&state.nats, &event)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The self-service counterpart to `transition_answers`: no session,
|
|
/// no group check - authorized by holding `item_id` (an unguessable
|
|
/// chain hash) plus a matching `email`, both carried in the link
|
|
/// itself. Deliberately returns the same generic error for "no such
|
|
/// item" and "email doesn't match", so an unsubscribe link can't be
|
|
/// used to probe which hashes or emails exist.
|
|
#[server(endpoint = "self_transition_answer")]
|
|
pub async fn self_transition_answer(
|
|
question_id: String,
|
|
alternative: String,
|
|
item_id: String,
|
|
email: String,
|
|
) -> Result<(), ServerFnError> {
|
|
use crate::chain::hash_node;
|
|
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
|
use crate::server::AppState;
|
|
|
|
let state = expect_context::<AppState>();
|
|
let question = state
|
|
.questions
|
|
.load()
|
|
.get(&question_id)
|
|
.cloned()
|
|
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
|
let self_transition = question
|
|
.alternatives
|
|
.iter()
|
|
.find(|a| a.name == alternative)
|
|
.ok_or_else(|| ServerFnError::new("unknown alternative"))?
|
|
.self_transition
|
|
.clone()
|
|
.ok_or_else(|| ServerFnError::new("alternative has no self transition"))?;
|
|
|
|
let store = state
|
|
.jetstream
|
|
.get_key_value(&self_transition.bucket)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
|
let bytes = store
|
|
.get(&item_id)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
|
.ok_or_else(|| ServerFnError::new("not found"))?;
|
|
let mut answer: Answer =
|
|
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
|
|
// The link is the credential and the email its second factor; the
|
|
// policy compares them and knows the one move a submitter may make.
|
|
// Refusal reads "not found" so the endpoint cannot be used to probe
|
|
// which records or emails exist.
|
|
let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default();
|
|
let decision = crate::access::decide_and_publish(
|
|
&state.nats,
|
|
&state.access.load(),
|
|
&crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() },
|
|
crate::access::Action::SelfTransition,
|
|
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
|
|
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
|
|
);
|
|
if !decision.allow {
|
|
// Told apart by which half of the rule failed. If the record is
|
|
// somewhere this button may be fired from, the email was wrong,
|
|
// and the answer must not confirm that the record exists. If it
|
|
// is not, the case has moved past this move and saying so is no
|
|
// longer a way to learn anything about it.
|
|
let initial = state
|
|
.aggregates
|
|
.load()
|
|
.get(&self_transition.bucket)
|
|
.map(|s| s.initial.clone())
|
|
.unwrap_or_else(|| OPEN_STATE.to_string());
|
|
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
|
|
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
|
}
|
|
|
|
let decided_ms = chrono::Utc::now().timestamp_millis();
|
|
let decision_payload_for_event =
|
|
serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
|
|
|
|
if let Some(schema) = state.aggregates.load().get(&self_transition.bucket) {
|
|
transition_or_reseed(
|
|
&state.jetstream,
|
|
schema,
|
|
&item_id,
|
|
&self_transition.to,
|
|
&answer.state,
|
|
decision_payload_for_event.clone(),
|
|
decided_ms,
|
|
)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
}
|
|
|
|
answer.state = self_transition.to.clone();
|
|
answer.decided_ms = Some(decided_ms);
|
|
answer.decided_by = Some(email.clone());
|
|
|
|
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
store
|
|
.put(&item_id, data.into())
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses, answer.storyline).await;
|
|
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
|
|
|
let parent_hashes = vec![item_id.clone()];
|
|
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
|
let event = AnswerSubmitted {
|
|
chain_hash,
|
|
parent_hashes,
|
|
question_id,
|
|
alternative: self_transition.label,
|
|
responses: decision_payload_for_event,
|
|
timestamp_ms: decided_ms,
|
|
storyline: answer.storyline,
|
|
};
|
|
emit_answer_submitted(&state.nats, &event)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// `app.rs`'s list-rendering path only shows the nice
|
|
/// button-per-transition UI if a resource's raw JSON value
|
|
/// deserializes as `Vec<Answer>` - otherwise it silently falls
|
|
/// back to a plain read-only dump with no error. Guards that shape
|
|
/// contract directly, since nothing else would catch a drift here.
|
|
#[test]
|
|
fn answer_list_round_trips_through_the_shape_app_rs_expects() {
|
|
let answers = vec![
|
|
Answer {
|
|
id: "abc123".to_string(),
|
|
question_id: "/review".to_string(),
|
|
alternative: "Applicants".to_string(),
|
|
responses: serde_json::json!({"name": "Ada"}),
|
|
state: OPEN_STATE.to_string(),
|
|
submitted_ms: 1_700_000_000_000,
|
|
storyline: 0,
|
|
decided_ms: None,
|
|
decided_by: None,
|
|
notes: Vec::new(),
|
|
},
|
|
Answer {
|
|
id: "def456".to_string(),
|
|
question_id: "/review".to_string(),
|
|
alternative: "Applicants".to_string(),
|
|
responses: serde_json::json!({"name": "Grace"}),
|
|
state: "invited".to_string(),
|
|
submitted_ms: 1_700_000_001_000,
|
|
storyline: 0,
|
|
decided_ms: Some(1_700_000_002_000),
|
|
decided_by: Some("bl".to_string()),
|
|
notes: Vec::new(),
|
|
},
|
|
];
|
|
let value = serde_json::Value::Array(
|
|
answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(),
|
|
);
|
|
let round_tripped: Vec<Answer> =
|
|
serde_json::from_value(value).expect("Answer list round-trips");
|
|
assert_eq!(round_tripped.len(), 2);
|
|
assert_eq!(round_tripped[1].state, "invited");
|
|
assert_eq!(round_tripped[1].decided_by.as_deref(), Some("bl"));
|
|
}
|
|
}
|