//! DB-backed tests for the failed-original sweep (`services/maintenance.rs`). //! //! Context: the compression worker deliberately no longer deletes an upload's original when //! its transcode fails — a transient ENOSPC or a codec panic must never destroy the only //! copy of a photo a guest cannot retake. But the row is soft-deleted and the uploader's //! quota IS refunded, so those bytes become invisible, unowned and free. A guest hitting a //! reproducible codec failure could accumulate orphans at no personal cost, and since //! `active_uploaders` counts only users with non-deleted uploads, dropping out of that count //! actually RAISES everyone's per-user ceiling while the disk fills. //! //! The sweep reclaims them after a retention window. Its selection predicate is the whole //! safety argument — it must reach the give-up path's leftovers and nothing else — so that //! is what these tests pin, following the same "reproduce the SQL verbatim" pattern as //! `upload_concurrency.rs`. //! //! `#[sqlx::test]` gives each test a fresh database with the real migrations applied. mod common; use common::*; use sqlx::PgPool; use uuid::Uuid; /// SRC: `services/maintenance.rs::cleanup_failed_originals` — the selection, verbatim. async fn sweep_selects(pool: &PgPool, retention_days: i64) -> Vec { sqlx::query_as::<_, (Uuid, String)>( "SELECT id, original_path FROM upload WHERE compression_status = 'failed' AND deleted_at IS NOT NULL AND deleted_at < NOW() - ($1 || ' days')::interval AND original_path <> ''", ) .bind(retention_days.to_string()) .fetch_all(pool) .await .expect("sweep query") .into_iter() .map(|(id, _)| id) .collect() } #[allow(clippy::too_many_arguments)] async fn seed_upload( pool: &PgPool, event_id: Uuid, user_id: Uuid, status: &str, deleted_days_ago: Option, original_path: &str, ) -> Uuid { let id: Uuid = sqlx::query_scalar( "INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, compression_status, deleted_at) VALUES ($1, $2, $3, 'image/jpeg', 1000, $4, CASE WHEN $5::bigint IS NULL THEN NULL ELSE NOW() - ($5::text || ' days')::interval END) RETURNING id", ) .bind(event_id) .bind(user_id) .bind(original_path) .bind(status) .bind(deleted_days_ago) .fetch_one(pool) .await .expect("seed upload"); id } #[sqlx::test] async fn sweeps_only_long_failed_soft_deleted_uploads(pool: PgPool) { let event_id = seed_event(&pool, "sweep-event").await; let user_id = seed_user(&pool, event_id, "Sweeper").await; // The one and only thing the sweep may touch: the exact state the compression worker's // give-up path leaves behind, aged past the window. let target = seed_upload( &pool, event_id, user_id, "failed", Some(30), "originals/e/target.jpg", ) .await; // Everything below is a near-miss that must survive. // A healthy live upload — the catastrophic case if the predicate were ever loosened. let live = seed_upload( &pool, event_id, user_id, "done", None, "originals/e/live.jpg", ) .await; // Failed but still inside the retention window: the recovery window is the entire point // of keeping the file, so reclaiming it early would defeat the fix it protects. let recent = seed_upload( &pool, event_id, user_id, "failed", Some(1), "originals/e/recent.jpg", ) .await; // Failed but NOT soft-deleted — not the give-up path; something else set this status. let failed_live = seed_upload( &pool, event_id, user_id, "failed", None, "originals/e/failed-live.jpg", ) .await; // Soft-deleted by the OWNER, compression fine. Its file is already gone; this row must // never be re-processed. let owner_deleted = seed_upload( &pool, event_id, user_id, "done", Some(30), "originals/e/owner.jpg", ) .await; // Already swept: `original_path` cleared. Re-selecting it every hour would log a // phantom reclaim forever. let already_swept = seed_upload(&pool, event_id, user_id, "failed", Some(30), "").await; let selected = sweep_selects(&pool, 14).await; assert_eq!( selected, vec![target], "the sweep must select exactly the aged give-up-path leftovers" ); for (id, what) in [ (live, "a live upload"), (recent, "a failure still inside the retention window"), (failed_live, "a failed but not soft-deleted upload"), (owner_deleted, "an owner-deleted upload"), (already_swept, "an already-swept row"), ] { assert!(!selected.contains(&id), "the sweep must not touch {what}"); } } #[sqlx::test] async fn retention_window_is_honoured_at_the_boundary(pool: PgPool) { let event_id = seed_event(&pool, "sweep-boundary").await; let user_id = seed_user(&pool, event_id, "Boundary").await; let inside = seed_upload( &pool, event_id, user_id, "failed", Some(13), "originals/e/inside.jpg", ) .await; let outside = seed_upload( &pool, event_id, user_id, "failed", Some(15), "originals/e/outside.jpg", ) .await; let selected = sweep_selects(&pool, 14).await; assert!( selected.contains(&outside), "15 days old must be past a 14-day window" ); assert!( !selected.contains(&inside), "13 days old must still be retained" ); } #[sqlx::test] async fn clearing_original_path_makes_the_sweep_idempotent(pool: PgPool) { // The sweep clears `original_path` after reclaiming the file. Without that, a row whose // file is already gone is re-selected on every hourly tick forever. let event_id = seed_event(&pool, "sweep-idempotent").await; let user_id = seed_user(&pool, event_id, "Idem").await; let id = seed_upload( &pool, event_id, user_id, "failed", Some(30), "originals/e/once.jpg", ) .await; assert_eq!(sweep_selects(&pool, 14).await, vec![id]); sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1") .bind(id) .execute(&pool) .await .expect("clear path"); assert!( sweep_selects(&pool, 14).await.is_empty(), "a swept row must not come back" ); }