Files
EventSnap/e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts
fabi bbdfae09a0 chore(e2e): add ESLint + Prettier; fix real findings; dedupe BASE
The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).

Rules keep the ones that catch real TEST bugs and drop the noise:
  - no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
    passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
  - no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
    `api` arg, an unused JPEG_MAGIC import), all removed.
  - no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
    a page.evaluate() return.
  - no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.

Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.

Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:45:59 +02:00

722 lines
34 KiB
TypeScript

/**
* Regression guard — reopen→re-release export integrity + the stale-keepsake data-loss fix (H1).
*
* Two related bugs on this path:
*
* (1) Temp-file race: a second worker spawned by a re-release could stomp the first
* worker's FIXED temp path → a corrupt/truncated keepsake ZIP.
*
* (2) Stale keepsake (silent data loss): a worker from the PRIOR release, still streaming a
* snapshot taken BEFORE a reopen, would finish and unconditionally re-flip
* `export_*_ready = TRUE` on an archive that predated the reopen — so uploads added
* during the reopen window were permanently missing from a "ready" keepsake.
*
* The fix (migration 014): ONE monotonic `event.export_epoch`, bumped in the SAME UPDATE as any
* change to `export_released_at` — release and reopen are its only writers. Each export_job row
* carries a COPY of the epoch it was enqueued for. An export is downloadable IFF
* `released AND job.epoch = event.export_epoch AND job.status = 'done'` — readiness is DERIVED,
* never stored, so it cannot drift and no worker can resurrect it.
*
* The consequence that kills the whole bug class: a worker holding a retired epoch is INERT BY
* CONSTRUCTION. Every write it makes is guarded on `epoch` on the very row it updates, so it simply
* matches nothing — no cross-table EXISTS (which would be unsound under READ COMMITTED), no flag to
* flip, nothing to skip. Losing a race can no longer corrupt state; it can only waste work.
*
* Coverage:
* - churn integrity: rapid reopen→re-release yields exactly one INTACT ZIP, no stuck jobs.
* - completeness: an upload added during the reopen window IS present in the re-released
* keepsake (the direct data-loss regression).
* - supersession (re-release): a re-release SUPERSEDES an in-flight (running) job — it bumps the
* generation and regenerates, rather than leaving the stale worker to finalize.
* - supersession (reopen): a reopen ALONE bumps the event epoch, so a worker holding the
* pre-reopen epoch is retired and can publish nothing.
* - acceptance: an upload held open mid-body across a release is REJECTED, not silently dropped
* from the keepsake — the upload-path TOCTOU that was the real cause of the "stale keepsake" bug.
* Deterministic: the body is paused with the pre-flight check already passed.
* - takedown: deleting a photo AFTER release regenerates the keepsake without it.
*/
import { test, expect } from '../../fixtures/test';
import { Client } from 'pg';
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { seedUpload } from '../../helpers/seed';
import { uploadRaw, uploadPausedMidStream } from '../../helpers/upload-client';
import { SseListener } from '../../helpers/sse-listener';
import { BASE } from '../../helpers/env';
const SLUG = 'e2e-test-event';
const N_UPLOADS = 6;
/** A ZIP holding no entries: just the 22-byte end-of-central-directory record. */
const EMPTY_ZIP_BYTES = 22;
const PG = {
host: process.env.E2E_DB_HOST ?? 'localhost',
port: Number(process.env.E2E_DB_PORT ?? '55432'),
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
};
async function pgQuery<T = any>(sql: string): Promise<T[]> {
const c = new Client(PG);
await c.connect();
try {
return (await c.query(sql)).rows as T[];
} finally {
await c.end();
}
}
function post(path: string, jwt: string) {
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
}
async function exportStatus(jwt: string): Promise<any> {
const res = await fetch(BASE + '/api/v1/export/status', {
headers: { Authorization: `Bearer ${jwt}` },
});
return res.json();
}
async function mintTicket(jwt: string): Promise<string> {
const res = await post('/api/v1/export/ticket', jwt);
return (await res.json()).ticket;
}
async function waitExportDone(jwt: string) {
await expect
.poll(
async () => {
const s = await exportStatus(jwt);
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
},
{ timeout: 60_000, intervals: [500] }
)
.toBe(true);
}
/** Download the current ZIP keepsake and return its (non-directory) entry names. */
async function downloadZipEntries(jwt: string): Promise<string[]> {
const ticket = await mintTicket(jwt);
const res = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(200);
const bytes = Buffer.from(await res.arrayBuffer());
expect(bytes.subarray(0, 2).toString('latin1')).toBe('PK'); // ZIP magic — not a stomped file
// A ZIP with no entries is exactly its 22-byte end-of-central-directory record. That is
// structurally VALID (an event with nothing to export), but `unzip -t` exits non-zero on it — so
// check for it explicitly rather than letting it masquerade as corruption.
if (bytes.length === EMPTY_ZIP_BYTES) return [];
const dir = mkdtempSync(join(tmpdir(), 'es-zip-'));
const zipPath = join(dir, 'Gallery.zip');
writeFileSync(zipPath, bytes);
try {
execFileSync('unzip', ['-t', zipPath], { stdio: 'pipe' }); // CRC integrity; throws on corruption
} catch (e: any) {
const out = `${e.stdout?.toString() ?? ''}${e.stderr?.toString() ?? ''}`;
throw new Error(`keepsake ZIP failed integrity check (${bytes.length} bytes):\n${out}`);
}
return execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.endsWith('/'));
}
/** The epoch the CURRENT zip job row was enqueued for. */
async function zipJobEpoch(): Promise<number> {
const [row] = await pgQuery<{ epoch: string }>(
`SELECT ej.epoch FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}' AND ej.type = 'zip'`
);
return Number(row.epoch);
}
/** The event's authoritative generation counter. */
async function eventEpoch(): Promise<number> {
const [row] = await pgQuery<{ export_epoch: string }>(
`SELECT export_epoch FROM event WHERE slug = '${SLUG}'`
);
return Number(row.export_epoch);
}
test.describe('Flow re-review — reopen→re-release export integrity + stale-keepsake (H1)', () => {
// The real export job runs image processing; give it head-room over the tiny fixtures.
test.setTimeout(120_000);
test('rapid release/reopen/re-release yields exactly one INTACT ZIP and no stuck jobs', async ({
host,
}) => {
// Seed real, decodable uploads while the event is open.
for (let i = 0; i < N_UPLOADS; i++) {
await seedUpload(host.jwt, { caption: `pic ${i}` });
}
// First release → export starts and uploads lock (release ⇒ lock).
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
const rejected = await uploadRaw(
host.jwt,
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
{
filename: 'late.jpg',
contentType: 'image/jpeg',
}
);
expect(rejected.status).toBe(403);
// Churn: reopen (retires the generation) then re-release, several times in quick
// succession. This is the path that used to double-spawn workers over a shared temp file.
// End on a release so the gallery is left released.
for (let i = 0; i < 4; i++) {
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
const rel = await post('/api/v1/host/gallery/release', host.jwt);
expect(rel.status).toBe(204);
}
await waitExportDone(host.jwt);
// No export_job may be left stuck 'running'.
const jobs = await pgQuery<{ type: string; status: string }>(
`SELECT ej.type::text AS type, ej.status::text AS status
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}'`
);
expect(jobs.length).toBe(2);
for (const j of jobs) expect(j.status, `${j.type} job status`).toBe('done');
// Exactly one entry per seeded upload — no entry lost to a stomped temp file, none
// duplicated by a second worker.
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(N_UPLOADS);
});
test('an upload added during the reopen window IS present in the re-released keepsake (H1)', async ({
host,
}) => {
// Release an initial keepsake of 3 uploads.
for (let i = 0; i < 3; i++) await seedUpload(host.jwt, { caption: `orig ${i}` });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
expect((await downloadZipEntries(host.jwt)).length).toBe(3);
// Reopen (invalidates the release) → add a 4th upload while unlocked → re-release.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
await seedUpload(host.jwt, { caption: 'added-during-reopen' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// The re-released keepsake MUST contain all 4 — the stale-keepsake bug would have left
// the download at the pre-reopen snapshot of 3, silently dropping the new upload.
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(4);
});
test('re-release SUPERSEDES an in-flight (running) export — bumps the generation', async ({
host,
}) => {
// Deterministic proof of the generation guard, independent of export timing. We pin the
// zip job to `running` with a sentinel to mimic a worker from a prior release that is
// still mid-export, then drive reopen→re-release.
//
// OLD behavior (the bug): the re-enqueue left a `running` row alone, so the stale worker
// would eventually finalize and re-flip the ready flag on its pre-reopen snapshot.
// NEW behavior (the fix): the re-enqueue bumps `epoch` and resets the row, so the
// stale generation is superseded — its sentinel is wiped and a fresh worker regenerates.
await seedUpload(host.jwt, { caption: 'a' });
await seedUpload(host.jwt, { caption: 'b' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const seqBefore = await zipJobEpoch();
// Simulate an in-flight worker owning the zip job at the current generation.
await pgQuery(
`UPDATE export_job ej SET status = 'running', progress_pct = 77
FROM event e
WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
// Reopen (clears release + ready flags AND bumps every export_job's epoch) then
// re-release (bumps + resets again). Either bump alone supersedes the pinned running row.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// The fresh generation must settle to done at a HIGHER epoch — proving the stale
// running row was superseded (not left to finalize) and the sentinel is gone.
await waitExportDone(host.jwt);
const [row] = await pgQuery<{ status: string; progress_pct: number; epoch: string }>(
`SELECT ej.status::text AS status, ej.progress_pct, ej.epoch
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}' AND ej.type = 'zip'`
);
expect(row.status, 'zip job status').toBe('done');
expect(Number(row.progress_pct), 'zip job progress').toBe(100);
expect(Number(row.epoch), 'epoch advanced past the superseded generation').toBeGreaterThan(
seqBefore
);
});
test('a reopen ALONE bumps the epoch — a pre-reopen worker is retired and can publish nothing', async ({
host,
}) => {
// Deterministic proof that a reopen retires the current generation. The nasty interleavings
// (a worker finalizing around a reopen/re-release) can't be forced by sub-millisecond timing —
// but their ROOT cause is observable: whether a reopen moves the epoch. If it does, the epoch
// the worker captured is gone for good, so its epoch-guarded finalize matches nothing no matter
// when it lands, and there is no separate ready flag left for it to resurrect.
await seedUpload(host.jwt, { caption: 'x' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const epochAtRelease = await eventEpoch();
expect(await zipJobEpoch(), 'the done job carries the live epoch').toBe(epochAtRelease);
// Reopen only — no re-release.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
const epochAfterReopen = await eventEpoch();
expect(
epochAfterReopen,
`reopen must move the event epoch past ${epochAtRelease}, retiring any worker holding it`
).toBeGreaterThan(epochAtRelease);
// The job row still carries the OLD epoch — so it no longer matches the event's, which is
// exactly what makes it (and any worker holding it) invisible and inert.
expect(await zipJobEpoch(), 'the stale job row is left behind at the retired epoch').toBe(
epochAtRelease
);
// Readiness is DERIVED, so there is no flag to check — the keepsake simply stops being
// downloadable the moment the epoch moves. Assert the observable contract instead.
const [ev] = await pgQuery<{ released_at: string | null }>(
`SELECT export_released_at AS released_at FROM event WHERE slug = '${SLUG}'`
);
expect(ev.released_at, 'reopen clears the release timestamp').toBeNull();
const ticket = await mintTicket(host.jwt);
const dl = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(dl.status, 'a reopened event serves no keepsake').toBe(404);
});
test('open ‖ release churn always converges to a consistent, downloadable keepsake', async ({
host,
}) => {
// Convergence STRESS test, not a regression guard — be honest about what this does and does
// not prove. `open_event` reopens the event AND bumps every export_job generation, and those
// two statements must be atomic (they run in one transaction): if they were separate
// autocommits, a re-release could slip between them and spawn a FRESH worker whose generation the
// reopen's second statement would then retire — orphaning it and stranding its row at `running`
// forever with the host unable to retry. open_event is now a single UPDATE, so this cannot occur.
//
// That interleaving is NOT reproducible from outside the process (verified: this test passes
// against a deliberately non-transactional build even with ~90 concurrent open/release pairs —
// the window is microseconds). So it does not guard the atomicity fix; that rests on the
// transaction being correct by construction. What this DOES pin down is that hammering both
// endpoints never leaves the export pipeline in a wedged state — no stuck job, no corrupt or
// missing archive — which is the user-visible property we actually care about.
await seedUpload(host.jwt, { caption: 'race' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Hammer both endpoints concurrently. The bad window (between open_event's two autocommit
// statements) is microseconds wide, so a couple of sequential pairs never hit it — we need
// many in-flight requests contending for pool connections to land a release inside it.
// Deliberately unchecked: whichever loses a race legitimately 4xx's ("already released" /
// nothing to reopen). We only care that no interleaving strands a job.
for (let round = 0; round < 15; round++) {
const calls: Promise<unknown>[] = [];
for (let i = 0; i < 6; i++) {
calls.push(post('/api/v1/host/event/open', host.jwt));
calls.push(post('/api/v1/host/gallery/release', host.jwt));
}
await Promise.all(calls);
}
// Land deterministically in a released state so a fresh generation is definitely in flight.
await post('/api/v1/host/event/open', host.jwt);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// An orphaned worker leaves its row `running` forever, so this poll would time out.
await waitExportDone(host.jwt);
const jobs = await pgQuery<{ type: string; status: string }>(
`SELECT ej.type::text AS type, ej.status::text AS status
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}'`
);
expect(jobs.length).toBe(2);
for (const j of jobs) expect(j.status, `${j.type} job must not be stranded`).toBe('done');
// And the keepsake is actually downloadable and intact.
expect((await downloadZipEntries(host.jwt)).length).toBe(1);
});
test('an upload STUCK MID-BODY when the release lands is rejected, not silently lost', async ({
host,
}) => {
// THE bug that survived three rounds of fixes to the export state machine — because it was never
// in the state machine. `upload` checked `uploads_locked_at` BEFORE streaming the body (minutes,
// for a 500 MB video) and committed the row without re-checking. A release landing mid-upload let
// the row commit AFTER the export snapshot: the photo showed up in the live feed and was
// PERMANENTLY missing from the keepsake, with nothing to regenerate it.
//
// This test is DETERMINISTIC, not a hoped-for race. The upload is held open mid-body with its
// pre-flight check already passed, so the release provably lands inside the exact window. The
// server must then reject it at commit time (`FOR SHARE` re-check) — anything else means a photo
// the server accepted is not in the archive.
await seedUpload(host.jwt, { caption: 'pre-race' });
const img = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
const split = Math.floor(img.length / 2);
const paused = uploadPausedMidStream(host.jwt, img.subarray(0, split), img.subarray(split), {
filename: 'stuck.jpg',
contentType: 'image/jpeg',
});
// Wait until the server is genuinely mid-body (past its pre-flight lock check).
await paused.checkPassed;
// Land the release squarely inside that window.
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Now let the body finish. The pre-flight check passed, so ONLY the commit-time re-check can
// save this photo from becoming a phantom.
paused.finish();
const res = await paused.response;
expect(
res.status,
'an upload whose body completed after the release MUST be rejected (403 uploads_locked). ' +
'A 201 here means the server accepted a photo it will never put in the keepsake — silent, ' +
'permanent data loss.'
).toBe(403);
expect((await res.json()).error).toBe('uploads_locked');
// And the keepsake holds exactly the one upload that was genuinely committed before the release.
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(1);
});
test('deleting a photo AFTER release regenerates the keepsake without it (takedown)', async ({
host,
}) => {
// A host deleting a photo on a removal request used to remove it from the feed but leave it in
// the already-generated archive FOREVER — the `if ready { continue }` skip guaranteed the
// export was never rebuilt. The keepsake is the one place it most needs to be gone.
const keep = await seedUpload(host.jwt, { caption: 'keep' });
const takedown = await seedUpload(host.jwt, { caption: 'takedown' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
expect((await downloadZipEntries(host.jwt)).length).toBe(2);
// Take it down while the gallery is released.
const del = await fetch(BASE + `/api/v1/host/upload/${takedown}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(del.status).toBe(204);
// The keepsake must rebuild — and must NOT still contain the removed photo.
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(1);
expect(
listing.some((n) => n.includes(takedown)),
'the taken-down photo must be gone from the regenerated keepsake'
).toBe(false);
expect(
listing.some((n) => n.includes(keep)),
'the kept photo survives'
).toBe(true);
});
test('BANNING a guest after release removes their photos from the keepsake', async ({
api,
host,
guest,
}) => {
// `ban_user` calls `invalidate_and_arm(Affects::Both)` — but every ban in the suite ran against
// an UNRELEASED event, where `invalidate_and_arm` returns early. So this regeneration path was
// dead code under test.
//
// The stakes are the whole point of a ban: the host bans someone for posting something abusive,
// and if the keepsake doesn't rebuild, that content stays in the archive every guest downloads,
// forever. The export query already filters `is_banned` — so the pipeline AGREES the content
// shouldn't be there; only the regeneration was missing.
const offender = await guest('Offender');
const innocent = await guest('Innocent');
const badPhoto = await seedUpload(offender.jwt, { caption: 'abusive' });
const goodPhoto = await seedUpload(innocent.jwt, { caption: 'lovely' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
expect(await downloadZipEntries(host.jwt)).toHaveLength(2);
await api.banUser(host.jwt, offender.userId);
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(
listing.some((n) => n.includes(badPhoto)),
"a banned guest's photo must not survive in the keepsake everyone downloads"
).toBe(false);
expect(
listing.some((n) => n.includes(goodPhoto)),
'everyone else keeps their photos'
).toBe(true);
expect(listing).toHaveLength(1);
});
test('UNBANNING a guest after release restores their photos to the keepsake', async ({
api,
host,
guest,
}) => {
// The mirror image, and the one that LOSES data. A host bans someone by mistake (or bans, then
// reconsiders) — unban must put their photos back into the archive. If the regeneration is
// missing, that guest's photos are absent from the keepsake everyone keeps, and after the
// wedding they are gone: the export is the only copy anyone takes home.
const g = await guest('Forgiven');
const photo = await seedUpload(g.jwt, { caption: 'restore me' });
await seedUpload(host.jwt, { caption: 'host photo' });
await api.banUser(host.jwt, g.userId);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Banned at release time → their photo is correctly absent.
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
const unban = await post(`/api/v1/host/users/${g.userId}/unban`, host.jwt);
expect(unban.status).toBe(204);
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(
listing.some((n) => n.includes(photo)),
'an unbanned guest must get their photos back in the keepsake — it is the only copy anyone keeps'
).toBe(true);
expect(listing).toHaveLength(2);
});
test('a HOST comment takedown after release keeps the keepsake downloadable', async ({
host,
guest,
}) => {
// `DELETE /host/comment/{id}` had ZERO test hits of any kind, and it is one of the two callers
// of the ViewerOnly carry-forward. A comment doesn't live in the ZIP (which holds media), so the
// ZIP must be CARRIED FORWARD, not rebuilt — but it must still be downloadable afterwards, and
// the viewer must lose the comment.
const g = await guest('Rude');
const uploadId = await seedUpload(host.jwt, { caption: 'pic' });
const cRes = await fetch(BASE + `/api/v1/upload/${uploadId}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: 'unfreundlich' }),
});
const commentId = (await cRes.json()).id;
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const del = await fetch(BASE + `/api/v1/host/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(del.status).toBe(204);
// The keepsake must still be downloadable, with its media intact.
await waitExportDone(host.jwt);
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
expect(await zipJobEpoch()).toBe(await eventEpoch());
});
test('editing a caption AFTER release regenerates the viewer but carries the ZIP forward', async ({
host,
}) => {
// edit_upload used to update the caption and NOTHING else — no keepsake invalidation. A caption
// lives in the HTML viewer (the ZIP holds media only), so after release the downloadable viewer
// kept showing the OLD caption forever while the live feed showed the new one. Editing is
// intentionally allowed post-release (like comments), so the fix is to regenerate, not forbid:
// invalidate_and_arm(ViewerOnly) — rebuild the viewer, carry the finished ZIP forward untouched.
const uploadId = await seedUpload(host.jwt, { caption: 'tippfehlr' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const zipEpochBefore = await zipJobEpoch();
const eventEpochBefore = await eventEpoch();
expect(zipEpochBefore).toBe(eventEpochBefore);
// The edit is allowed (not blocked by the release lock) ...
const res = await fetch(BASE + `/api/v1/upload/${uploadId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ caption: 'korrigiert' }),
});
expect(res.status).toBe(200);
// ... and it bumped the epoch (the viewer must rebuild) ...
await expect
.poll(async () => await eventEpoch(), { timeout: 10_000 })
.toBeGreaterThan(eventEpochBefore);
// ... while the ZIP was CARRIED FORWARD, not rebuilt: its row rides the new epoch (the media
// didn't change, so a full ZIP rebuild would be wasted work).
await waitExportDone(host.jwt);
expect(await zipJobEpoch(), 'the ZIP must be carried forward to the new epoch').toBe(
await eventEpoch()
);
// The keepsake is still downloadable with its single photo intact.
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
});
test('a comment deleted while the ZIP is still BUILDING does not strand the ZIP', async ({
host,
guest,
}) => {
// A comment-only change (`Affects::ViewerOnly`) carries the finished ZIP into the new epoch
// rather than spending minutes rebuilding an archive whose media didn't change. But "finished"
// is a PRECONDITION, and between `release_gallery` and the ZIP worker completing it is false —
// for a real multi-GB gallery, for MINUTES. Deleting a comment in that window is an utterly
// ordinary thing to do.
//
// The carry-forward UPDATE is guarded on `status = 'done'`, so in that window it matched
// NOTHING — and ViewerOnly re-armed only the viewer. The ZIP row was left at the retired epoch,
// the in-flight worker finished and wrote `done` at an epoch `export_current` no longer matches,
// and `GET /export/zip` 404'd FOREVER. Nothing re-armed it: recovery only runs at boot, and
// `release_gallery` refuses an already-released event.
//
// Fix: the carry-forward's own `rows_affected()` decides. Nothing carried ⇒ rebuild the ZIP too.
const g = await guest('Commenter');
const uploadId = await seedUpload(host.jwt, { caption: 'pic' });
const cRes = await fetch(BASE + `/api/v1/upload/${uploadId}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: 'schoenes foto' }),
});
expect(cRes.status).toBe(201);
const commentId = (await cRes.json()).id;
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Put the ZIP back into the state it occupies for most of a real release: still building.
// Everything else in this test is the real product.
await pgQuery(
`UPDATE export_job ej SET status = 'running', progress_pct = 50
FROM event e WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
// A guest deletes their own comment → Affects::ViewerOnly.
const del = await fetch(BASE + `/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(del.status).toBe(204);
// The ZIP must have been RE-ARMED at the live epoch, not abandoned at the retired one.
expect(await zipJobEpoch(), 'the ZIP must not be stranded at a retired epoch').toBe(
await eventEpoch()
);
// And it must actually become downloadable again, with its media intact.
await waitExportDone(host.jwt);
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
});
test('a release broadcasts export-progress 100 for both types and one export-available', async ({
host,
}) => {
// The ready-flip is now conditional, and a SUPERSEDED worker deliberately emits neither event.
// That makes the happy path worth pinning: `/export/status` polling (what waitExportDone uses)
// reads the export_job row, NOT the SSE — so a regression that silently stopped emitting these
// would leave every other test green while the /host and /export pages stopped updating live.
const sse = new SseListener();
await sse.start(host.jwt);
await seedUpload(host.jwt, { caption: 'sse' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
for (const type of ['zip', 'html']) {
await sse.waitForEvent(
'export-progress',
(e) => e.data?.type === type && e.data?.progress_pct === 100,
30_000
);
}
// Fires once BOTH ready flags are set — the authoritative "keepsake is downloadable" signal.
await sse.waitForEvent('export-available', () => true, 30_000);
sse.stop();
});
test('a FAILED keepsake is recoverable via rebuild — without reopening uploads to guests', async ({
host,
}) => {
// The escape hatch. A failed export used to be TERMINAL at runtime: `release_gallery` 409s on an
// already-released event, `recover_exports` only runs at boot, and nothing else retried. The
// host's only outs were restarting the container or REOPENING the event — which unlocks uploads
// to every guest and retracts the release, i.e. the cure was worse than the disease. (The host
// page literally used to instruct exactly that.)
//
// What this pins is not just "rebuild works", but that recovery is NON-DESTRUCTIVE: the event
// stays released and uploads stay locked across it. If a future refactor implements rebuild as
// an internal reopen+re-release, those two assertions fail.
await seedUpload(host.jwt, { caption: 'keepsake' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Simulate the worker having died: force the job terminal-failed, the state the host is stuck in.
await pgQuery(
`UPDATE export_job ej SET status = 'failed', file_path = NULL, progress_pct = 40
FROM event e WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
const failed = await exportStatus(host.jwt);
expect(failed.released).toBe(true);
expect(failed.zip.status).toBe('failed');
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
const ticket = await mintTicket(host.jwt);
expect(
(await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status
).toBe(404);
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);
const epochBefore = await eventEpoch();
// The escape hatch.
expect((await post('/api/v1/host/export/rebuild', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Recovered — and the keepsake is real, not an empty shell.
expect(await downloadZipEntries(host.jwt)).not.toHaveLength(0);
// ...and it cost the guests nothing: still released, still locked.
const [ev] = await pgQuery<{ released: boolean; locked: boolean }>(
`SELECT export_released_at IS NOT NULL AS released, uploads_locked_at IS NOT NULL AS locked
FROM event WHERE slug = '${SLUG}'`
);
expect(ev.released).toBe(true);
expect(ev.locked).toBe(true);
// A rebuild is a new generation, so any worker still alive from the failed one is inert.
expect(await eventEpoch()).toBeGreaterThan(epochBefore);
expect(await zipJobEpoch()).toBe(await eventEpoch());
});
test('rebuild is rejected when the gallery was never released', async ({ host }) => {
// Nothing to rebuild — and, more to the point, rebuild must not become a back door that
// publishes a keepsake for an event the host never released.
const res = await post('/api/v1/host/export/rebuild', host.jwt);
expect(res.status).toBe(400);
const [ev] = await pgQuery<{ released: boolean }>(
`SELECT export_released_at IS NOT NULL AS released FROM event WHERE slug = '${SLUG}'`
);
expect(ev.released).toBe(false);
});
});