Files
EventSnap/backend/tests/common/mod.rs
fabi 281eb3bec7 fix(export): refuse an export that cannot fit, and stop peaking at two generations
Nothing in export.rs ever asked whether the keepsake would fit. Both archives write
their media `Compression::Stored`, so each is essentially a byte-for-byte second copy
of the originals -- Gallery.zip always, and Memories.zip for every video and every
image at or under 5 MB. On the documented CX33 (80 GB, all three volumes on one
filesystem) the upload quota's fixed point leaves ~40 GB free, and a release spawns
BOTH halves concurrently against it.

The failure is not "the export failed", it is "the deliverable is stuck":

  1. ENOSPC lands partway through a multi-GB write.
  2. The epoch has already moved, so the job row is `failed` at the CURRENT
     generation and readiness (epoch = event.export_epoch AND status = 'done') is
     false -- GET /export/zip 404s.
  3. The last good archive sits on disk, unreferenced and unreachable.
  4. POST /host/export/rebuild, the only escape, re-arms the same doomed write.

Three changes.

Reclaim before building. `prune_stale_export_files` ran only after the new archive
was written, renamed and finalised. That reads as durability but buys nothing: the
moment `invalidate_and_arm` bumps the epoch the old archive is ALREADY unreachable,
so keeping it reserves gigabytes for a download nobody can perform -- and for a
takedown it is content someone explicitly asked to have removed. Peak usage is now
one generation. Narrower than the post-finalize prune on purpose: final archives
only, never a `.tmp` or a `viewer_tmp_` dir, since a superseded worker can still be
streaming into those and at build START is far more likely to be alive.

Preflight the space. SUM(original_size_bytes) over exactly `query_uploads`'
visibility filter, +10% for ZIP overhead, multiplied by the number of armed jobs --
without that multiplier each of the two concurrent halves independently sees "it
fits" and together they don't. Runs AFTER claim_job, not before as reported: bailing
before the claim leaves the row `pending` with no worker and no error, the
spinner-forever state `mark_failed`'s status guard exists to prevent. Fails open when
the mount can't be read, exactly as the upload quota does.

Show the host the reason. /export/status returned {status, progress_pct} and nothing
else, so the host dashboard could only render "fehlgeschlagen" next to the retry
button. The message was written to the row and surfaced solely in the ADMIN job list
-- a different screen, possibly a different person. It now travels with the status,
and only on a failure, so a message left on a since-succeeded row can't appear beside
a green "ist bereit".

Tests: 10 unit (the u128 clamp caught a real bug in the first draft -- saturating_mul
then /100 turns an overflow into a number ~100x too small, the one direction that
authorises the write being guarded against; the carried-forward archive must survive
its own older epoch in the filename), 4 DB-backed (the estimate is asserted against
the row set the archive actually contains, not against a restatement of the WHERE
clause, so the two queries cannot drift), 3 e2e over the four-hop plumbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:38:26 +02:00

340 lines
11 KiB
Rust

