//! DB-backed tests for the deleted-media sweep (`services/maintenance.rs`). //! //! Context, in two halves. //! //! 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. //! //! The SAME hole was reachable by the ordinary path, and that one is not an edge case at all: //! `soft_delete_in_event` refunds `total_upload_bytes` on every guest or host delete and nothing //! removed the files, so the quota stopped bounding the disk. Upload 500 MB, delete, quota back to //! zero, upload another 500 MB — a guest curating their camera roll, which is what people do. The //! sweep used to reach only `compression_status = 'failed'`, so it never touched this case; the //! test below that now asserts an owner-deleted upload IS reclaimed is the one that used to assert //! the opposite. //! //! Two windows, because the two deletes mean different things: 14 days for a failure an operator //! may want to investigate, 24 hours for a removal someone asked for (14 days outlives the whole //! event, so a deliberate delete would never reclaim anything while it mattered). //! //! The selection predicate is the whole safety argument — it must reach both leftovers and never a //! live upload — so that is what these pin, following the same "reproduce the SQL verbatim" pattern //! as `upload_concurrency.rs`. `#[sqlx::test]` gives each test a fresh, migrated database. mod common; use common::*; use sqlx::PgPool; use uuid::Uuid; const FAILED_DAYS: i64 = 14; const DELETED_HOURS: i64 = 24; /// SRC: `services/maintenance.rs::cleanup_deleted_media` — the selection, verbatim. async fn sweep_selects(pool: &PgPool, failed_days: i64, deleted_hours: i64) -> Vec { type Row = (Uuid, String, Option, Option, Option); sqlx::query_as::<_, Row>( "SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload WHERE deleted_at IS NOT NULL AND CASE WHEN compression_status = 'failed' THEN deleted_at < NOW() - ($1 || ' days')::interval ELSE deleted_at < NOW() - ($2 || ' hours')::interval END AND (original_path <> '' OR preview_path IS NOT NULL OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)", ) .bind(failed_days.to_string()) .bind(deleted_hours.to_string()) .fetch_all(pool) .await .expect("sweep query") .into_iter() .map(|(id, ..)| id) .collect() } /// Seed an upload aged `deleted_hours_ago` (None = live), with optional derivative paths. async fn seed_aged_upload( pool: &PgPool, event_id: Uuid, user_id: Uuid, status: &str, deleted_hours_ago: Option, original_path: &str, derivatives: bool, ) -> Uuid { sqlx::query_scalar( "INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, compression_status, deleted_at, preview_path, display_path, thumbnail_path) VALUES ($1, $2, $3, 'image/jpeg', 1000, $4, CASE WHEN $5::bigint IS NULL THEN NULL ELSE NOW() - ($5::text || ' hours')::interval END, CASE WHEN $6 THEN 'previews/p.jpg' END, CASE WHEN $6 THEN 'displays/d.jpg' END, CASE WHEN $6 THEN 'thumbs/t.jpg' END) RETURNING id", ) .bind(event_id) .bind(user_id) .bind(original_path) .bind(status) .bind(deleted_hours_ago) .bind(derivatives) .fetch_one(pool) .await .expect("seed upload") } /// A live upload is untouchable no matter how the windows are configured. /// /// PREVENTS: the catastrophic loosening. Everything else here is about reclaiming more; this is the /// one assertion that must never bend. #[sqlx::test] async fn a_live_upload_is_never_selected(pool: PgPool) { let event_id = seed_event(&pool, "sweep-live").await; let user_id = seed_user(&pool, event_id, "Sweeper").await; for status in ["done", "failed", "processing", "pending"] { let live = seed_aged_upload( &pool, event_id, user_id, status, None, "originals/e/live.jpg", true, ) .await; assert!( !sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS) .await .contains(&live), "a non-deleted upload with status {status} must never be swept" ); } } /// THE FIX. An upload a guest or host deliberately deleted is reclaimed once past 24 hours. /// /// PREVENTS: the regression back to a sweep scoped to `compression_status = 'failed'`, which is /// what let the quota stop bounding the disk. This assertion is the inverse of the one this file /// used to make. #[sqlx::test] async fn a_deliberately_deleted_upload_is_reclaimed_after_a_day(pool: PgPool) { let event_id = seed_event(&pool, "sweep-deleted").await; let user_id = seed_user(&pool, event_id, "Curator").await; let deleted = seed_aged_upload( &pool, event_id, user_id, "done", Some(48), "originals/e/owner.jpg", true, ) .await; // Still inside the window — a mis-tap is recoverable for a day. let recent = seed_aged_upload( &pool, event_id, user_id, "done", Some(2), "originals/e/recent.jpg", true, ) .await; let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await; assert!( selected.contains(&deleted), "a deliberate delete past the window must be reclaimed — this is the leak" ); assert!( !selected.contains(&recent), "a delete inside the window keeps its recovery grace" ); } /// The two windows are independent: a failure is retained far longer than a deliberate delete. /// /// PREVENTS: collapsing them into one. Applying 24h to failures would destroy the recovery window /// the retained-original fix exists to provide; applying 14 days to deliberate deletes would mean /// nothing is ever reclaimed during an event. #[sqlx::test] async fn the_two_retention_windows_do_not_bleed_into_each_other(pool: PgPool) { let event_id = seed_event(&pool, "sweep-windows").await; let user_id = seed_user(&pool, event_id, "Windows").await; // 48h old: past the deliberate window, nowhere near the failure window. let failed_recent = seed_aged_upload( &pool, event_id, user_id, "failed", Some(48), "originals/e/f-recent.jpg", false, ) .await; let deleted_same_age = seed_aged_upload( &pool, event_id, user_id, "done", Some(48), "originals/e/d-same.jpg", false, ) .await; // 30 days old: past both. let failed_old = seed_aged_upload( &pool, event_id, user_id, "failed", Some(30 * 24), "originals/e/f-old.jpg", false, ) .await; let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await; assert!( !selected.contains(&failed_recent), "a 2-day-old compression failure is still inside its 14-day recovery window" ); assert!( selected.contains(&deleted_same_age), "a deliberate delete of the same age is past its 24-hour window" ); assert!( selected.contains(&failed_old), "a 30-day-old failure is past both windows" ); } /// Boundary behaviour on both windows. #[sqlx::test] async fn retention_windows_are_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 cases = [ ("failed", 13 * 24, false, "13 days"), ("failed", 15 * 24, true, "15 days"), ("done", 23, false, "23 hours"), ("done", 25, true, "25 hours"), ]; for (status, hours, expected, label) in cases { let id = seed_aged_upload( &pool, event_id, user_id, status, Some(hours), "originals/e/b.jpg", false, ) .await; assert_eq!( sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS) .await .contains(&id), expected, "a {status} upload deleted {label} ago: expected swept={expected}" ); sqlx::query("DELETE FROM upload WHERE id = $1") .bind(id) .execute(&pool) .await .expect("clean up"); } } /// A row is re-selected until EVERY one of its paths is cleared. /// /// PREVENTS: two failures at once. The sweep used to clear `original_path` alone, which was right /// for its only case (a failed compression produces no derivatives) but leaves preview, display and /// thumbnail on disk the moment it reaches a successfully processed upload — three files per /// upload, none of them counted in `original_size_bytes`, that nothing else ever removes. And a row /// whose paths are all cleared must stop coming back, or every hourly tick logs a phantom reclaim /// forever. #[sqlx::test] async fn a_row_is_reselected_until_every_path_is_cleared(pool: PgPool) { let event_id = seed_event(&pool, "sweep-idempotent").await; let user_id = seed_user(&pool, event_id, "Idem").await; let id = seed_aged_upload( &pool, event_id, user_id, "done", Some(48), "originals/e/once.jpg", true, ) .await; assert_eq!(sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await, [id]); // Clearing only the original is NOT enough — the derivatives are still on disk. sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1") .bind(id) .execute(&pool) .await .expect("clear original"); assert_eq!( sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await, [id], "derivatives left behind must keep the row selected" ); sqlx::query( "UPDATE upload SET preview_path = NULL, display_path = NULL, thumbnail_path = NULL WHERE id = $1", ) .bind(id) .execute(&pool) .await .expect("clear derivatives"); assert!( sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS) .await .is_empty(), "a fully swept row must not come back" ); } /// The derivative backfill must never resurrect what the sweep just reclaimed. /// /// PREVENTS: an interaction, not a bug in either piece. The sweep nulls `preview_path`, and /// `backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT NULL` — /// close enough that a future edit to either could have the backfill re-decode an original that is /// no longer on disk, on every boot. `deleted_at IS NULL` is what keeps them apart. #[sqlx::test] async fn the_backfill_ignores_swept_rows(pool: PgPool) { let event_id = seed_event(&pool, "sweep-backfill").await; let user_id = seed_user(&pool, event_id, "Backfill").await; seed_aged_upload( &pool, event_id, user_id, "done", Some(48), "originals/e/gone.jpg", true, ) .await; // SRC: `services/compression.rs::backfill_stale_derivatives` — the selection, verbatim. let backfilled: Vec<(Uuid, String, String)> = sqlx::query_as( "SELECT id, original_path, mime_type FROM upload WHERE deleted_at IS NULL AND mime_type LIKE 'image/%' AND original_path IS NOT NULL AND ( (display_path IS NULL AND preview_path IS NOT NULL) OR derivatives_rev < $1 )", ) .bind(1i16) .fetch_all(&pool) .await .expect("backfill query"); assert!( backfilled.is_empty(), "a soft-deleted row must be invisible to the backfill, before or after sweeping" ); }