import { expect, test, type Page } from "@playwright/test"; async function install(page: Page, options: { rejectSave?: boolean; rejectReload?: boolean; conflict?: boolean; hold?: Promise; holdFirstRead?: Promise } = {}) { page.on("pageerror", (error) => { console.error("Campaign saving fixture:", error.message); }); let revision = 1; let subject = "Original subject"; let rejected = false; const writes: Record[] = []; let reads = 0; const version = () => ({ id: "version-a", campaign_id: "campaign-a", version_number: 1, edit_revision: revision, strong_etag: `"version-a:${revision}"`, editor_state: {}, current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false, updated_at: "2026-09-07T10:00:00Z", raw_json: { campaign: { name: "Original" }, template: { subject, text: "" }, server: {} } }); await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) { const body = route.request().postDataJSON(); writes.push(body); if (options.hold) await options.hold; if (options.rejectSave && !rejected) { rejected = true; return route.fulfill({ status: 422, json: { detail: "Fixture policy denied this save" } }); } if ((options.conflict && !rejected) || body.base_revision !== revision) { rejected = true; if (options.conflict) { revision = 2; subject = "Other editor's subject"; } return route.fulfill({ status: 409, json: { detail: { code: "revision_conflict", resource: { type: "campaign_version", id: "version-a" }, current_revision: revision, submitted_base_revision: body.base_revision, retryable: true } } }); } subject = body.campaign_json.template.subject; revision += 1; return route.fulfill({ json: version() }); } if (route.request().method() === "GET" && route.request().url().includes("/versions/version-a")) { reads++; const snapshot = version(); if (reads === 1 && options.holdFirstRead) await options.holdFirstRead; return options.rejectReload ? route.fulfill({ status: 503, json: { detail: "Fixture refresh unavailable" } }) : route.fulfill({ json: snapshot }); } return route.abort(); }); await page.goto("/?campaign-saving&language=en"); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Original subject"); return { writes, reads: () => reads, subject: () => subject }; } test("a rejected campaign save retains its draft and retries only on explicit request", async ({ page }) => { const fixture = await install(page, { rejectSave: true }); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Unsaved work"); await page.getByRole("button", { name: "Save draft", exact: true }).click(); await expect(page.getByTestId("save-error")).toContainText("Fixture policy denied"); await expect(page.getByTestId("save-dirty")).toHaveText("true"); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Unsaved work"); expect(fixture.writes).toHaveLength(1); await page.getByRole("button", { name: "Save draft", exact: true }).click(); await expect(page.getByTestId("save-result")).toHaveText("true"); await expect(page.getByTestId("save-dirty")).toHaveText("false"); expect(fixture.writes).toHaveLength(2); expect(fixture.subject()).toBe("Unsaved work"); }); test("duplicate save requests coalesce and newer typing survives the older acknowledgement", async ({ page }) => { let release!: () => void; const fixture = await install(page, { hold: new Promise((resolve) => { release = resolve; }) }); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Submitted work"); await page.getByRole("button", { name: "Request save twice", exact: true }).click(); await expect.poll(() => fixture.writes.length).toBe(1); await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeDisabled(); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer unsaved work"); release(); await expect(page.getByTestId("save-busy")).toHaveText("false"); await expect(page.getByTestId("save-dirty")).toHaveText("true"); await expect(page.getByTestId("save-status")).toContainText("newer changes are still unsaved"); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer unsaved work"); expect(fixture.subject()).toBe("Submitted work"); expect(fixture.writes).toHaveLength(1); await page.getByRole("button", { name: "Save draft", exact: true }).click(); await expect(page.getByRole("alertdialog", { name: "Concurrent changes" })).toBeVisible(); await page.getByRole("tab", { name: "Use my change", exact: true }).click(); await page.getByRole("button", { name: "Apply resolution", exact: true }).click(); await expect(page.getByTestId("save-dirty")).toHaveText("false"); expect(fixture.subject()).toBe("Newer unsaved work"); }); test("failed follow-up refresh never turns an acknowledged save into save failed", async ({ page }) => { const fixture = await install(page, { rejectReload: true }); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Committed work"); await page.getByRole("button", { name: "Save draft", exact: true }).click(); await expect(page.getByTestId("save-result")).toHaveText("true"); await expect(page.getByTestId("save-status")).toContainText("Saved"); await expect(page.getByTestId("save-error")).toContainText("was saved"); await expect(page.getByTestId("save-dirty")).toHaveText("false"); expect(fixture.writes).toHaveLength(1); }); test("cancelling a revision conflict ends saving and keeps the local draft", async ({ page }) => { const fixture = await install(page, { conflict: true }); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("My conflicting work"); await page.getByRole("button", { name: "Save draft", exact: true }).click(); const dialog = page.getByRole("alertdialog", { name: "Concurrent changes" }); await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(page.getByTestId("save-busy")).toHaveText("false"); await expect(page.getByTestId("save-status")).toContainText("Save cancelled"); await expect(page.getByTestId("save-dirty")).toHaveText("true"); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("My conflicting work"); expect(fixture.writes).toHaveLength(1); }); test("background version refresh and failed discard do not silently clear unsaved work", async ({ page }) => { await install(page, { rejectReload: true }); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Keep this draft"); await page.getByRole("button", { name: "Background refresh", exact: true }).click(); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft"); await page.getByRole("button", { name: "Discard draft", exact: true }).click(); await expect(page.getByTestId("save-error")).toContainText("Fixture refresh unavailable"); await expect(page.getByTestId("save-dirty")).toHaveText("true"); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft"); }); test("new typing during a discard refresh is retained instead of being replaced by the late response", async ({ page }) => { let release!: () => void; const fixture = await install(page, { holdFirstRead: new Promise(resolve => { release = resolve; }) }); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Earlier draft"); await page.getByRole("button", { name: "Discard draft", exact: true }).click(); await expect.poll(fixture.reads).toBe(1); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer draft"); release(); await expect(page.getByTestId("save-error")).toContainText("Discard cancelled"); await expect(page.getByTestId("save-dirty")).toHaveText("true"); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer draft"); expect(fixture.writes).toHaveLength(0); }); test("a late discard response cannot overwrite an acknowledged save", async ({ page }) => { let release!: () => void; const fixture = await install(page, { holdFirstRead: new Promise(resolve => { release = resolve; }) }); await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Saved work"); await page.getByRole("button", { name: "Discard draft", exact: true }).click(); await expect.poll(fixture.reads).toBe(1); await page.getByRole("button", { name: "Save draft", exact: true }).click(); await expect(page.getByTestId("save-result")).toHaveText("true"); release(); await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Saved work"); await expect(page.getByTestId("save-dirty")).toHaveText("false"); expect(fixture.subject()).toBe("Saved work"); });