/** * Re-review regression guard — Chain #2 ("export corruption on reopen→re-release"). * * Bug that was fixed: `spawn_export_jobs` ran the worker unconditionally and the * worker wrote a FIXED temp path (`Gallery.zip.tmp` / `viewer_tmp_{event}`). * The new reopen→re-release flow could therefore spawn a second worker while the * first was still running — two workers stomping the same temp file → a corrupt * keepsake ZIP served to guests. * * The fix: `claim_job` is an atomic `UPDATE ... WHERE status='pending'`; a worker * that doesn't win the claim bails without touching the temp file. The re-enqueue * `ON CONFLICT DO UPDATE ... WHERE status <> 'running'` leaves an in-flight job * alone. Net: exactly one worker per (event,type). * * This test seeds real uploads, releases, then churns reopen→re-release (stressing * the claim guard), waits for the export to settle, and asserts the produced ZIP is * INTACT (passes `unzip -t`) with exactly one entry per upload — plus that no * export_job row is left stuck `running`. A temp-file race would corrupt/truncate * the archive or drop entries, failing these assertions. * * Note on scope: a Dockerised export over small fixtures completes in well under a * second, so this cannot *deterministically* force two workers to overlap in time. * It stresses the claim/ON-CONFLICT guard under rapid churn and hard-checks archive * integrity; the atomic single-worker guarantee itself is additionally proven by * code review + SQL PREPARE. Together they cover the regression. */ import { test, expect } from '../../fixtures/test'; import { Client } from 'pg'; import { execFileSync } from 'node:child_process'; import { writeFileSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { seedUpload } from '../../helpers/seed'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const SLUG = 'e2e-test-event'; const N_UPLOADS = 6; 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; } test.describe('Flow re-review — reopen→re-release export integrity (#2)', () => { // The real export job runs image processing; give it head-room over the tiny fixtures. test.setTimeout(90_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 { uploadRaw } = await import('../../helpers/upload-client'); const { readFileSync } = await import('node:fs'); 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 (clears release + ready flags) then re-release, several times in // quick succession. This is the path that used to be able to double-spawn workers // over the 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); } // Wait for the export to settle: both jobs done and the ZIP marked ready. await expect .poll(async () => { const s = await exportStatus(host.jwt); return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done'; }, { timeout: 60_000, intervals: [500] }) .toBe(true); // No export_job may be left stuck 'running' (would mean a worker that never // completed — the failure mode the claim guard exists to avoid). 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'); // Download the ZIP and prove it is INTACT — a temp-file race would corrupt or // truncate it. `unzip -t` verifies every entry's CRC; a non-zero exit throws. const ticket = await mintTicket(host.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()); // ZIP local-file-header magic — a stomped/half-written file fails this immediately. expect(bytes.subarray(0, 2).toString('latin1')).toBe('PK'); const dir = mkdtempSync(join(tmpdir(), 'es-zip-')); const zipPath = join(dir, 'Gallery.zip'); writeFileSync(zipPath, bytes); // Integrity: `unzip -t` exits 0 only if all CRCs check out and the archive is whole. execFileSync('unzip', ['-t', zipPath], { stdio: 'pipe' }); // Completeness: exactly one entry per seeded upload — no entry lost to a stomped // temp file, none duplicated by a second worker. const listing = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' }) .split('\n') .map((l) => l.trim()) .filter((l) => l.length > 0 && !l.endsWith('/')); expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(N_UPLOADS); }); test('re-release does NOT disturb an in-flight (running) export job — the anti-race guard', async ({ host, }) => { // Deterministic proof of the fix, independent of export timing. We simulate a worker // that is still mid-export by pinning the zip job to `running` with a sentinel // progress value, then drive reopen→re-release. The fix's // ON CONFLICT ... DO UPDATE ... WHERE export_job.status <> 'running' // must leave that row untouched (and the freshly-spawned worker's // claim_job: UPDATE ... WHERE status = 'pending' // finds nothing to claim, so it bails without racing the temp file). // // On the OLD code (unconditional ON CONFLICT DO UPDATE) the row would be reset to // pending/progress=0 and a second worker would claim and run concurrently — so the // sentinel below would be wiped. This assertion therefore fails on the bug and passes // on the fix. await seedUpload(host.jwt, { caption: 'a' }); await seedUpload(host.jwt, { caption: 'b' }); // Release and let the first export fully finish so no real worker is active. expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204); await expect .poll(async () => { const s = await exportStatus(host.jwt); return s.zip?.status === 'done' && s.html?.status === 'done'; }, { timeout: 60_000, intervals: [500] }) .toBe(true); // Simulate an in-flight worker owning the zip job, with a sentinel we can check. 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, does NOT touch job rows) then re-release. 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); // Give any spawned worker a beat to attempt (and, correctly, bail) its claim. await new Promise((r) => setTimeout(r, 750)); // The guard must have preserved the 'running' sentinel — untouched by both the // re-enqueue and the bailing worker. const [row] = await pgQuery<{ status: string; progress_pct: number }>( `SELECT ej.status::text AS status, ej.progress_pct 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('running'); expect(Number(row.progress_pct), 'zip job sentinel progress').toBe(77); }); });