//! Shared fixtures for the DB-backed integration tests.
//!
//! Every helper here executes SQL that is **character-for-character identical** to what `src/`
//! actually runs (see the `// SRC:` markers). That is the whole point: a paraphrased query is a
//! query nobody runs, and a test that passes against a paraphrase proves nothing about production.
#![allow(dead_code)] // each integration-test crate uses a different subset of these helpers
use sqlx::PgPool;
use uuid::Uuid;
/// Insert a bare, unreleased event (epoch 0, uploads open).
pub async fn seed_event(pool: &PgPool, slug: &str) -> Uuid {
sqlx::query_scalar("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING id")
.bind(slug)
.bind("Hochzeit")
.fetch_one(pool)
.await
.expect("seed event")
}
/// Insert a guest with a zeroed byte total.
pub async fn seed_user(pool: &PgPool, event_id: Uuid, name: &str) -> Uuid {
sqlx::query_scalar(
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
VALUES ($1, $2, 'x') RETURNING id",
)
.bind(event_id)
.bind(name)
.fetch_one(pool)
.await
.expect("seed user")
}
/// SRC: `handlers/host.rs::release_gallery` — the claim + epoch bump, verbatim.
/// Returns the POST-increment epoch, exactly as the handler consumes it.
pub async fn release_gallery(pool: &PgPool, slug: &str) -> Option<i64> {
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
"UPDATE event
SET export_released_at = NOW(),
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
export_epoch = export_epoch + 1
WHERE slug = $1 AND export_released_at IS NULL
RETURNING id, name, export_epoch",
)
.bind(slug)
.fetch_optional(pool)
.await
.expect("release_gallery");
if let Some((event_id, _, epoch)) = claimed {
// The handler arms both jobs in the SAME transaction; for a single-connection fixture the
// sequencing is equivalent.
let mut conn = pool.acquire().await.expect("acquire");
enqueue_types_at_epoch(&mut conn, event_id, epoch, &["zip", "html"]).await;
Some(epoch)
} else {
None
}
}
/// SRC: `handlers/host.rs::open_event` — the one statement that retires an entire generation.
/// Returns rows affected.
pub async fn open_event(pool: &PgPool, slug: &str) -> u64 {
sqlx::query(
"UPDATE event
SET uploads_locked_at = NULL,
export_released_at = NULL,
export_epoch = export_epoch + 1
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
)
.bind(slug)
.execute(pool)
.await
.expect("open_event")
.rows_affected()
}
/// SRC: `services/export.rs::enqueue_types_at_epoch` — verbatim upsert.
pub async fn enqueue_types_at_epoch(
conn: &mut sqlx::PgConnection,
event_id: Uuid,
epoch: i64,
types: &[&str],
) {
for export_type in types {
sqlx::query(
"INSERT INTO export_job (event_id, type, status, progress_pct, epoch)
VALUES ($1, $2::export_type, 'pending', 0, $3)
ON CONFLICT (event_id, type) DO UPDATE
SET status = 'pending', progress_pct = 0, file_path = NULL,
error_message = NULL, completed_at = NULL,
epoch = EXCLUDED.epoch
WHERE export_job.status <> 'done' OR export_job.epoch <> EXCLUDED.epoch",
)
.bind(event_id)
.bind(export_type)
.bind(epoch)
.execute(&mut *conn)
.await
.expect("enqueue_types_at_epoch");
}
}
/// SRC: `services/export.rs::claim_job` — verbatim. `true` = we won the generation.
pub async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str, epoch: i64) -> bool {
sqlx::query(
"UPDATE export_job SET status = 'running'
WHERE event_id = $1 AND type = $2::export_type
AND epoch = $3 AND status = 'pending'",
)
.bind(event_id)
.bind(export_type)
.bind(epoch)
.execute(pool)
.await
.expect("claim_job")
.rows_affected()
> 0
}
/// SRC: `services/export.rs::finalize_job` — verbatim. This IS the publish step.
pub async fn finalize_job(
pool: &PgPool,
event_id: Uuid,
export_type: &str,
epoch: i64,
file_path: &str,
) -> bool {
sqlx::query(
"UPDATE export_job
SET status = 'done', progress_pct = 100, file_path = $3, completed_at = NOW()
WHERE event_id = $1 AND type = $2::export_type
AND epoch = $4 AND status = 'running'",
)
.bind(event_id)
.bind(export_type)
.bind(file_path)
.bind(epoch)
.execute(pool)
.await
.expect("finalize_job")
.rows_affected()
> 0
}
/// SRC: `services/export.rs::update_progress` — verbatim. Doubles as the worker's liveness check:
/// `false` means "your generation was retired, stop working".
pub async fn update_progress(
pool: &PgPool,
event_id: Uuid,
export_type: &str,
epoch: i64,
pct: i16,
) -> bool {
sqlx::query(
"UPDATE export_job SET progress_pct = $3
WHERE event_id = $1 AND type = $2::export_type
AND epoch = $4 AND status = 'running'",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.bind(epoch)
.execute(pool)
.await
.expect("update_progress")
.rows_affected()
> 0
}
/// SRC: `services/export.rs::invalidate_and_arm` — the ViewerOnly ZIP carry-forward, verbatim.
/// Returns `rows_affected() == 1`, which is what the production code branches on.
pub async fn carry_zip_forward(pool: &PgPool, event_id: Uuid, epoch: i64) -> bool {
sqlx::query(
"UPDATE export_job SET epoch = $2
WHERE event_id = $1 AND type = 'zip'::export_type
AND status = 'done' AND epoch = $2 - 1",
)
.bind(event_id)
.bind(epoch)
.execute(pool)
.await
.expect("carry_zip_forward")
.rows_affected()
== 1
}
/// SRC: `services/export.rs::invalidate_and_arm` — the epoch bump, verbatim.
pub async fn bump_epoch(pool: &PgPool, slug: &str) -> Option<(Uuid, String, i64)> {
sqlx::query_as(
"UPDATE event SET export_epoch = export_epoch + 1
WHERE slug = $1 AND export_released_at IS NOT NULL
RETURNING id, name, export_epoch",
)
.bind(slug)
.fetch_optional(pool)
.await
.expect("bump_epoch")
}
/// The event's authoritative epoch.
pub async fn event_epoch(pool: &PgPool, event_id: Uuid) -> i64 {
sqlx::query_scalar("SELECT export_epoch FROM event WHERE id = $1")
.bind(event_id)
.fetch_one(pool)
.await
.expect("event_epoch")
}
/// The raw job row, bypassing `export_current` — what the WORKER sees.
pub async fn job_row(
pool: &PgPool,
event_id: Uuid,
export_type: &str,
) -> Option<(String, i64, Option<String>)> {
sqlx::query_as(
"SELECT status::text, epoch, file_path FROM export_job
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.fetch_optional(pool)
.await
.expect("job_row")
}
/// Does `export_current` expose this job at all? (The view itself, without the `status` filter —
/// it is what `handlers/admin.rs::export_status` reports to the host UI.)
pub async fn in_export_current(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM export_current
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.fetch_one(pool)
.await
.expect("in_export_current")
> 0
}
/// THE download predicate. SRC: `handlers/admin.rs::download_export` reads exactly this shape —
/// `SELECT c.file_path FROM export_current c WHERE ... AND c.status = 'done'`. If this returns
/// `Some`, a guest can download the keepsake; if `None`, they get a 404.
pub async fn downloadable(pool: &PgPool, event_id: Uuid, export_type: &str) -> Option<String> {
sqlx::query_scalar(
"SELECT c.file_path FROM export_current c
WHERE c.event_id = $1 AND c.type = $2::export_type AND c.status = 'done'",
)
.bind(event_id)
.bind(export_type)
.fetch_optional(pool)
.await
.expect("downloadable")
.flatten()
}
/// Insert an upload of `size` bytes, optionally already soft-deleted.
pub async fn seed_upload(
pool: &PgPool,
event_id: Uuid,
user_id: Uuid,
size: i64,
deleted: bool,
) -> Uuid {
sqlx::query_scalar(
"INSERT INTO upload (event_id, user_id, original_path, mime_type,
original_size_bytes, deleted_at)
VALUES ($1, $2, 'originals/x.jpg', 'image/jpeg', $3,
CASE WHEN $4 THEN NOW() ELSE NULL END)
RETURNING id",
)
.bind(event_id)
.bind(user_id)
.bind(size)
.bind(deleted)
.fetch_one(pool)
.await
.expect("seed upload")
}
/// Flip the moderation flags a ban sets.
pub async fn set_user_moderation(pool: &PgPool, user_id: Uuid, banned: bool, hidden: bool) {
sqlx::query("UPDATE \"user\" SET is_banned = $2, uploads_hidden = $3 WHERE id = $1")
.bind(user_id)
.bind(banned)
.bind(hidden)
.execute(pool)
.await
.expect("set moderation");
}
/// SRC: `services/export.rs::query_uploads` — the visibility filter, verbatim, projected down to
/// `(id, original_size_bytes)`. This is the row set that ACTUALLY lands in the archives.
pub async fn export_visible_uploads(pool: &PgPool, event_id: Uuid) -> Vec<(Uuid, i64)> {
sqlx::query_as(
"SELECT u.id, u.original_size_bytes
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
WHERE u.event_id = $1 AND u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await
.expect("export_visible_uploads")
}
/// SRC: `services/export.rs::estimate_export_bytes` — verbatim.
pub async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> i64 {
let (bytes,): (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
WHERE u.event_id = $1 AND u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE",
)
.bind(event_id)
.fetch_one(pool)
.await
.expect("estimate_export_bytes");
bytes
}
/// SRC: `services/export.rs::ensure_export_space` — the armed-job count, verbatim.
pub async fn armed_job_count(pool: &PgPool, event_id: Uuid) -> i64 {
let (n,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM export_job
WHERE event_id = $1 AND status IN ('pending', 'running')",
)
.bind(event_id)
.fetch_one(pool)
.await
.expect("armed_job_count");
n
}