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>
722 lines
34 KiB
TypeScript
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);
|
|
});
|
|
});
|