//! DB-backed integration tests for the export epoch state machine (migration 014). //! //! These run against a REAL Postgres: `#[sqlx::test]` creates a throwaway database per test and //! runs `backend/migrations/` into it, so the schema, the enums, the `UNIQUE (event_id, type)` //! constraint and the `export_current` view are the production ones — not a mock. //! //! THE INVARIANT, from migration 014: //! //! An export is downloadable IFF //! event.export_released_at IS NOT NULL //! AND export_job.epoch = event.export_epoch //! AND export_job.status = 'done' //! //! Readiness is DERIVED (the `export_current` view), never stored. Every test below pins one leg of //! that invariant with the exact SQL `src/` executes (see `tests/common/mod.rs`). mod common; use common::*; use sqlx::PgPool; // ───────────────────────────────────────────────────────────────────────────── // 1. Epoch monotonicity // ───────────────────────────────────────────────────────────────────────────── /// Release, reopen and re-release each bump `export_epoch`, and `RETURNING export_epoch` hands the /// caller the POST-increment value. /// /// PREVENTS: a worker born with the PRE-increment epoch. It would be inert from the instant it /// started — every one of its writes is `epoch`-guarded, so `claim_job`/`finalize_job` would match /// nothing, the job row would sit at `pending` 0% forever with no live worker, and the host's /// download button would spin and then 404. The keepsake would never be built at all. #[sqlx::test] async fn release_returns_post_increment_epoch(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; assert_eq!(event_epoch(&pool, event_id).await, 0, "a fresh event starts at epoch 0"); let released = release_gallery(&pool, "wedding").await.expect("release claims the event"); assert_eq!(released, 1, "RETURNING must give the epoch AFTER the +1, not before"); assert_eq!(event_epoch(&pool, event_id).await, released, "worker's epoch == event's epoch"); // The jobs armed by the release carry exactly that epoch — this is what makes the worker's // guarded writes match. for t in ["zip", "html"] { let (status, epoch, _) = job_row(&pool, event_id, t).await.expect("job armed"); assert_eq!(status, "pending"); assert_eq!(epoch, released, "{t} job must be armed at the epoch the worker was born with"); } } /// Epoch is strictly monotonic across the whole release/reopen/re-release cycle, and a second /// release attempt while already released is rejected WITHOUT bumping. /// /// PREVENTS: epoch reuse. If a reopen could return the event to an epoch some old `done` row still /// carries, a retired keepsake — one that a guest asked to be taken down from — would silently /// become downloadable again. #[sqlx::test] async fn epoch_is_strictly_monotonic_across_reopen(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; assert_eq!(release_gallery(&pool, "wedding").await, Some(1)); // A duplicate release is a no-op (`WHERE export_released_at IS NULL`) and must NOT bump. assert_eq!(release_gallery(&pool, "wedding").await, None, "already released"); assert_eq!(event_epoch(&pool, event_id).await, 1, "a rejected release must not move the epoch"); // Reopen retires the generation with ONE write. assert_eq!(open_event(&pool, "wedding").await, 1); assert_eq!(event_epoch(&pool, event_id).await, 2, "reopen bumps"); // And re-releasing bumps again — never back to 1. assert_eq!(release_gallery(&pool, "wedding").await, Some(3), "re-release bumps again"); assert_eq!(event_epoch(&pool, event_id).await, 3); } // ───────────────────────────────────────────────────────────────────────────── // 2. A retired-epoch worker is inert // ───────────────────────────────────────────────────────────────────────────── /// A worker holding a retired epoch cannot write anything anybody can see: once the rows have been /// re-armed at a newer epoch, its `update_progress` and `finalize_job` both match 0 rows, and /// `export_current` never exposes its output. /// /// PREVENTS: the classic lost race — a slow worker from BEFORE a takedown finishing afterwards and /// publishing an archive that still contains the photo a guest asked to have removed. "Please take /// my photo out" is the one request that most needs to reach the keepsake, and the keepsake is the /// artifact people keep forever. #[sqlx::test] async fn retired_epoch_worker_writes_are_no_ops(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let old_epoch = release_gallery(&pool, "wedding").await.unwrap(); // Worker A is born at epoch 1 and claims the ZIP. assert!(claim_job(&pool, event_id, "zip", old_epoch).await, "worker A wins its claim"); assert!(update_progress(&pool, event_id, "zip", old_epoch, 40).await, "still live at 40%"); // ── A takedown lands mid-export: `invalidate_and_arm(Affects::Both)` bumps and re-arms. ── let (_, _, new_epoch) = bump_epoch(&pool, "wedding").await.expect("bump on a released event"); assert_eq!(new_epoch, old_epoch + 1); let mut conn = pool.acquire().await.unwrap(); enqueue_types_at_epoch(&mut conn, event_id, new_epoch, &["zip", "html"]).await; drop(conn); // Worker A is now INERT BY CONSTRUCTION. Every write is guarded on its own birth epoch. assert!( !update_progress(&pool, event_id, "zip", old_epoch, 90).await, "the liveness check must report `false` so worker A stops grinding through the gallery" ); assert!( !finalize_job(&pool, event_id, "zip", old_epoch, "exports/Gallery.1.zip").await, "worker A's finalize MUST affect 0 rows — this is the write that would have published a \ keepsake still containing the taken-down photo" ); // The re-armed row is untouched by the loser: still pending at the LIVE epoch, waiting for the // fresh worker. (If worker A had won, this row would read `done` at epoch 1.) let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap(); assert_eq!((status.as_str(), epoch), ("pending", new_epoch)); assert_eq!(file_path, None, "the loser's file_path must never be recorded"); // And nothing is downloadable — not the stale archive, not anything. assert_eq!(downloadable(&pool, event_id, "zip").await, None); } /// The documented, deliberate nuance in `claim_job`: after a bare `open_event` (which writes /// NOTHING to `export_job` — that is the point of the design), a worker at the old epoch still WINS /// its claim and can still write `done`. That is wasted work, not incorrectness: retirement is /// enforced at READ time. `export_current` must refuse to expose the row. /// /// PREVENTS: someone "optimising" `claim_job` into a cross-table `EXISTS (SELECT ... FROM event)` /// guard — the exact unsound guard migration 014 removed (under READ COMMITTED, a blocked UPDATE /// re-evaluates same-row predicates but answers other-table subqueries from a stale snapshot). /// This test pins the read-time enforcement so the write-time guard is never re-added. #[sqlx::test] async fn reopen_retires_at_read_time_not_write_time(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let epoch = release_gallery(&pool, "wedding").await.unwrap(); assert!(claim_job(&pool, event_id, "zip", epoch).await); // Host reopens uploads. No export_job row is touched. assert_eq!(open_event(&pool, "wedding").await, 1); // The in-flight worker's row-local writes still match — it was never told to stop. assert!( finalize_job(&pool, event_id, "zip", epoch, "exports/Gallery.1.zip").await, "documented: the claim/finalize is guarded on the JOB row's epoch, not the event's" ); let (status, _, _) = job_row(&pool, event_id, "zip").await.unwrap(); assert_eq!(status, "done", "the row really does say done"); // …and yet it is invisible. `export_current` requires the event to be released AND the epochs to // match; the reopen broke both. A worker at a dead epoch writes a row nobody can see. assert!(!in_export_current(&pool, event_id, "zip").await); assert_eq!( downloadable(&pool, event_id, "zip").await, None, "a reopened event must serve NO keepsake, however finished the job row looks" ); } // ───────────────────────────────────────────────────────────────────────────── // 3. `export_current` exactness (table-driven) // ───────────────────────────────────────────────────────────────────────────── /// The view is the ONE definition of "downloadable". Released + `done` + matching epoch ⇒ present; /// break ANY single leg ⇒ absent. Nothing else may make it appear or disappear. /// /// PREVENTS, leg by leg: /// * `released` — serving a keepsake for an event whose uploads are still open, i.e. an archive /// missing every photo taken after the snapshot. /// * `done` — handing out a half-written ZIP (a corrupt keepsake, downloaded once, kept forever). /// * `epoch` — the retired-generation download: the 404-forever keepsake, or worse, the archive /// still containing content that was taken down. #[sqlx::test] async fn export_current_is_exactly_the_invariant(pool: PgPool) { // (name, released?, status, job epoch offset from the event epoch, expected visible) let cases: &[(&str, bool, &str, i64, bool)] = &[ ("released + done + current epoch", true, "done", 0, true), ("NOT released (done, epoch matches)", false, "done", 0, false), ("NOT done: pending", true, "pending", 0, false), ("NOT done: running", true, "running", 0, false), ("NOT done: failed", true, "failed", 0, false), ("stale epoch (done, released)", true, "done", -1, false), ("future epoch (done, released)", true, "done", 1, false), ("migration-014 retired sentinel epoch -1", true, "done", -2, false), ]; for (i, (name, released, status, offset, expect_visible)) in cases.iter().enumerate() { let slug = format!("case{i}"); let event_id = seed_event(&pool, &slug).await; // Get the event to a known epoch (1) either by releasing it, or — for the unreleased case — // by releasing and reopening, which leaves it unreleased at a non-zero epoch. let event_epoch_now = if *released { release_gallery(&pool, &slug).await.unwrap() } else { release_gallery(&pool, &slug).await.unwrap(); open_event(&pool, &slug).await; event_epoch(&pool, event_id).await }; // Plant a single ZIP job row in the exact state under test. `-2` encodes "the sentinel the // migration stamps on retired rows", which must never equal a non-negative event epoch. // (Clear the rows the release armed first — `UNIQUE (event_id, type)`.) sqlx::query("DELETE FROM export_job WHERE event_id = $1") .bind(event_id) .execute(&pool) .await .expect("clear armed jobs"); let job_epoch = if *offset == -2 { -1 } else { event_epoch_now + offset }; sqlx::query( "INSERT INTO export_job (event_id, type, status, progress_pct, epoch, file_path) VALUES ($1, 'zip', $2::export_status, 100, $3, 'exports/Gallery.zip')", ) .bind(event_id) .bind(*status) .bind(job_epoch) .execute(&pool) .await .expect("plant job row"); let visible = downloadable(&pool, event_id, "zip").await.is_some(); assert_eq!( visible, *expect_visible, "export_current exactness violated for case: {name} \ (released={released}, status={status}, job_epoch={job_epoch}, event_epoch={event_epoch_now})" ); } } // ───────────────────────────────────────────────────────────────────────────── // 4. The ViewerOnly ZIP carry-forward // ───────────────────────────────────────────────────────────────────────────── /// Branch A — the ZIP is `done` at the outgoing epoch: the carry-forward re-stamps it to the new /// epoch (rows_affected = 1), so only the HTML viewer is rebuilt and the finished ZIP stays /// downloadable throughout. /// /// PREVENTS: rebuilding a multi-GB archive because someone deleted a comment. The ZIP holds media, /// not comments — a needless rebuild would 404 the photo download for minutes to change nothing /// inside it. #[sqlx::test] async fn viewer_only_carries_a_done_zip_forward(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let e1 = release_gallery(&pool, "wedding").await.unwrap(); // Both halves finish at epoch 1 — the keepsake is live. for t in ["zip", "html"] { assert!(claim_job(&pool, event_id, t, e1).await); assert!(finalize_job(&pool, event_id, t, e1, &format!("exports/{t}.{e1}.zip")).await); } let zip_file = downloadable(&pool, event_id, "zip").await.expect("zip is live"); // ── A comment is moderated: invalidate_and_arm(Affects::ViewerOnly). ── let (_, _, e2) = bump_epoch(&pool, "wedding").await.unwrap(); let carried = carry_zip_forward(&pool, event_id, e2).await; assert!(carried, "a `done` ZIP at epoch-1 MUST be carried forward (rows_affected == 1)"); // Only the viewer is re-armed… let mut conn = pool.acquire().await.unwrap(); enqueue_types_at_epoch(&mut conn, event_id, e2, &["html"]).await; drop(conn); // …and the ZIP is STILL DOWNLOADABLE, at the new epoch, pointing at the same, unrenamed file. let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap(); assert_eq!((status.as_str(), epoch), ("done", e2), "the ZIP row rode the epoch bump"); assert_eq!( downloadable(&pool, event_id, "zip").await, Some(zip_file), "the carried archive must never stop being served — same file, new epoch" ); // The viewer, meanwhile, is correctly retired and pending a rebuild. assert_eq!(job_row(&pool, event_id, "html").await.unwrap().0, "pending"); assert_eq!(downloadable(&pool, event_id, "html").await, None); } /// Branch B — THE BUG WE JUST FIXED. If the ZIP is still `pending`/`running` when the comment is /// moderated (which is MINUTES for a real multi-GB gallery, and deleting a comment right after /// release is an utterly ordinary thing to do), the carry-forward matches NOTHING /// (rows_affected = 0) — so the caller must NOT assume it carried, and must re-arm the ZIP too. /// /// PREVENTS: the stranded ZIP. Blindly re-arming only the viewer would leave the ZIP row at the /// retired epoch; the in-flight worker then finishes and writes `done` at an epoch `export_current` /// no longer matches, nothing ever re-arms it, and `GET /export/zip` 404s FOREVER — a keepsake the /// couple paid for that simply never appears, short of a reboot. #[sqlx::test] async fn viewer_only_carry_forward_matches_nothing_when_zip_unfinished(pool: PgPool) { for zip_state in ["pending", "running"] { let slug = format!("wedding-{zip_state}"); let event_id = seed_event(&pool, &slug).await; let e1 = release_gallery(&pool, &slug).await.unwrap(); // The ZIP worker is still going; only the viewer has finished. if zip_state == "running" { assert!(claim_job(&pool, event_id, "zip", e1).await); } assert!(claim_job(&pool, event_id, "html", e1).await); assert!(finalize_job(&pool, event_id, "html", e1, "exports/Memories.1.zip").await); // ── The comment is moderated. ── let (_, _, e2) = bump_epoch(&pool, &slug).await.unwrap(); let carried = carry_zip_forward(&pool, event_id, e2).await; assert!( !carried, "a {zip_state} ZIP has nothing to carry forward — the UPDATE must affect 0 rows \ (its `status = 'done'` predicate is the whole precondition)" ); // The carry-forward's OWN result decides. It didn't match ⇒ rebuild the ZIP as well. let types: &[&str] = if carried { &["html"] } else { &["zip", "html"] }; let mut conn = pool.acquire().await.unwrap(); enqueue_types_at_epoch(&mut conn, event_id, e2, types).await; drop(conn); // THE ASSERTION THAT WOULD HAVE CAUGHT THE BUG: the ZIP must not be stranded at the dead // epoch. It is re-armed at the live one, so a fresh worker will actually build it. let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap(); assert_eq!( (status.as_str(), epoch), ("pending", e2), "the unfinished ZIP MUST be re-armed at the new epoch, not left stranded at {e1}" ); // Even if the old in-flight worker now "finishes", it is inert and cannot resurrect itself. assert!(!finalize_job(&pool, event_id, "zip", e1, "exports/Gallery.1.zip").await); assert_eq!(downloadable(&pool, event_id, "zip").await, None); } } /// The re-arm upsert must never clobber the archive it just carried forward. /// /// `enqueue_types_at_epoch`'s `WHERE export_job.status <> 'done' OR export_job.epoch <> EXCLUDED.epoch` /// is the "startup recovery must not clobber a good half" rule, expressed as the readiness predicate /// itself. PREVENTS: boot recovery resetting a perfectly good, downloadable ZIP back to `pending` /// and making the keepsake 404 while it needlessly rebuilds. #[sqlx::test] async fn enqueue_preserves_a_done_half_at_the_current_epoch(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let e1 = release_gallery(&pool, "wedding").await.unwrap(); // The ZIP finished; the HTML worker was killed mid-flight (crash) and sits at `running`. assert!(claim_job(&pool, event_id, "zip", e1).await); assert!(finalize_job(&pool, event_id, "zip", e1, "exports/Gallery.1.zip").await); assert!(claim_job(&pool, event_id, "html", e1).await); // Boot recovery re-arms both types at the SAME epoch. let mut conn = pool.acquire().await.unwrap(); enqueue_types_at_epoch(&mut conn, event_id, e1, &["zip", "html"]).await; drop(conn); // The good half survives untouched… let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap(); assert_eq!((status.as_str(), epoch), ("done", e1), "a done half at the live epoch is preserved"); assert_eq!(file_path.as_deref(), Some("exports/Gallery.1.zip"), "file_path not nulled"); assert!(downloadable(&pool, event_id, "zip").await.is_some()); // …and only the missing half is re-armed. assert_eq!(job_row(&pool, event_id, "html").await.unwrap().0, "pending"); }