/** * Regression guard — when the keepsake fails to build, the HOST must be told why. * * `/export/status` reported `{status, progress_pct}` and nothing else, so the host dashboard could * only ever render "Keepsake-Erstellung fehlgeschlagen." next to an "Erneut versuchen" button. The * reason WAS being written — `mark_failed` stores it on the job row — but it surfaced solely in the * admin dashboard's job list. The host is the person who releases the gallery, owns the retry * button, and is standing at the venue; the admin may be someone else entirely, or the same person * without the password to hand. * * That matters most for the failure this shipped alongside: the export disk preflight. Its message * names the two numbers that decide what to do ("benötigt ca. X GB, frei sind Y GB"), and without * it "Erneut versuchen" fails identically, forever, with no hint that the answer is free some space. * * These drive the real UI and the real endpoint — the plumbing is four hops (SQL → handler JSON → * store type → Svelte branch) and any one of them dropping the field restores the silent version. */ import { test, expect } from '../../fixtures/test'; import { BASE } from '../../helpers/env'; const SLUG = 'e2e-test-event'; const DISK_REASON = 'Nicht genug Speicherplatz für das Keepsake: benötigt ca. 42.0 GB, frei sind 3.0 GB. ' + 'Bitte Speicher freigeben und das Keepsake anschließend neu erstellen.'; test.describe('Export — a failed keepsake explains itself to the host', () => { test('the failure reason reaches /export/status', async ({ host, db }) => { await db.setExportReleased(SLUG, true); await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON); await db.fakeExportJob(SLUG, 'html', 'failed', DISK_REASON); const res = await fetch(`${BASE}/api/v1/export/status`, { headers: { Authorization: `Bearer ${host.jwt}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { zip: { status: string; error_message: string | null }; html: { status: string; error_message: string | null }; }; expect(body.zip.status).toBe('failed'); expect( body.zip.error_message, 'the reason must travel with the status, not live only in the admin job list' ).toBe(DISK_REASON); expect(body.html.error_message).toBe(DISK_REASON); }); test('a succeeding export carries no stale reason', async ({ host, db }) => { // The mirror that keeps the above honest: a handler that returned `error_message` // unconditionally would pass the first test while showing an error next to a green // "Keepsake ist bereit." Rows keep their last message until they are re-armed, so this is a // real state, not a hypothetical one. await db.setExportReleased(SLUG, true); await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON); await db.fakeExportJob(SLUG, 'zip', 'done', DISK_REASON); const res = await fetch(`${BASE}/api/v1/export/status`, { headers: { Authorization: `Bearer ${host.jwt}` }, }); const body = (await res.json()) as { zip: { status: string; error_message: string | null } }; expect(body.zip.status).toBe('done'); expect( body.zip.error_message, 'a message left on a row that has since succeeded must not be shown' ).toBeNull(); }); test('the host dashboard renders the reason under the failure', async ({ page, host, signIn, db, }) => { await db.setExportReleased(SLUG, true); await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON); await db.fakeExportJob(SLUG, 'html', 'failed', DISK_REASON); await signIn(page, host); await page.goto('/host'); await expect(page.getByText(/Keepsake-Erstellung fehlgeschlagen/i)).toBeVisible({ timeout: 15_000, }); // The actionable half — the numbers, not just the verdict. await expect( page.getByText(/Nicht genug Speicherplatz/i), 'the host must see WHY, next to the only button they have' ).toBeVisible(); await expect(page.getByText(/3\.0 GB/)).toBeVisible(); // And the retry button is still mounted — it is deliberately outside the status branches. await expect(page.getByTestId('export-rebuild')).toBeVisible(); }); });