GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the one endpoint in the backend where a read performed a persisted write. The FE retries GETs automatically, so a transient failure could enter the create path more than once; a lock prevented a duplicate row, but the safety depended on the lock, not on the endpoint being a query. Split GetOrCreate into Get (a pure query) and the already-existing ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s when the owner has no brief yet. GET /brief/preview used GetOrCreate too, so it gets the same Get + 404 treatment, forced by the split. RB-22 already made BriefStore.load() on the FE tolerate a 404 by calling reset() once; this ticket is what makes that branch live. Updated the brief/preview/org-template backend tests that assumed GET seeded a brief on first call to create one explicitly first, and added a test that GET 404s and writes no row without the fix (verified red beforehand). Regenerated the API client (npm run gen:api). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
7.1 KiB
TypeScript
125 lines
7.1 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import { Actors, loginAs } from './support/actors';
|
|
|
|
// One flow through both Brief v2 axes on the real FE+backend (WP-19 conventions):
|
|
// content (drafter composes via the besluit panel → approver approves → sends) and
|
|
// appearance (admin edits + publishes the org template, drafter's canvas reflects it).
|
|
// Preview assertions are content-type/body-level (text/html + watermark marker), not
|
|
// pixel, per WP-28's decision.
|
|
//
|
|
// This test mutates real state (a letter, keyed per-owner by `BriefStore.Get`/
|
|
// `ResetAndCreate` — RB-23 split the old `GetOrCreate`), and WP-74 gives it a fresh
|
|
// throwaway backend DB every `npm run e2e` run, so a leftover/in-progress letter from
|
|
// a PREVIOUS RUN is never an issue any more. `GET /brief` 404s on that fresh DB until
|
|
// the "Opnieuw beginnen (demo)" click below creates the first row — RB-22's
|
|
// `BriefStore.load()` already tolerates that 404 by calling `reset()` once, so the
|
|
// page renders correctly either way; the explicit click is this test's own fixture
|
|
// setup, not a workaround for the 404. It
|
|
// deliberately still logs in as the shared `Actors.zorgverlener` rather than its own
|
|
// BSN, though: giving it a distinct BSN (as `smoke.spec.ts` does) hit a real,
|
|
// reproducible bug in this repo's own e2e run — `GET /brief/preview`'s sent-letter
|
|
// response kept the DRAFT watermark under a non-`DemoOwner` `X-Subject`, even though
|
|
// the outgoing request carried the right header and a direct `curl` against the same
|
|
// backend at the same instant returned the correct, frozen archive. That points to a
|
|
// backend-side staleness/race in `BriefStore`'s SQLite read path (see
|
|
// `letter-preview.adapter.ts`'s "KNOWN GAP" note), out of WP-74's file scope to fix —
|
|
// so this spec stays on the one identity that doesn't trip it, pending that backend
|
|
// investigation. The org-template appearance is a SEPARATE, already-known gap: it's
|
|
// NOT owner-keyed (there's exactly one, shared by every caller) and has no reset
|
|
// endpoint, so this test still restores the org-template draft it edits (step 8) and
|
|
// never asserts an absolute version number — only that it increased by exactly one.
|
|
test('drafter composes → approver sends; admin republishes appearance', async ({ page }) => {
|
|
await loginAs(page, Actors.zorgverlener);
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
|
|
// --- Compose (drafter) ---
|
|
await page.goto('/brief?role=drafter');
|
|
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Aanvraag herregistratie' })).toBeVisible();
|
|
|
|
const submitButton = page.getByRole('button', { name: 'Indienen ter beoordeling' });
|
|
await expect(submitButton).toBeDisabled();
|
|
|
|
await page.locator('label[for="besluit-positief"]').click();
|
|
await expect(page.getByText(/standaardtekst\(en\) toegevoegd/)).toBeVisible();
|
|
await expect(submitButton).toBeEnabled();
|
|
|
|
// Wait for the debounced draft save before navigating away.
|
|
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
|
|
|
|
// --- Preview: draft is watermarked ---
|
|
await page.getByRole('button', { name: 'Voorbeeld', exact: true }).click();
|
|
const [draftPreview] = await Promise.all([
|
|
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
|
|
page.getByRole('button', { name: 'Openen als document (PDF)' }).click(),
|
|
]);
|
|
expect(draftPreview.headers()['content-type']).toContain('text/html');
|
|
// The `.preview-watermark` CSS rule ships in every preview (draft or sent) — only the
|
|
// "VOORBEELD" marker div (LetterHtml.Render's `watermark` param) is actually conditional.
|
|
expect(await draftPreview.text()).toContain('>VOORBEELD<');
|
|
await page.getByRole('button', { name: 'Sluiten' }).click();
|
|
|
|
// --- Submit → approve → send (role change = full navigation, per WP-33 stickiness) ---
|
|
await submitButton.click();
|
|
await expect(page.getByText('De brief wacht op beoordeling door een collega.')).toBeVisible();
|
|
|
|
await page.goto('/brief?role=approver');
|
|
await page.getByRole('button', { name: 'Goedkeuren' }).click({ timeout: 10_000 });
|
|
await page.getByRole('button', { name: 'Versturen' }).click({ timeout: 10_000 });
|
|
await expect(page.getByText('De brief is verzonden.')).toBeVisible();
|
|
|
|
// --- Preview: sent letter serves its frozen, unwatermarked archive ---
|
|
// Sent = !canEdit → app-letter-composer, whose "Voorbeeld" click goes straight to
|
|
// store.previewLetter() (fetch + window.open), unlike the drafter's behandel-scherm
|
|
// dialog above where "Voorbeeld" only opens a local modal and a second click inside it
|
|
// triggers the fetch.
|
|
const [sentPreview] = await Promise.all([
|
|
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
|
|
page.getByRole('button', { name: 'Voorbeeld', exact: true }).click(),
|
|
]);
|
|
expect(sentPreview.headers()['content-type']).toContain('text/html');
|
|
expect(await sentPreview.text()).not.toContain('>VOORBEELD<');
|
|
|
|
// --- Admin republishes the appearance ---
|
|
await page.goto('/brief/huisstijl?role=admin');
|
|
const orgNameInput = page.getByLabel('Organisatienaam');
|
|
const before = await page.getByText(/Gepubliceerde versie:/).textContent();
|
|
const beforeVersion = Number(before?.match(/\d+/)?.[0]);
|
|
|
|
const unique = `BIG-register E2E ${Date.now()}`;
|
|
await orgNameInput.fill(unique);
|
|
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole('button', { name: 'Publiceren' }).click();
|
|
await expect(page.getByText(/Dit raakt \d+ nog niet verzonden brieven/)).toBeVisible();
|
|
await page.getByRole('button', { name: 'Bevestigen' }).click();
|
|
await expect(page.getByText(`Gepubliceerde versie: ${beforeVersion + 1}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// --- Drafter's canvas reflects the new appearance on a fresh letter ---
|
|
await page.goto('/brief?role=drafter');
|
|
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
|
|
await page.getByRole('button', { name: 'Voorbeeld', exact: true }).click();
|
|
await expect(page.locator('dialog')).toContainText(unique);
|
|
await page.getByRole('button', { name: 'Sluiten' }).click();
|
|
|
|
// --- Restore: put the org template's appearance back the way this test found it ---
|
|
await page.goto('/brief/huisstijl?role=admin');
|
|
await page
|
|
.locator('.history-row', {
|
|
// beforeVersion is a number this test itself captured earlier, never external/attacker input
|
|
// (detect-non-literal-regexp false positive — the reported check_id doesn't match what
|
|
// `nosemgrep` compares against for this rule, confirmed by trial; bare form suppresses it).
|
|
hasText: new RegExp(`Versie ${beforeVersion} ·`), // nosemgrep
|
|
})
|
|
.getByRole('button', { name: 'Terugzetten in concept' })
|
|
.click();
|
|
await expect(orgNameInput).not.toHaveValue(unique);
|
|
await page.getByRole('button', { name: 'Publiceren' }).click();
|
|
await page.getByRole('button', { name: 'Bevestigen' }).click();
|
|
await expect(page.getByText(`Gepubliceerde versie: ${beforeVersion + 2}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|