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.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. 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, }); });