import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; import { execSync } from "child_process"; const BASE = "http://localhost:8765"; const STAMP = Date.now(); const EMAIL = `e2e-setfb-${STAMP}@example.com`; const USERNAME = `e2e-setfb-${STAMP}`; const PASSWORD = "E2e-Playwright-2026!"; // Settings-page save feedback: every save surface must acknowledge the // click (toast) and the scan form must NOT swap the handler's JSON body // over its card (the pre-toast behavior — raw JSON replaced the form). // // Dev-state safety: every success save re-submits the value ALREADY in // the input (read from the page first), so nothing in the shared dev DB // changes; the error-path request is rejected server-side before any // write. let token = ""; let page = null as Page | null; // system_config.updated_by audit restore: the config save handler stamps // the requesting admin's id onto system_config rows. The harness cleanup // deletes transient example.com users, and this FK (no cascade) would // block the delete and fail unrelated integration tests. Capture before // any save, restore after. let e2eUserID = ""; let origUpdatedBy = ""; function psql(sql: string): string { const flat = sql.replace(/\s+/g, " "); return execSync( `docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`, ) .toString() .trim(); } async function apiLogin(request: APIRequestContext): Promise { const res = await request.post("/api/auth/login", { data: { login: EMAIL, password: PASSWORD }, }); expect(res.status(), "login should succeed").toBe(200); return (await res.json()).access_token as string; } test.describe.configure({ mode: "serial" }); test.describe("Settings save feedback (toasts + disabled buttons)", () => { test.beforeAll(async ({ browser, request }) => { // Fresh user, promoted to admin in the dev DB (the harness cleanup // sweeps transient example.com users afterwards). const reg = await request.post("/api/auth/register", { data: { email: EMAIL, username: USERNAME, password: PASSWORD }, }); expect([201, 200, 409]).toContain(reg.status()); psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`); token = await apiLogin(request); expect(token).toBeTruthy(); e2eUserID = psql(`SELECT id FROM users WHERE email='${EMAIL}'`); origUpdatedBy = psql(`SELECT COALESCE(updated_by::text, '') FROM system_config WHERE key='base_url'`); page = await browser.newPage(); await page.context().addCookies([{ name: "token", value: token, url: BASE }]); await page.goto("/admin/settings"); await expect(page.locator("h1")).toHaveText("System Settings"); }); test.afterAll(async () => { await page?.close(); // Hand system_config.updated_by back to whoever held it (or NULL), // so the transient user can be swept. if (e2eUserID) { const restore = origUpdatedBy ? `'${origUpdatedBy}'::uuid` : "NULL"; psql(`UPDATE system_config SET updated_by = ${restore} WHERE updated_by = '${e2eUserID}'::uuid`); } }); test("Base URL save shows a success toast — twice (twin-form drift pin)", async () => { const input = page.locator("#settings-form input[name='base_url']"); await expect(input).toBeVisible(); // First save goes through the templ form… await page.locator("#settings-form button[type='submit']").first().click(); await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({ timeout: 10_000, }); // …the second through the Go-source twin the handler swaps in after // the first save. If the twin ever drops data-settings-save (the // handlers→templates import cycle is why the twin exists), THIS is // the assertion that fails. await page.waitForTimeout(300); // let the swap settle await expect(page.locator("#settings-form")).toBeVisible(); await page.locator("#settings-form button[type='submit']").first().click(); await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({ timeout: 10_000, }); }); test("Scan settings save shows a toast and never swaps raw JSON over the card", async () => { const section = page.locator("#scan-settings-section"); await expect(section).toBeVisible(); await section.locator("button[type='submit']").click(); await expect(page.getByText("Scan settings saved", { exact: true })).toBeVisible({ timeout: 10_000, }); // The regression: the scan handler responds JSON; with the old // hx-swap="outerHTML" the card was replaced by the literal JSON text. await expect(section).toBeVisible(); await expect(section.locator("form")).toBeVisible(); const body = await page.locator("body").innerText(); expect(body).not.toContain("scan settings updated successfully"); }); test("Tunable row save shows a toast and the per-row status span", async () => { const row = page.locator("form[data-settings-save='tunable']").first(); await expect(row).toBeVisible(); const key = await row.locator("input[name='key']").getAttribute("value"); expect(key).toBeTruthy(); await row.locator("button[type='submit']").click(); await expect(page.getByText("Setting saved", { exact: true })).toBeVisible({ timeout: 10_000, }); // Server-driven per-row status (kept alongside the toast; it carries // the "restart required" nuance). await expect(page.locator(`#status-${key}`)).toContainText("Saved", { timeout: 10_000, }); }); test("Invalid value shows an error toast and the form survives", async () => { // Inject a genuinely invalid option and select it: setting a select // to a value with no matching option silently serializes as NO field // at all (no selected option → nothing submitted), which would save // fine instead of failing. With a real option present, the server // rejects with 400 {"error":"invalid timezone"} before any write. await page.evaluate(() => { const select = document.getElementById("default_timezone") as HTMLSelectElement; const opt = document.createElement("option"); opt.value = "Not/AZone"; opt.textContent = "E2E invalid"; select.appendChild(opt); select.value = "Not/AZone"; }); await page.locator("#settings-form button[type='submit']").first().click(); await expect(page.getByText("Save failed: invalid timezone")).toBeVisible({ timeout: 10_000, }); // htmx does not swap non-2xx bodies: the form is intact. await expect(page.locator("#settings-form")).toBeVisible(); await expect(page.locator("#settings-form input[name='base_url']")).toBeVisible(); }); });