/** * 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'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; 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(sql: string): Promise { 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 { const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } }); return res.json(); } async function mintTicket(jwt: string): Promise { 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 { 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 { 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 { 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[] = []; 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('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); }); });