/** * A retry of a photo that was ALREADY STORED must return that photo — even after the gallery has * been released. * * The idempotency key arrives as a multipart field as well, and there is a replay for it in the * handler; but a field cannot be read until the body is being parsed, which happens after the * lock/release pre-flight. So the replay was unreachable in precisely the case that matters: * * the photo commits → the response is lost on the way back (the flaky-wifi failure the key * exists for) → the host releases the gallery at the end of the night → the phone retries → * `gallery_released`. * * The guest is then told a photo that is sitting in the gallery was never sent, and the remedy the * client offers — ask the hosts to reopen — bumps `export_epoch`, retiring the whole keepsake and * forcing a rebuild, to re-send something that was never missing. * * The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line. */ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { BASE } from '../../helpers/env'; const SLUG = 'e2e-test-event'; function sample(): Buffer { return readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')); } test.describe('Upload — a retry after release replays instead of refusing', () => { test('the stored photo comes back, and the guest is not told to reopen the gallery', async ({ guest, db, }) => { const g = await guest('WiederholerWilli'); const key = crypto.randomUUID(); // 1. The upload commits. In the real failure the guest never sees this response. const first = await uploadRaw(g.jwt, sample(), { filename: 'a.jpg', contentType: 'image/jpeg', clientUploadId: key, }); expect(first.status).toBe(201); const original = await first.json(); // 2. The host releases the gallery — the end-of-event action every queue runs into. await db.setExportReleased(SLUG, true); // A DIFFERENT photo must still be refused: this is the control that proves the release is // actually in effect, so the replay below is not just "the gate was open all along". const stranger = await uploadRaw(g.jwt, sample(), { filename: 'b.jpg', contentType: 'image/jpeg', clientUploadId: crypto.randomUUID(), }); expect(stranger.status, 'a genuinely new upload must still be refused after release').toBe(403); expect((await stranger.json()).error).toBe('gallery_released'); // 3. The phone retries the FIRST photo. It is already in the gallery, so the honest answer is // the stored row — not "the gallery is closed". const retry = await uploadRaw(g.jwt, sample(), { filename: 'a.jpg', contentType: 'image/jpeg', clientUploadId: key, }); expect( retry.status, 'a retry of an already-stored photo must be replayed, not refused with gallery_released' ).toBe(200); const replayed = await retry.json(); expect(replayed.id, 'the replay must return the ORIGINAL upload, not a new one').toBe( original.id ); // 4. And no second row was created — the whole point of the key. // // Counted by UPLOADER, not by `id`. Filtering on `u.id === original.id` looks like a duplicate // check and is not one: a duplicate row gets a fresh uuid, so it could never match, and the // filter yields exactly 1 whether the gallery holds one copy or five. This guest uploaded once // successfully ('a.jpg'); 'b.jpg' was refused at step 2 and the retry must have replayed rather // than stored, so their total must be exactly one. const feed = await fetch(`${BASE}/api/v1/feed?limit=100`, { headers: { Authorization: `Bearer ${g.jwt}` }, }); const items: any[] = (await feed.json()).uploads ?? []; const mine = items.filter((u) => u.user_id === g.userId); expect( mine.length, `the retry must not have stored a second copy; got ${mine.map((u) => u.id).join(', ')}` ).toBe(1); expect(mine[0].id, 'and the one that exists is the original').toBe(original.id); }); });