Files
portal/tests/jetstream.rs
T
Bendik Aagaard LynghaugandClaude Opus 5 214d4eb85c
Test / test (push) Successful in 58s
Sessions outlive the deploy that interrupted them
A MemoryStore meant every restart signed out every desk on the site
being deployed. Bearable while a desk is three people, and not the
kind of thing that gets better on its own.

They live in NATS KV now: the JetStream already under the events, the
projections, the content reload and the access questions. No new
service, no new store, and the same bucket a second instance of a site
would read if there ever is one. `create` draws a fresh id rather than
writing over a session already at that key, the bucket's `max_age`
collects what nobody comes back for, and a record's own expiry is what
answers a load, whatever the bucket still holds.

Proved the only way it can be: a store opened twice against one
JetStream, which is what two runs of the binary are.

Also fixed, while there: the integration test asserted a record's
projected state was one that `aggregates::transition` never writes -
it appends events, the desk handler updates the projection - so it
failed whenever it was actually run. It now asserts what it says it
does, that a mailed reply leaves the state alone. test.yml has been
red since 0.3.47 while publish.yml stayed green, which is how v0.4.0
went out over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:38:05 +02:00

156 lines
7.7 KiB
Rust

//! The one test that meets a real JetStream: a submission becomes an
//! aggregate, a desk move is accepted, an undeclared move is refused,
//! and the record reads back from its own event log. Everything else
//! in the suite replays in memory; this is what proves the store, the
//! event log and the projection agree with each other.
//!
//! Runs only when `PORTAL_TEST_NATS_URL` points at a JetStream (CI
//! starts a throwaway one, see `.gitea/workflows/test.yml`); without
//! it the test passes as skipped, so `cargo test` stays green offline.
#![cfg(feature = "ssr")]
use portal::aggregates::{self, parse_aggregates_yaml, TransitionError};
use portal::answers::{store_answer, Answer};
use portal::events::store::{ensure_stream, load_events};
use serde_json::json;
#[tokio::test]
async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_engine() {
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
ensure_stream(&js).await.expect("event stream");
// A bucket of its own per run, so reruns and parallel runs never
// read each other's records.
let bucket = format!("it_{}", chrono::Utc::now().timestamp_millis());
let schemas = parse_aggregates_yaml(&format!(
"aggregates:\n - bucket: {bucket}\n initial: open\n states:\n open: {{ event: received }}\n quoted: {{ event: quoted }}\n won: {{ event: won }}\n lost: {{ event: lost }}\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n"
))
.expect("schema");
let schema = &schemas[&bucket];
let now = chrono::Utc::now().timestamp_millis();
// The submission: a KV record in the initial state, plus the first event.
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now)
.await
.expect("store");
let store = js.get_key_value(&bucket).await.expect("bucket exists");
let stored: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
assert_eq!(stored.state, "open");
assert_eq!(stored.responses["email"], "a@b.example");
// A desk move the machine allows.
let moved = aggregates::transition(&js, schema, "rec-1", "quoted", json!({"by": "test"}), now + 1)
.await
.expect("open -> quoted is declared");
assert_eq!(moved.state, "quoted");
// Moves the machine does not allow are refused, whoever asks.
let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await;
assert!(matches!(back, Err(TransitionError::DisallowedTransition { .. })), "{back:?}");
let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await;
assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}");
let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await;
assert!(matches!(nobody, Err(TransitionError::UnknownAggregate)), "{nobody:?}");
// The record reads back from its own event log, in the state the
// accepted moves left it, with a sequence that moved forward.
let events = load_events(&js, &bucket, "rec-1").await.expect("events");
assert_eq!(events.len(), 3, "received, quoted, won");
let replayed = aggregates::replay(schema, "rec-1", &events).expect("replays");
assert_eq!(replayed.state, "won");
assert!(replayed.sequence > moved.sequence);
// A reply to the mail this record was sent becomes a note on it,
// through the same store the desks read - and moves nothing: the
// record is still where the desk left it.
let mut record: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
let state_before_the_note = record.state.clone();
let reply = portal::mail::Incoming {
v: portal::mail::CONTRACT,
id: "mail-1".into(),
bucket: bucket.clone(),
record: "rec-1".into(),
from: "A <A@B.example>".into(),
subject: "Re: your enquiry".into(),
text: "Thursday suits us.".into(),
received_ms: now + 10,
};
let note = portal::mail::note_for(&reply, &record).expect("the address matches the record");
record.notes.push(note);
store.put("rec-1", serde_json::to_vec(&record).unwrap().into()).await.expect("note stored");
let read_back: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
assert_eq!(read_back.notes.len(), 1);
assert_eq!(read_back.notes[0].text, "Thursday suits us.");
assert_eq!(read_back.notes[0].from, "a@b.example");
assert_eq!(read_back.state, state_before_the_note, "a reply is a note, never a decision");
// The same reply read twice adds nothing.
assert!(portal::mail::note_for(&reply, &read_back).is_err());
// And the event log is untouched by it.
let after = load_events(&js, &bucket, "rec-1").await.expect("events");
assert_eq!(after.len(), 3, "a note writes no event");
}
/// A session outlives the process that made it. The `MemoryStore` this
/// replaced could not: every deploy signed out every desk, and the
/// only proof that this one does not is a store opened twice against
/// the same JetStream, as two runs of the binary would.
#[tokio::test]
async fn a_session_survives_the_process_that_made_it() {
use tower_sessions::session::{Id, Record};
use tower_sessions::SessionStore;
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
let store = portal::sessions::NatsStore::open(&js).await.expect("open");
let mut record = Record {
id: Id::default(),
data: Default::default(),
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
+ tower_sessions::cookie::time::Duration::hours(1),
};
record.data.insert("who".into(), json!("ingrid"));
store.create(&mut record).await.expect("create");
// A second store over the same JetStream is what the next boot of
// the binary has: nothing carried over in memory.
let next_boot = portal::sessions::NatsStore::open(&js).await.expect("reopen");
let found = next_boot.load(&record.id).await.expect("load").expect("still there");
assert_eq!(found.data.get("who"), Some(&json!("ingrid")));
// `create` never writes over somebody else's session: asked to
// create one whose id is taken, it takes a new id instead.
let mut collide = Record { id: record.id, data: Default::default(), expiry_date: record.expiry_date };
next_boot.create(&mut collide).await.expect("create beside it");
assert_ne!(collide.id, record.id, "an id already in use must not be reused");
assert_eq!(
next_boot.load(&record.id).await.expect("load").expect("original intact").data.get("who"),
Some(&json!("ingrid")),
"the original session must be untouched"
);
// An expired record is gone to anyone asking, whatever the bucket
// still holds.
let stale = Record {
id: Id::default(),
data: Default::default(),
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
- tower_sessions::cookie::time::Duration::minutes(1),
};
next_boot.save(&stale).await.expect("save stale");
assert!(next_boot.load(&stale.id).await.expect("load stale").is_none());
// Signing out leaves nothing to read back.
next_boot.delete(&record.id).await.expect("delete");
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
next_boot.delete(&collide.id).await.ok();
}