import { expect, test, type Page } from "@playwright/test"; async function install(page: Page, options: { rejectFirst?: boolean; conflictFirst?: boolean; stateMatrix?: boolean; holdSave?: Promise; holdFirstJobs?: Promise; rejectFirstJobs?: boolean; language?: "en" | "de"; readOnly?: boolean } = {}) { const errors: string[] = []; page.on("pageerror", error => { errors.push(error.message); console.error("Review fixture:", error.message); }); const writes: Record[] = []; const reads: string[] = []; let revision = 1; let complete = false; let buildGeneration = 1; let selectedVersionId = "review-version"; let jobRequests = 0; const builds: Record[] = []; const publicBuildToken = () => selectedVersionId === "replacement-version" ? "replacement-build-reference" : buildGeneration === 1 ? "opaque-build-reference" : `rebuilt-reference-${buildGeneration}`; const subjectFor = (subject: string) => selectedVersionId === "replacement-version" ? `Replacement ${subject}` : buildGeneration === 1 ? subject : `Rebuilt ${subject}`; const decisions = new Map>(); const statuses = options.stateMatrix ? ["needs_review", "ready", "excluded", "warning", "blocked", "needs_review"] : ["needs_review", "needs_review", "excluded"]; if (options.stateMatrix) decisions.set("job-6", { job_id: "job-6", decision: "accept", reason: "Already reviewed this specific exception." }); const jobs = statuses.map((status, offset) => { const index = offset + 1; return { id: `job-${index}`, entry_id: `entry-${index}`, review_key: `entry-${index}`, entry_index: index, recipient_email: `recipient-${index}@example.test`, subject: `Message ${index}`, build_status: "built", validation_status: status, send_status: "not_queued", resolved_recipients: { to: [{ email: `recipient-${index}@example.test` }] }, attachments: [], issues: status === "ready" ? [] : [{ code: "missing_optional_attachment", behavior: status === "excluded" ? "drop" : status === "warning" ? "warn" : status === "blocked" ? "block" : "ask", severity: status === "blocked" ? "error" : "warning", source: "attachments", message: "Expected attachment was not found" }], review_decision: { eligible: status === "needs_review", category_key: "attachment-group", reason_required: true, issue_codes: ["missing_optional_attachment"] } }; }); const version = () => ({ id: selectedVersionId, campaign_id: "review-campaign", version_number: 1, edit_revision: revision, strong_etag: `"${selectedVersionId}:${revision}"`, review_build_token: publicBuildToken(), locked_at: "2026-09-07T09:00:00Z", current_flow: "manual", current_step: "review", workflow_state: "built", is_complete: false, updated_at: "2026-09-07T10:00:00Z", build_summary: { built_count: 2, needs_review_count: 2 }, validation_summary: { ok: true, warning_count: 14, issues: [] }, raw_json: { campaign: { name: "Review fixture" }, template: { subject: "Review", text: "Fixture message", html: "

Fixture message

" }, server: {}, entries: { inline: jobs.map(row => ({ id: row.entry_id, email: row.recipient_email, active: true })) } }, editor_state: { review_send: { review_build_token: publicBuildToken(), inspection_complete: complete, reviewed_message_keys: [...decisions.keys()].map(id => `entry-${id.split("-")[1]}`), issue_decisions: [...decisions.values()] } } }); const jobResponse = () => ({ jobs: jobs.map(row => ({ ...row, subject: subjectFor(row.subject), reviewed: decisions.has(row.id) })), page: 1, page_size: 200, total: jobs.length, total_unfiltered: jobs.length, pages: 1, cursor: null, next_cursor: null, counts: {}, filtered_counts: {}, review: { blocking_count: options.stateMatrix ? 1 : 0, required_count: 2, bulk_acceptable_count: options.stateMatrix ? 1 : 0, reviewed_required_count: decisions.size, inspection_complete: complete } }); await page.route((url) => url.pathname.startsWith("/api/"), async route => { const request = route.request(); const url = new URL(request.url()); if (request.method() === "GET") { reads.push(url.pathname); if (url.pathname.endsWith("/workspace/delta")) { selectedVersionId = url.searchParams.get("version_id") ?? "review-version"; return route.fulfill({ json: { campaign: { id: "review-campaign", name: "Review fixture", current_version_id: selectedVersionId, status: "draft" }, versions: [version()], current_version: version(), summary: { cards: { jobs_total: jobs.length, sent: 0, failed: 0 }, status_counts: {}, attachments: { missing_configs: 14 } }, deleted: [], full: true, has_more: false, watermark: `w-${revision}` } }); } if (url.pathname.endsWith("/jobs")) { jobRequests++; const snapshot = jobResponse(); if (jobRequests === 1 && options.holdFirstJobs) { // A stale response advertising another page must not start that // follow-up request after the selected build has changed. snapshot.pages = 2; await options.holdFirstJobs; } if (jobRequests === 1 && options.rejectFirstJobs) return route.fulfill({ status: 503, json: { detail: "Fixture initial review load failed" } }); return route.fulfill({ json: snapshot }); } if (/\/jobs\/job-/.test(url.pathname)) return route.fulfill({ json: { job: jobs.find(row => url.pathname.endsWith(row.id)), attempts: [] } }); if (url.pathname.endsWith(`/versions/${selectedVersionId}`)) return route.fulfill({ json: version() }); return route.fulfill({ json: {} }); } if (url.pathname.endsWith("/attachments/preview")) return route.fulfill({ json: { campaign_id: "review-campaign", version_id: "review-version", shared_file_count: 0, rules: [], linkable_files: [], unused_shared_files: [] } }); if (request.method() === "POST" && url.pathname.endsWith("/review-state")) { const body = request.postDataJSON(); writes.push(body); if (options.holdSave) await options.holdSave; if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 503, json: { detail: "Fixture decision save unavailable" } }); if (options.conflictFirst && writes.length === 1) { decisions.set("job-2", { job_id: "job-2", decision: "accept", reason: "Another reviewer checked this document.", actor_user_id: "other-reviewer" }); revision++; return route.fulfill({ status: 409, json: { detail: "Another reviewer saved a decision. Inspect the refreshed progress and retry explicitly." } }); } expect(body.merge_progress).toBe(true); expect(body.build_token).toBe(publicBuildToken()); expect(body.base_revision).toBe(revision); for (const decision of body.issue_decisions) decisions.set(decision.job_id, decision); revision++; complete = body.inspection_complete; return route.fulfill({ json: version() }); } if (request.method() === "POST" && url.pathname.endsWith("/build")) { builds.push(request.postDataJSON()); buildGeneration++; revision++; complete = false; decisions.clear(); return route.fulfill({ json: { built_count: 2, build_token: publicBuildToken() } }); } return route.abort(); }); await page.goto(`/?campaign-review&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.holdFirstJobs ? "&race" : ""}`); if (options.holdFirstJobs || options.rejectFirstJobs) await expect.poll(() => jobRequests).toBe(1); else await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible(); return { writes, reads, errors, decisions, builds, get jobRequests() { return jobRequests; } }; } async function openMessage(page: Page, index = 1) { const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true }); await expect(table.getByText(`Message ${index}`, { exact: true })).toBeVisible(); await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).nth(index - 1).click(); await expect(page.getByRole("dialog")).toBeVisible(); return page.getByRole("dialog"); } test("each acceptance persists immediately and advances, surviving reload before whole-review completion", async ({ page }) => { const fixture = await install(page); const dialog = await openMessage(page); const readsBefore = fixture.reads.length; await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Recipient already received the document."); expect(fixture.reads.length).toBe(readsBefore); expect(fixture.writes).toHaveLength(0); await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click(); await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible(); expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0].inspection_complete).toBe(false); expect(fixture.writes[0].issue_decisions[0].reason).toBe("Recipient already received the document."); expect(fixture.reads.length).toBe(readsBefore); await page.reload(); await openMessage(page); await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible(); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]); }); test("failed review save keeps the reason and current message until explicit retry succeeds", async ({ page }) => { const fixture = await install(page, { rejectFirst: true }); const dialog = await openMessage(page); await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Retain this reason."); await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click(); await expect(dialog.getByText(/Fixture decision save unavailable/)).toBeVisible(); await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Retain this reason."); expect(fixture.decisions.size).toBe(0); await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click(); await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible(); expect(fixture.writes).toHaveLength(2); expect(fixture.errors).toEqual([]); }); test("pending individual acceptance cannot duplicate, dismiss, or navigate before acknowledgement", async ({ page }) => { let acknowledge!: () => void; const fixture = await install(page, { holdSave: new Promise((resolve) => { acknowledge = resolve; }) }); const dialog = await openMessage(page); await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Confirmed exception, waiting for durable save."); await dialog.getByRole("button", { name: "Accept and continue", exact: true }).evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); await expect.poll(() => fixture.writes.length).toBe(1); for (const navigation of await dialog.locator(".template-preview-nav button").all()) await expect(navigation).toBeDisabled(); for (const close of await dialog.getByRole("button", { name: /^(Close|Schließen)$/ }).all()) await expect(close).toBeDisabled(); await page.keyboard.press("Escape"); await expect(dialog).toBeVisible(); await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible(); acknowledge(); await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible(); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]); }); test("German individual review records its reason and skips intentional exclusions", async ({ page }) => { const fixture = await install(page, { language: "de" }); let dialog = await openMessage(page); await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Das Dokument liegt dem Empfänger bereits vor."); await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click(); await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible(); await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Auch dieser Empfänger benötigt keinen weiteren Anhang."); await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click(); await expect(dialog).not.toBeVisible(); expect(fixture.writes).toHaveLength(2); expect(fixture.decisions.has("job-3")).toBe(false); await page.reload(); dialog = await openMessage(page); await expect(dialog.getByText("Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.")).toBeVisible(); expect(fixture.errors).toEqual([]); }); test("real review grouped acceptance persists only eligible messages with exact category guard", async ({ page }) => { const fixture = await install(page); await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(1); await page.getByRole("button", { name: "Accept similar review conditions", exact: true }).click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByRole("checkbox")).toHaveCount(2); await expect(dialog.getByRole("checkbox", { name: "recipient-3@example.test" })).toHaveCount(0); await dialog.getByRole("textbox").fill("Both recipients have already received these documents."); await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click(); await expect(dialog).not.toBeVisible(); expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0]).toMatchObject({ merge_progress: true, build_token: "opaque-build-reference", base_revision: 1, inspection_complete: false, decision_category_key: "attachment-group", reviewed_message_keys: ["entry-1", "entry-2"] }); expect(fixture.writes[0].issue_decisions).toEqual([ { job_id: "job-1", decision: "accept", reason: "Both recipients have already received these documents." }, { job_id: "job-2", decision: "accept", reason: "Both recipients have already received these documents." } ]); await page.reload(); await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled(); await openMessage(page, 2); await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible(); expect(fixture.decisions.has("job-3")).toBe(false); await page.getByRole("dialog").getByRole("button", { name: /^(Close|Schließen)$/ }).last().click(); await page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ }).click(); await expect.poll(() => fixture.writes.length).toBe(2); expect(fixture.writes[1].inspection_complete).toBe(true); const attachmentsPreflight = page.locator(".deliverability-preflight-item").filter({ has: page.getByText(/^(Attachments|Anhänge)$/) }); await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready"); await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0); await page.reload(); await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready"); await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0); expect(fixture.errors).toEqual([]); }); test("read-only campaign access permits inspection but no review decisions", async ({ page }) => { const fixture = await install(page, { readOnly: true }); await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled(); await expect(page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ })).toBeDisabled(); const dialog = await openMessage(page); const accept = dialog.getByRole("button", { name: "Accept and continue", exact: true }); if (await accept.count()) await expect(accept).toBeDisabled(); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("concurrent reviewer conflict refreshes evidence without replaying or overwriting their decision", async ({ page }) => { const fixture = await install(page, { conflictFirst: true }); const dialog = await openMessage(page); await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Keep my independently inspected exception."); await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click(); await expect(dialog.getByText(/Another reviewer saved a decision/)).toBeVisible(); await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Keep my independently inspected exception."); await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible(); expect(fixture.writes).toHaveLength(1); expect(fixture.decisions.has("job-1")).toBe(false); expect(fixture.decisions.get("job-2")?.reason).toBe("Another reviewer checked this document."); expect(fixture.reads.filter(path => path.endsWith("/versions/review-version"))).toHaveLength(1); await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click(); await expect(dialog).not.toBeVisible(); expect(fixture.writes).toHaveLength(2); expect(fixture.writes[1].base_revision).toBe(2); expect(fixture.writes[1].issue_decisions).toEqual([{ job_id: "job-1", decision: "accept", reason: "Keep my independently inspected exception." }]); expect(fixture.decisions.get("job-2")?.actor_user_id).toBe("other-reviewer"); expect(fixture.errors).toEqual([]); }); test("one four-state filter distinguishes automatic, accepted, warning, blocked and excluded messages", async ({ page }) => { const fixture = await install(page, { stateMatrix: true, language: "de" }); const section = page.locator("#campaign-built-message-details"); const table = section.getByRole("table"); const stateCells = table.locator('.data-grid-body-cell[data-column-id="messageState"]'); await expect(table.locator('[data-column-id="validation"]')).toHaveCount(0); await expect(table.locator('[data-column-id="reviewed"]')).toHaveCount(0); await expect(stateCells).toHaveText(["Prüfung erforderlich", "Bereit", "Ausgeschlossen", "Prüfung erforderlich", "Blockiert", "Bereit"]); await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(1)).toContainText("keine manuelle Entscheidung erforderlich"); await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(5)).toContainText("gespeicherter Prüfentscheidung"); await table.locator('.data-grid-header-cell[data-column-id="messageState"] .data-grid-filter-trigger').click(); const filter = page.locator(".data-grid-filter-popover"); await expect(filter.getByRole("checkbox")).toHaveCount(4); for (const label of ["Bereit", "Prüfung erforderlich", "Blockiert", "Ausgeschlossen"]) await expect(filter.getByRole("checkbox", { name: label, exact: true })).toBeVisible(); await filter.getByRole("button", { name: "Alle abwählen", exact: true }).click(); await filter.getByRole("checkbox", { name: "Prüfung erforderlich", exact: true }).check(); await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich"]); await filter.getByRole("button", { name: /^(Close filter|Filter schließen)$/ }).click(); // The convenience action uses the very same derived state, adding blockers // but never reintroducing accepted rows just because their build said ask. await section.getByRole("button", { name: /^(Show review candidates only|Nur Prüfkandidaten anzeigen)$/ }).click(); await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich", "Blockiert"]); await section.getByRole("button", { name: /^(Show all messages|Alle Nachrichten anzeigen)$/ }).click(); await expect(stateCells).toHaveCount(6); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("rebuilding a version loads its new review rows after the build busy state clears", async ({ page }) => { const fixture = await install(page); expect(fixture.jobRequests).toBe(1); await page.getByRole("button", { name: /^(Build again|Erneut erstellen|Erneut bauen)$/ }).click(); const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true }); await expect(table.getByText("Rebuilt Message 1", { exact: true })).toBeVisible(); await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0); expect(fixture.builds).toHaveLength(1); expect(fixture.jobRequests).toBe(2); await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).first().click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Reviewed the rebuilt evidence."); await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click(); await expect.poll(() => fixture.writes.length).toBe(1); expect(fixture.writes[0]).toMatchObject({ build_token: "rebuilt-reference-2", base_revision: 2 }); expect(fixture.errors).toEqual([]); }); test("a delayed old-version job response cannot replace the newly selected build", async ({ page }) => { let releaseOld!: () => void; const fixture = await install(page, { holdFirstJobs: new Promise((resolve) => { releaseOld = resolve; }) }); await page.getByRole("button", { name: "Switch fixture version", exact: true }).click(); await expect(page).toHaveURL(/version=replacement-version/); await expect.poll(() => fixture.reads.filter(path => path.endsWith("/workspace/delta")).length).toBeGreaterThanOrEqual(2); releaseOld(); const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true }); await expect(table.getByText("Replacement Message 1", { exact: true })).toBeVisible(); await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0); expect(fixture.jobRequests).toBe(2); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("an initial jobs failure stops automatic retries but a manual retry can recover", async ({ page }) => { const fixture = await install(page, { rejectFirstJobs: true }); await expect(page.getByText(/Fixture initial review load failed/)).toBeVisible(); const retry = page.getByRole("button", { name: /^(Load review|Prüfung laden)$/ }); await expect(retry).toBeEnabled(); await page.waitForLoadState("networkidle"); expect(fixture.jobRequests).toBe(1); await retry.click(); await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible(); expect(fixture.jobRequests).toBe(2); expect(fixture.errors).toEqual([]); });