Files
EventSnap/backend/tests/upload_idempotency.rs
fabi 963f6449a1 feat(db): idempotency keys, ban-aware counts, and a host audit trail
Four migrations, all additive against a database that already has 001-025 applied.

026 narrows the client-upload idempotency index with `AND deleted_at IS NULL`. The old
index made a soft-deleted row keep its key forever, so a guest who deleted a photo and
re-sent the same one had the retry silently swallowed. The new indexed set is a strict
subset of the old, so it cannot fail on existing rows.

027 adds `client_join_id`, which lets a join retry after a lost response resume the same
account instead of 409ing on a name the caller itself owns. Every existing row gets NULL
and the partial index excludes NULLs, so it indexes nothing at creation.

028 brings the feed view's like/comment counts in line with what the feed actually renders:
a banned guest's rows were still counted, so a card showed "3 comments" above two.
`comment.rs` gets the matching `NOT u.is_banned` on the live read path — the export and
hashtag queries already filtered it, so the two views of one moderation action disagreed.

029 records host moderation actions, which were previously invisible after the fact.

Verified by applying 001-029 to a real Postgres against seeded data, including a
soft-deleted row holding a key and a banned user's like and comment. 026's down-migration
legitimately fails where a deleted and a live row share a key — that is inherent to the
direction, documented in the file, and sqlx never runs downs at boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:43:11 +02:00

201 lines
7.7 KiB
Rust

//! DB-backed tests for the upload idempotency key (migration 022).
//!
//! The guarantee under test is the one thing standing between a lost response and a duplicated
//! wedding photo: a retry of an upload that already committed must NOT create a second row, and
//! must not charge the guest's storage quota twice. The whole mechanism is SQL — a partial unique
//! index plus `ON CONFLICT DO NOTHING` — so it is tested against a real database with the real
//! migrations applied, using the same statements `src/` runs.
mod common;
use common::*;
use sqlx::PgPool;
use uuid::Uuid;
/// SRC: `models/upload.rs::Upload::create` — the insert, verbatim.
///
/// Returns the new row's id, or `None` when the key was already stored. The handler treats
/// `None` as "a concurrent retry won" and replays the stored row instead of committing.
async fn create_upload(
pool: &PgPool,
event_id: Uuid,
user_id: Uuid,
original_path: &str,
client_upload_id: Option<Uuid>,
) -> Option<Uuid> {
let row: Option<(Uuid,)> = sqlx::query_as(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL DO NOTHING
RETURNING id",
)
.bind(event_id)
.bind(user_id)
.bind(original_path)
.bind("image/jpeg")
.bind(1_000i64)
.bind(Option::<String>::None)
.bind(client_upload_id)
.fetch_optional(pool)
.await
.expect("create_upload");
row.map(|(id,)| id)
}
/// SRC: `models/upload.rs::Upload::find_by_client_upload_id` — the lookup, verbatim.
async fn find_by_key(pool: &PgPool, user_id: Uuid, client_upload_id: Uuid) -> Option<Uuid> {
let row: Option<(Uuid,)> = sqlx::query_as(
"SELECT id FROM upload
WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
)
.bind(client_upload_id)
.bind(user_id)
.fetch_optional(pool)
.await
.expect("find_by_key");
row.map(|(id,)| id)
}
async fn upload_count(pool: &PgPool) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM upload")
.fetch_one(pool)
.await
.expect("upload_count")
}
/// The core guarantee. A phone that loses the response and re-sends the same photo gets the
/// original row back, not a second copy in the gallery and a second charge against its quota.
#[sqlx::test]
async fn the_same_key_can_only_ever_store_one_upload(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Wackelige Wanda").await;
let key = Uuid::new_v4();
let first = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
assert!(first.is_some(), "the first attempt must store the upload");
// The retry: same key, and (as after a real re-send) a different file on disk.
let second = create_upload(&pool, event_id, user_id, "originals/b.jpg", Some(key)).await;
assert!(
second.is_none(),
"a retry of a committed upload must not insert a second row"
);
assert_eq!(upload_count(&pool).await, 1);
// And the handler can find the winner to replay it.
assert_eq!(find_by_key(&pool, user_id, key).await, first);
}
/// The index must not over-reach. Two genuinely different photos carry different keys and must
/// both land — this is the ordinary case, and breaking it would silently drop uploads.
#[sqlx::test]
async fn different_keys_are_different_uploads(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Fleißige Frieda").await;
for _ in 0..5 {
assert!(
create_upload(
&pool,
event_id,
user_id,
"originals/x.jpg",
Some(Uuid::new_v4())
)
.await
.is_some()
);
}
assert_eq!(upload_count(&pool).await, 5);
}
/// The index is PARTIAL, and this is why. Every upload that predates migration 022, and any
/// client that doesn't send a key, carries NULL — if those collided, the first such upload would
/// block every subsequent one and the whole event would fail after one photo.
#[sqlx::test]
async fn uploads_without_a_key_never_collide(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Alte Anna").await;
for _ in 0..5 {
assert!(
create_upload(&pool, event_id, user_id, "originals/legacy.jpg", None)
.await
.is_some(),
"a NULL key must never be treated as a duplicate"
);
}
assert_eq!(upload_count(&pool).await, 5);
}
/// The lookup is scoped to the owner. The key alone is unique, so this can only matter if a key
/// ever repeated across users — but a replay that handed one guest another guest's upload row
/// would be a data leak, so the scope is asserted rather than assumed.
#[sqlx::test]
async fn the_replay_lookup_never_crosses_users(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let owner = seed_user(&pool, event_id, "Besitzerin Bea").await;
let other = seed_user(&pool, event_id, "Fremder Franz").await;
let key = Uuid::new_v4();
let id = create_upload(&pool, event_id, owner, "originals/a.jpg", Some(key)).await;
assert_eq!(find_by_key(&pool, owner, key).await, id);
assert_eq!(
find_by_key(&pool, other, key).await,
None,
"another guest's retry must not resolve to this upload"
);
}
/// A deleted photo must not be resurrected by a stale queue item. If the guest uploaded, deleted,
/// and their queue then retried the original request, replaying the deleted row would put the
/// photo they removed back in the gallery — so the lookup excludes soft-deleted rows and the
/// retry becomes a fresh upload instead.
#[sqlx::test]
async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Reumütige Rita").await;
let key = Uuid::new_v4();
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
.await
.expect("first insert");
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("soft delete");
assert_eq!(
find_by_key(&pool, user_id, key).await,
None,
"a soft-deleted upload must not be replayed"
);
// The other half of that rule, and the half that was missing (H9). Asserting only that the
// lookup returns None left the index free to disagree with it: migration 022's predicate
// covered soft-deleted rows, so the retry's INSERT hit `ON CONFLICT DO NOTHING` against the
// dead row, the replay lookup above then found nothing, and the handler answered 409 — which
// the client classifies terminal and purges the blob for. The photo was gone from the phone
// AND absent from the gallery, with no way back.
//
// Migration 026 narrowed the index to live rows so a retry after a delete inserts a FRESH
// upload, which is what `find_by_client_upload_id`'s own doc comment always claimed happened.
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
assert!(
retried.is_some(),
"a retry after the guest deleted the photo must create a fresh upload, not 409 forever"
);
assert_ne!(
retried,
Some(id),
"the retry must be a new row, not the dead one"
);
assert_eq!(
find_by_key(&pool, user_id, key).await,
retried,
"the live row is the one the replay lookup must now find"
);
}