`backend/tests/` follows a house rule of copying production SQL character-for-
character rather than calling `src/`, because the crate is a binary and nothing
in it is importable from an integration test. For pinning behaviour that already
existed that is a defensible trade. Applied to a NEW fix whose only coverage is
the copy, it proves nothing: the fix and its test become two independent
implementations, and deleting the fix leaves the test green.
`audit_names.rs` did exactly that. It never called `audit::record` — it
reimplemented `resolve_names` and the INSERT inside the test file, down to a
hardcoded `.bind("host")`, and then asserted `actor_role == "host"` against its
own literal. That assertion could not fail for any change to the code it named,
and grep confirmed there was no other coverage of the audit-name work anywhere.
Moved into `#[cfg(test)]` inside `services/audit.rs`, where the real function IS
callable. CI already runs `cargo test --all-features` with a live DATABASE_URL,
so `#[sqlx::test]` works there; verified all four run and pass. The role
assertion now compares against `UserRole::as_str()` itself rather than a literal,
so it tracks a rename instead of pretending to, plus an explicit `assert_ne!`
against the Debug spelling.
Also:
- `retry-after-release.spec.ts` filtered the feed on `u.id === original.id` to
prove "no second row was created". A duplicate gets a fresh uuid and could
never match, so the filter yielded exactly 1 whether the gallery held one copy
or five. Counts by uploader now, with the original's identity asserted
separately. (The rest of that spec is sound — its 403 control and replay-id
check both fail if the header fast-path is reverted.)
- `upload_after_release_commits_sees_the_lock_and_is_rejected` claimed the
handler answers `UploadsLocked`. It answers `GalleryReleased` since the check
order was inverted on this branch, and the test asserts no variant at all.
Documented what it actually covers (the locked READ) and where the ordering IS
covered (two e2e specs).
- Two `// SRC:` pointers had drifted ~130 lines into unrelated code, which is how
a hand-copied fixture silently stops matching its original. Now named, not
numbered.
- `emptyOutDir: false` claimed a failed viewer build "leaves the last good
artifact in place". True for the `generateBundle` error, false for the newer
`writeBundle` assertion, which fires after Vite has already written the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
4.2 KiB
TypeScript
96 lines
4.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|