All 40 backend tests were pure-function tests — not one line of SQL ran under `cargo test`,
even though the riskiest code in the repo is SQL. Add 12 DB-backed `#[sqlx::test]` tests
(fresh throwaway DB per test, real migrations) pinning the invariants that code is
load-bearing for, each mutation-verified to fail on broken code:
export_epoch.rs (8): epoch is post-increment on release (a worker born pre-increment is
inert); epoch strictly monotonic across reopen; a retired-epoch worker's writes are
no-ops; export_current is EXACTLY the invariant (8-case table); the ViewerOnly
carry-forward carries a done ZIP forward AND matches nothing when the ZIP is unfinished
(the af997a8 strand bug); boot recovery doesn't clobber a live ZIP.
upload_concurrency.rs (4): the atomic quota UPDATE stops two stale-snapshot uploads from
both committing (sequential AND concurrent-tx via EPQ); the FOR SHARE upload lock
serializes against release, blocking a photo from committing after the export snapshot.
Deleting FOR SHARE, or the carry-forward's `status='done'`, each makes a test fail.
Test isolation: TRUNCATE now also resets disk_cache and sse_tickets. The stale disk
reading was harmless only while quotas were globally off in e2e (they no longer are — see
the new quota spec), i.e. two holes were masking each other.
clippy: `cargo clippy --all-targets -- -D warnings` now passes (it did not). Deleted the
dead jobs.rs (an unused BackgroundJob sketch) and unused model methods; `#[allow(dead_code)]`
+ comment on the sqlx FromRow field-sets that ARE populated by the DB. No SQL or logic
changed. `cargo test` requires DATABASE_URL by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
8.2 KiB
Rust
258 lines
8.2 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()
|
|
}
|