Files
bookhoard/e2e/settings-feedback.spec.ts
T
John O'Keefe 9f39e2efd8
Release / build-and-push (push) Successful in 4m35s
feat(settings): save feedback — success/error toasts, disabled buttons; fix scan-form raw-JSON swap
None of the three save surfaces on the admin settings page gave usable
feedback: the Base URL form re-rendered into an identical-looking form
(its only indicator was the Go-source twin's banner — markup that
diverged from the templ original), the scan form was WORSE — its handler
responds JSON, so htmx's outerHTML swap replaced the whole scan card
with literal JSON text — and the tunable rows showed only a tiny
per-row label.

Client side (toasts are the sanctioned client-local island; no new
inline scripts, CSP unchanged):

- web/src/settings.ts: one delegated htmx:afterRequest/sendError
  listener keyed on a data-settings-save attribute stamped on each
  form. Success → green toast per surface ("Settings saved" / "Scan
  settings saved" / "Setting saved"); failure → error toast carrying
  the server's JSON error field, degrading to a status line for the
  tunable endpoint's HTML error body (markup must never reach a toast).
  Network-level failures (htmx:sendError) toast instead of silently
  re-enabling the button.
- htmx 2.x quirk handled: for FORM submissions, afterRequest's
  detail.elt is the event-dispatch ancestor, not the form — the
  requesting element lives on detail.requestConfig.elt (verified
  empirically; saveLabelFor reads the right one).
- hx-disabled-elt="this" on all save buttons: the click is
  acknowledged and double-clicks cannot double-save.

Server side:

- Scan form: hx-swap="none" (the inputs already hold the saved values;
  nothing needs re-rendering) — kills the JSON-over-the-card swap.
- The Base URL form's save response is a Go-source copy of the templ
  form (handlers cannot import templates: templates/utils.go imports
  handlers types). The twin now carries the functional attributes
  (data-settings-save, hx-disabled-elt) so feedback survives the swap,
  with a drift-warning comment; the double-save e2e pins this.

Dev-DB safety: every success save in the spec re-submits the value
already on the page (zero drift), and the spec restores
system_config.updated_by after itself — the save handler stamps the
requesting admin's id there, and that FK (no cascade) otherwise blocks
the integration harness's transient-user sweep (it broke
TestUpdateProfileAdminMode/TestDeleteUserConsolidated on this run's
first pass; pollution cleaned up by hand and now self-restoring).

Tests: web/src/settings.test.ts pins the form→label map and the
error-extraction contract (including the no-markup-in-toast rule);
e2e/settings-feedback.spec.ts drives all three surfaces through the
real admin UI including the error path and the twin-drift double-save.
2026-10-28 00:22:00 -04:00

161 lines
6.6 KiB
TypeScript

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<string> {
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();
});
});