import { expect, test, type Page } from "@playwright/test"; async function install(page: Page, options: { language?: "en" | "de"; readOnly?: boolean; rejectFirst?: boolean; holdWrite?: Promise; switchAccount?: boolean } = {}) { const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); const writes: { path: string; payload: Record }[] = []; let detailReads = 0; let holdNextRead: Promise | undefined; let heldReads = 0; const statuses = ["failed_temporary", "failed_permanent", "outcome_unknown", "sending", "sending", "queued", "smtp_accepted", "skipped"]; const jobs = statuses.map((status, index) => ({ id: `job-${index + 1}`, campaign_version_id: "report-version", entry_id: `entry-${index + 1}`, entry_index: index + 1, recipient_email: `primary-${index + 1}@example.test`, subject: `Report message ${index + 1}`, resolved_recipients: { to: [{ email: `primary-${index + 1}@example.test` }, { name: "Additional person", email: `additional-${index + 1}@example.test` }], cc: [{ email: `copy-${index + 1}@example.test` }], bcc: [{ email: `blind-${index + 1}@example.test` }] }, build_status: "built", validation_status: status === "skipped" ? "excluded" : "ready", queue_status: status === "sending" ? "claimed" : "draft", send_status: status, imap_status: status === "smtp_accepted" ? "appended" : index === 0 ? "pending" : index === 1 ? "appending" : index === 2 ? "outcome_unknown" : "not_requested", attempt_count: ["not_queued", "queued", "skipped"].includes(status) ? 0 : 1, postbox_attempt_count: 0, print_attempt_count: 0, attachments: [], recovery: { smtp: { eligible: index === 3, revision: `claim-revision-${index + 1}`, reason: index === 3 ? "recoverable" : "live_claim" }, imap: { eligible: false, revision: "imap-revision", reason: "not_active" } } })); const version = { id: "report-version", campaign_id: "report-campaign", version_number: 1, edit_revision: 1, strong_etag: '"report-version:1"', workflow_state: "partially_completed", locked_at: "2026-09-07T10:00:00Z", raw_json: {}, editor_state: {} }; const summary = () => ({ cards: { jobs_total: jobs.length, sent: jobs.filter(job => job.send_status === "smtp_accepted").length, failed: jobs.filter(job => job.send_status.startsWith("failed")).length }, delivery: { background_workers_enabled: false, celery_enabled: false }, status_counts: { send: Object.fromEntries([...new Set(jobs.map(job => job.send_status))].map(status => [status, jobs.filter(job => job.send_status === status).length])), imap: Object.fromEntries([...new Set(jobs.map(job => job.imap_status))].map(status => [status, jobs.filter(job => job.imap_status === status).length])) } }); await page.route(url => url.pathname.startsWith("/api/"), async route => { const request = route.request(); const url = new URL(request.url()); if (request.method() === "GET") { if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: { campaign: { id: "report-campaign", name: "Report fixture", current_version_id: "report-version", status: "partially_completed" }, versions: [version], current_version: version, summary: summary(), deleted: [], full: true, has_more: false, watermark: "report-watermark" } }); if (url.pathname.endsWith("/delivery-progress")) return route.fulfill({ json: { campaign_id: "report-campaign", version_id: "report-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: jobs.length, smtp: { total: 7, processed: 4, accepted: summary().cards.sent, active: 2, pending: 1, failed: summary().cards.failed, outcome_unknown: 1, excluded: 1, paused: 0, cancelled: 0 }, imap: { total: 1, processed: 1, appended: 1, active: 0, pending: 0, failed: 0, outcome_unknown: 0, excluded: 7 }, status_counts: {} } }); if (url.pathname.endsWith("/jobs")) { const search = (url.searchParams.get("q") ?? url.searchParams.get("filter_recipient") ?? "").toLowerCase(); const matchesStatus = (column: "send" | "imap", status: string) => { const filter = url.searchParams.get(`filter_${column}`); return !filter?.startsWith("list:") || JSON.parse(filter.slice(5)).includes(status); }; const selected = structuredClone(jobs.filter(job => (!search || JSON.stringify([job.resolved_recipients, job.recipient_email, job.entry_id, job.subject]).toLowerCase().includes(search)) && matchesStatus("send", job.send_status) && matchesStatus("imap", job.imap_status))); const hold = holdNextRead; if (hold) { holdNextRead = undefined; heldReads++; await hold; } return route.fulfill({ json: { jobs: selected, page: 1, page_size: 50, total: selected.length, total_unfiltered: jobs.length, pages: 1, counts: { send: {}, imap: {} }, filtered_counts: {} } }); } if (/\/jobs\/job-\d+$/.test(url.pathname)) { detailReads++; return route.fulfill({ json: { job: jobs.find(job => url.pathname.endsWith(job.id)), attempts: { smtp: [], imap: [] } } }); } return route.fulfill({ json: {} }); } const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload }); if (options.holdWrite) await options.holdWrite; if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 409, json: { detail: "Fixture recovery changed. Reload evidence before retrying." } }); if (/\/jobs\/(retry|send-unattempted)$/.test(url.pathname)) { expect(payload.run_inline).toBe(true); expect(payload.enqueue_celery).toBe(false); expect(payload.version_id).toBe("report-version"); for (const id of payload.job_ids) { const job = jobs.find(row => row.id === id)!; expect(["failed_temporary", "failed_permanent", "not_queued", "queued"]).toContain(job.send_status); job.send_status = "smtp_accepted"; } return route.fulfill({ json: { result: { selected_count: payload.job_ids.length, attempted_count: payload.job_ids.length, sent_count: payload.job_ids.length, failed_count: 0, outcome_unknown_count: 0, remaining_count: 0, run_inline: true } } }); } const job = jobs.find(row => url.pathname.includes(`/jobs/${row.id}/`)); if (url.pathname.endsWith("/recover-claim") && job) { expect(payload.expected_revision).toBe(job.recovery.smtp.revision); expect(payload.note.trim()).not.toBe(""); job.send_status = "outcome_unknown"; job.recovery.smtp.eligible = false; return route.fulfill({ json: { result: { job_id: job.id } } }); } if (url.pathname.endsWith("/resolve-outcome") && job) { expect(payload.note.trim()).not.toBe(""); job.send_status = payload.decision === "smtp_accepted" ? "smtp_accepted" : "failed_temporary"; return route.fulfill({ json: { result: { job_id: job.id } } }); } return route.abort(); }); await page.goto(`/?campaign-report&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.switchAccount ? "&switch-account" : ""}`); const table = page.getByRole("table", { name: "campaign-report-jobs-v2-report-campaign", exact: true }); await expect(table.getByText("Report message 1", { exact: true })).toBeVisible(); return { table, jobs, writes, errors, holdNextJobsRead(hold: Promise) { holdNextRead = hold; }, get heldReads() { return heldReads; }, get detailReads() { return detailReads; } }; } for (const language of ["en", "de"] as const) test(`${language}: Report shows every To/Cc/Bcc recipient and searches without per-row details`, async ({ page }) => { const fixture = await install(page, { language }); await expect(fixture.table.getByText("Additional person ", { exact: false })).toBeVisible(); await expect(fixture.table.getByText("copy-1@example.test", { exact: true })).toBeVisible(); await expect(fixture.table.getByText("blind-1@example.test", { exact: true })).toBeVisible(); await expect(fixture.table.getByText(language === "de" ? "An:" : "To:", { exact: true }).first()).toBeVisible(); expect(fixture.detailReads).toBe(0); await page.getByRole("textbox").first().fill("blind-2@example.test"); await expect(fixture.table.getByText("Report message 2", { exact: true })).toBeVisible(); await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0); expect(fixture.detailReads).toBe(0); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("workerless Report retry executes one canonical request and displays real acceptance", async ({ page }) => { const fixture = await install(page); await expect(page.getByRole("button", { name: "Queue temporary failures for workers", exact: true })).toBeDisabled(); await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click(); await expect.poll(() => fixture.writes.length).toBe(1); expect(fixture.writes[0].path).toMatch(/\/jobs\/retry$/); expect(fixture.writes[0].payload.job_ids).toEqual(["job-1"]); await expect(page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled(); await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click(); await expect(page.getByText(/Delivery request finished: 1 attempted, 1 accepted, 0 failed/)).toBeVisible(); await page.reload(); await expect(fixture.table.getByText("Report message 1", { exact: true })).toBeVisible(); expect(fixture.jobs[0].send_status).toBe("smtp_accepted"); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]); }); test("page retry and unattempted continuation exclude accepted, active and uncertain messages", async ({ page }) => { const fixture = await install(page); expect(fixture.jobs[5].send_status).toBe("queued"); await page.getByRole("button", { name: "Retry failed messages on this page now (2)", exact: true }).click(); await expect.poll(() => fixture.writes.length).toBe(1); expect(fixture.writes[0].payload.job_ids).toEqual(["job-1", "job-2"]); expect(fixture.writes[0].payload.include_permanent).toBe(true); await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click(); await page.getByRole("button", { name: "Send unattempted messages on this page now (1)", exact: true }).click(); await expect.poll(() => fixture.writes.length).toBe(2); expect(fixture.writes[1].path).toMatch(/\/jobs\/send-unattempted$/); expect(fixture.writes[1].payload.job_ids).toEqual(["job-6"]); expect(fixture.errors).toEqual([]); }); test("reconciliation requires evidence, retains failed input, and retries only explicitly", async ({ page }) => { const fixture = await install(page, { rejectFirst: true }); await fixture.table.getByRole("button", { name: /^(Not sent|Nicht gesendet)$/ }).and(page.locator(":enabled")).click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByRole("button", { name: "Record message as not sent", exact: true })).toBeDisabled(); await dialog.getByRole("textbox", { name: /^Evidence note/ }).fill("SMTP logs show no acceptance for this message ID."); await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click(); await expect(dialog.getByText("Fixture recovery changed. Reload evidence before retrying.", { exact: false })).toBeVisible(); await expect(dialog.getByRole("textbox")).toHaveValue("SMTP logs show no acceptance for this message ID."); expect(fixture.writes).toHaveLength(1); await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click(); await expect(dialog).toHaveCount(0); expect(fixture.writes[1].payload.note).toBe("SMTP logs show no acceptance for this message ID."); expect(fixture.jobs[2].send_status).toBe("failed_temporary"); expect(fixture.writes).toHaveLength(2); expect(fixture.errors).toEqual([]); }); test("only a server-proven stale claim can be recovered, without implicit send or reconciliation", async ({ page }) => { const fixture = await install(page); await expect(fixture.table.getByText(/Processing is still active or its lease has not expired/)).toBeVisible(); await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled"))).toHaveCount(1); await fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled")).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox").fill("Previous process stopped; checked its expired lease and server logs."); await dialog.getByRole("button", { name: "Mark outcome for investigation", exact: true }).click(); await expect(dialog).toHaveCount(0); expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0].path).toContain("/jobs/job-4/recover-claim"); expect(fixture.writes[0].payload.expected_revision).toBe("claim-revision-4"); expect(fixture.jobs[3].send_status).toBe("outcome_unknown"); expect(fixture.jobs[4].send_status).toBe("sending"); expect(fixture.errors).toEqual([]); }); test("pending recovery cannot duplicate or dismiss, and read-only Report cannot mutate", async ({ page }) => { let release!: () => void; const held = new Promise(resolve => { release = resolve; }); const fixture = await install(page, { holdWrite: held }); await fixture.table.getByRole("button", { name: /^(Accepted|Angenommen)$/ }).and(page.locator(":enabled")).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox").fill("Verified SMTP acceptance in the server log."); await dialog.getByRole("button", { name: "Record SMTP acceptance", exact: true }).dblclick(); await expect.poll(() => fixture.writes.length).toBe(1); await page.keyboard.press("Escape"); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("textbox")).toBeDisabled(); release(); await expect(dialog).toHaveCount(0); await page.goto("/?campaign-report&language=en&read-only"); await expect(fixture.table.getByRole("button", { name: "Retry now", exact: true }).first()).toBeDisabled(); await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).first()).toBeDisabled(); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]); }); test("queued remainder can continue by row, but previous SMTP, Postbox or Print attempts cannot", async ({ page }) => { const fixture = await install(page); const continuations = fixture.table.getByRole("button", { name: "Send unattempted message now", exact: true }).and(page.locator(":enabled")); for (const field of ["attempt_count", "postbox_attempt_count", "print_attempt_count"] as const) { fixture.jobs[5][field] = 1; await page.getByRole("button", { name: "Reload", exact: true }).click(); await expect(continuations).toHaveCount(0); await expect(page.getByRole("button", { name: "Send unattempted messages on this page now (0)", exact: true })).toBeDisabled(); fixture.jobs[5][field] = 0; } await page.getByRole("button", { name: "Reload", exact: true }).click(); await expect(continuations).toHaveCount(1); await continuations.click(); await expect.poll(() => fixture.writes.length).toBe(1); expect(fixture.writes[0].payload.job_ids).toEqual(["job-6"]); expect(fixture.jobs[3].send_status).toBe("sending"); expect(fixture.jobs[2].send_status).toBe("outcome_unknown"); expect(fixture.errors).toEqual([]); }); test("active, queued and incomplete IMAP counts drill down to the exact current states", async ({ page }) => { const fixture = await install(page); for (const [label, count, ids] of [ ["Sending", 2, [4, 5]], ["Queued", 1, [6]], ["Pending", 1, [1]], ["Copying to Sent", 1, [2]], ["Outcome uncertain", 1, [3]] ] as const) { const shortcut = page.locator("dt").filter({ hasText: new RegExp(`^${label}$`) }).locator("..").getByRole("button"); await expect(shortcut).toHaveText(String(count)); await shortcut.click(); await expect(shortcut).toHaveAttribute("aria-pressed", "true"); await expect(fixture.table.getByText(/^Report message \d+$/)).toHaveCount(ids.length); for (const id of ids) await expect(fixture.table.getByText(`Report message ${id}`, { exact: true })).toBeVisible(); } expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("old report reads cannot restore rows after switching account context", async ({ page }) => { let release!: () => void; const held = new Promise(resolve => { release = resolve; }); const fixture = await install(page, { switchAccount: true }); fixture.holdNextJobsRead(held); await page.getByRole("button", { name: "Reload", exact: true }).click(); await expect.poll(() => fixture.heldReads).toBe(1); fixture.jobs[0].subject = "New account message"; await page.getByRole("button", { name: "Switch fixture account" }).click(); await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible(); const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs")); release(); await oldResponse; await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0); await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible(); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]); }); test("an old recovery acknowledgment cannot reopen progress in another account context", async ({ page }) => { let release!: () => void; const held = new Promise(resolve => { release = resolve; }); const fixture = await install(page, { switchAccount: true, holdWrite: held }); await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click(); await expect.poll(() => fixture.writes.length).toBe(1); await expect(page.getByRole("dialog")).toBeVisible(); // Simulate an externally changed authentication provider while the request is pending. await page.getByRole("button", { name: "Switch fixture account" }).evaluate(button => (button as HTMLButtonElement).click()); await expect(page.getByRole("dialog")).toHaveCount(0); const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs/retry")); release(); await oldResponse; await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await expect.poll(() => fixture.jobs[0].send_status).toBe("smtp_accepted"); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText(/Delivery request finished:/)).toHaveCount(0); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]); }); test("long recipient names and addresses wrap without clipping their contents", async ({ page }) => { const fixture = await install(page); const email = `${"long-address-segment-".repeat(5)}@example.test`; fixture.jobs[0].resolved_recipients.bcc = [{ email }]; await page.getByRole("button", { name: "Reload", exact: true }).click(); const cell = fixture.table.locator(".campaign-report-recipient-cell").filter({ hasText: email }); await expect(cell.getByText(email, { exact: true })).toBeVisible(); await expect.poll(() => cell.evaluate(node => ({ overflow: node.scrollWidth - node.clientWidth, height: node.getBoundingClientRect().height }))) .toMatchObject({ overflow: 0 }); expect(await cell.getByText(email, { exact: true }).evaluate(node => getComputedStyle(node).overflowWrap)).toBe("anywhere"); expect(fixture.errors).toEqual([]); });