//! DB-backed tests for the export disk preflight. //! //! The keepsake used to be built with NO free-space check at all, and the failure that produced was //! not "the export failed" but "the deliverable is stuck and the escape hatch needs the space that //! isn't there": //! //! 1. A takedown bumps the epoch and re-arms both halves. //! 2. The ZIP hits ENOSPC partway through a multi-GB write. //! 3. The job row is now `failed` at the CURRENT epoch, so readiness //! (`epoch = event.export_epoch AND status = 'done'`) is false and `GET /export/zip` 404s — //! while the last good archive sits on disk, unreferenced and unreachable. //! 4. `POST /host/export/rebuild` re-arms the same doomed write. //! //! Two changes close it: reclaim the superseded generation BEFORE building (so peak usage is one //! generation, not two) and refuse up front with a number the host can act on. //! //! What these tests pin is the ESTIMATE — the part that decides. The arithmetic on top of it lives //! in `services/export.rs`'s unit tests; the filesystem selection lives in `is_superseded_archive`. //! //! ON DRIFT, precisely, because it is easy to overclaim here. The hazard is that `query_uploads` //! (which selects the rows the archives are built from) and `estimate_export_bytes` (which sizes //! them) could disagree — and an estimate missing rows the archive writes UNDER-reserves, the one //! direction that reintroduces the ENOSPC. **These tests cannot catch that**, and neither can any //! test in this harness: both sides here are `SRC:`-marked hand-copies in `tests/common/mod.rs`, //! so if production moved and the copies didn't, they would sit still and keep passing. //! //! That is fixed where it can be — the two queries now share one `export_visibility_where!()` //! fragment in `services/export.rs`, so they cannot diverge by construction. What is left for //! these tests is what the convention is genuinely good at: pinning the BEHAVIOUR, so a change //! that deliberately alters the filter has to come here and say so. mod common; use common::*; use sqlx::PgPool; /// The estimate must equal the sum over EXACTLY the rows `query_uploads` returns — computed from /// that row set, not from a restatement of its WHERE clause. /// /// PINS: which uploads the preflight is allowed to count. Each excluded row below is excluded by a /// DIFFERENT predicate, so a change that drops or weakens any one of them fails here and has to be /// argued for. (It does not detect production drifting away from these copies — see the file /// header; `export_visibility_where!()` is what makes that impossible.) #[sqlx::test] async fn the_estimate_sums_exactly_the_rows_the_archive_will_contain(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let visible = seed_user(&pool, event_id, "Anna").await; let banned = seed_user(&pool, event_id, "Ben").await; let hidden = seed_user(&pool, event_id, "Cara").await; seed_upload(&pool, event_id, visible, 1_000, false).await; seed_upload(&pool, event_id, visible, 2_500, false).await; // Each of these is excluded from the archive by a DIFFERENT predicate. seed_upload(&pool, event_id, visible, 9_000, true).await; // soft-deleted seed_upload(&pool, event_id, banned, 9_000, false).await; // uploader banned seed_upload(&pool, event_id, hidden, 9_000, false).await; // uploads hidden set_user_moderation(&pool, banned, true, true).await; set_user_moderation(&pool, hidden, false, true).await; let rows = export_visible_uploads(&pool, event_id).await; let expected: i64 = rows.iter().map(|(_, bytes)| bytes).sum(); assert_eq!(rows.len(), 2, "only Anna's two live uploads are archived"); assert_eq!( estimate_export_bytes(&pool, event_id).await, expected, "the preflight must size the gallery the export will actually write" ); assert_eq!(expected, 3_500); } /// An event with nothing to archive estimates zero rather than NULL. /// /// PREVENTS: `SUM()` over no rows returning NULL and the decode blowing up — which would abort the /// export with a type error instead of building an (entirely legitimate) empty keepsake. #[sqlx::test] async fn an_empty_gallery_estimates_zero_not_null(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; assert_eq!(estimate_export_bytes(&pool, event_id).await, 0); // And with a user who has uploaded nothing. seed_user(&pool, event_id, "Anna").await; assert_eq!(estimate_export_bytes(&pool, event_id).await, 0); } /// A release arms both halves, so the preflight sees a count of 2 and reserves for the pair. /// /// PREVENTS: the concurrency under-reservation. `spawn_export_jobs` starts the ZIP and HTML workers /// at the same instant, and BOTH are gallery-sized (`Memories.zip` streams the original for every /// video and every image at or under 5 MB, all `Compression::Stored`). A worker reserving only for /// itself would see "it fits", its sibling would independently see the same, and together they /// would ENOSPC — which is why `required_free_bytes` multiplies by this count. #[sqlx::test] async fn a_release_arms_both_halves_so_the_preflight_reserves_for_two(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let user = seed_user(&pool, event_id, "Anna").await; seed_upload(&pool, event_id, user, 1_000, false).await; assert_eq!( armed_job_count(&pool, event_id).await, 0, "nothing is armed before the release" ); let epoch = release_gallery(&pool, "wedding").await.expect("released"); assert_eq!( armed_job_count(&pool, event_id).await, 2, "a release arms zip AND html — both compete for the same disk" ); // A worker that has claimed its half is still competing; `running` must keep counting. assert!(claim_job(&pool, event_id, "zip", epoch).await); assert_eq!( armed_job_count(&pool, event_id).await, 2, "claiming moves pending -> running, which must not drop out of the reservation" ); // Only a FINISHED half stops competing. assert!(finalize_job(&pool, event_id, "zip", epoch, "exports/Gallery.zip").await); assert_eq!( armed_job_count(&pool, event_id).await, 1, "a done half no longer needs space reserved for it" ); } /// A ViewerOnly regeneration re-arms only the HTML half, so the preflight reserves for one. /// /// PREVENTS: over-reservation refusing a rebuild that fits perfectly well. Moderating a comment /// carries the finished ZIP forward untouched; demanding room for a second copy of it would fail /// the one operation that needs no new gallery-sized write at all. #[sqlx::test] async fn a_viewer_only_regeneration_reserves_for_one_half(pool: PgPool) { let event_id = seed_event(&pool, "wedding").await; let user = seed_user(&pool, event_id, "Anna").await; seed_upload(&pool, event_id, user, 1_000, false).await; let epoch = release_gallery(&pool, "wedding").await.expect("released"); for t in ["zip", "html"] { assert!(claim_job(&pool, event_id, t, epoch).await); assert!(finalize_job(&pool, event_id, t, epoch, &format!("exports/{t}")).await); } assert_eq!(armed_job_count(&pool, event_id).await, 0); // A moderated comment: bump the epoch, carry the ZIP forward, re-arm only the viewer. let (_, _, next) = bump_epoch(&pool, "wedding").await.expect("bumped"); assert!( carry_zip_forward(&pool, event_id, next).await, "the finished ZIP is re-stamped, not rebuilt" ); let mut conn = pool.acquire().await.expect("acquire"); enqueue_types_at_epoch(&mut conn, event_id, next, &["html"]).await; assert_eq!( armed_job_count(&pool, event_id).await, 1, "only the viewer is being rebuilt, so only one archive's worth of space is needed" ); }