Prepares the full QA test infrastructure for the admin frontend before all prerequisite feature tasks (RCA-12–18) are complete. - playwright.config.ts: 6 browser/device projects (Chromium, Firefox, WebKit, mobile Chrome, mobile Safari, tablet) - tests/e2e/01-login.spec.ts: login form, route guards, setup wizard - tests/e2e/02-dashboard.spec.ts: stats cards, device list, quick actions - tests/e2e/03-cookies.spec.ts: cookie list, search, detail panel, delete - tests/e2e/04-devices.spec.ts: device cards, revoke flow, status filter - tests/e2e/05-settings.spec.ts: three-tab layout, save/error toasts - tests/e2e/06-responsive.spec.ts: no horizontal scroll on mobile/tablet - tests/api/admin-api.spec.ts: REST API contract tests for all /admin/* endpoints - helpers/auth.ts: loginViaUI + loginViaAPI helpers - helpers/mock-api.ts: route intercept fixtures for all pages Co-Authored-By: Paperclip <noreply@paperclip.ing>
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { loginViaAPI } from "./helpers/auth.js";
|
|
import { mockSettings, mockAPIError } from "./helpers/mock-api.js";
|
|
|
|
/**
|
|
* RCA-18: Settings page
|
|
*
|
|
* Covers:
|
|
* - Three tabs: Sync / Security / Appearance
|
|
* - Settings are pre-populated from GET /admin/settings
|
|
* - Changes saved via PATCH /admin/settings
|
|
* - Success toast on save
|
|
* - Password change (security tab)
|
|
* - Theme selection (appearance tab)
|
|
* - Language selection (appearance tab)
|
|
* - API error on save
|
|
*/
|
|
|
|
test.describe("Settings page", () => {
|
|
test.beforeEach(async ({ page, request }) => {
|
|
await loginViaAPI(page, request);
|
|
await mockSettings(page);
|
|
});
|
|
|
|
test("displays three tabs: sync, security, appearance", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
|
|
await expect(page.getByRole("tab", { name: /sync/i })).toBeVisible();
|
|
await expect(page.getByRole("tab", { name: /security/i })).toBeVisible();
|
|
await expect(page.getByRole("tab", { name: /appearance/i })).toBeVisible();
|
|
});
|
|
|
|
// --- Sync tab ---
|
|
|
|
test("sync tab: auto-sync toggle reflects saved value", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
|
|
await page.getByRole("tab", { name: /sync/i }).click();
|
|
|
|
const toggle = page
|
|
.getByRole("switch", { name: /auto.?sync/i })
|
|
.or(page.getByLabel(/auto.?sync/i));
|
|
// Mock returns autoSync: true
|
|
await expect(toggle).toBeChecked();
|
|
});
|
|
|
|
test("sync tab: frequency selector shows current value", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /sync/i }).click();
|
|
|
|
// Mock returns frequency: "realtime"
|
|
const select = page
|
|
.getByLabel(/frequency/i)
|
|
.or(page.getByRole("combobox").filter({ hasText: /realtime/i }));
|
|
await expect(select).toBeVisible();
|
|
});
|
|
|
|
test("sync tab: saving calls PATCH /admin/settings", async ({ page }) => {
|
|
let patchCalled = false;
|
|
await page.route("**/admin/settings", (route) => {
|
|
if (route.request().method() === "PATCH") {
|
|
patchCalled = true;
|
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
|
}
|
|
return route.continue();
|
|
});
|
|
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /sync/i }).click();
|
|
|
|
// Toggle auto-sync off
|
|
const toggle = page
|
|
.getByRole("switch", { name: /auto.?sync/i })
|
|
.or(page.getByLabel(/auto.?sync/i));
|
|
await toggle.click();
|
|
|
|
// Some implementations save immediately; others have an explicit Save button
|
|
const saveBtn = page.getByRole("button", { name: /save/i });
|
|
if (await saveBtn.isVisible()) await saveBtn.click();
|
|
|
|
expect(patchCalled).toBe(true);
|
|
});
|
|
|
|
test("sync tab: success toast appears after save", async ({ page }) => {
|
|
await page.route("**/admin/settings", (route) => {
|
|
if (route.request().method() === "PATCH") {
|
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
|
}
|
|
return route.continue();
|
|
});
|
|
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /sync/i }).click();
|
|
|
|
const toggle = page
|
|
.getByRole("switch", { name: /auto.?sync/i })
|
|
.or(page.getByLabel(/auto.?sync/i));
|
|
await toggle.click();
|
|
|
|
const saveBtn = page.getByRole("button", { name: /save/i });
|
|
if (await saveBtn.isVisible()) await saveBtn.click();
|
|
|
|
await expect(
|
|
page.getByText(/saved|success|updated/i).first(),
|
|
).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
// --- Security tab ---
|
|
|
|
test("security tab: change password requires current + new + confirm", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /security/i }).click();
|
|
|
|
await expect(page.getByLabel(/current password/i)).toBeVisible();
|
|
await expect(page.getByLabel(/new password/i)).toBeVisible();
|
|
await expect(page.getByLabel(/confirm.*(new )?password/i)).toBeVisible();
|
|
});
|
|
|
|
test("security tab: password change with mismatch shows error", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /security/i }).click();
|
|
|
|
await page.getByLabel(/current password/i).fill("oldPass123");
|
|
await page.getByLabel(/new password/i).fill("NewPass456!");
|
|
await page.getByLabel(/confirm.*(new )?password/i).fill("Different789!");
|
|
|
|
await page.getByRole("button", { name: /change|save password|update/i }).click();
|
|
|
|
await expect(page.getByText(/do not match|mismatch|must match/i)).toBeVisible();
|
|
});
|
|
|
|
test("security tab: session timeout field accepts numeric input", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /security/i }).click();
|
|
|
|
const timeoutField = page
|
|
.getByLabel(/session timeout/i)
|
|
.or(page.getByRole("spinbutton").filter({ hasText: /timeout/i }));
|
|
if (await timeoutField.isVisible()) {
|
|
await timeoutField.fill("120");
|
|
await expect(timeoutField).toHaveValue("120");
|
|
}
|
|
});
|
|
|
|
// --- Appearance tab ---
|
|
|
|
test("appearance tab: theme options present (light/dark/system)", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /appearance/i }).click();
|
|
|
|
await expect(page.getByText(/light/i)).toBeVisible();
|
|
await expect(page.getByText(/dark/i)).toBeVisible();
|
|
await expect(page.getByText(/system/i)).toBeVisible();
|
|
});
|
|
|
|
test("appearance tab: language selector shows Chinese and English options", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /appearance/i }).click();
|
|
|
|
await expect(
|
|
page.getByText(/chinese|中文/i).or(page.getByText("zh")),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText(/english/i).or(page.getByText("en")),
|
|
).toBeVisible();
|
|
});
|
|
|
|
// --- Error states ---
|
|
|
|
test("shows error message when settings fail to load", async ({ page }) => {
|
|
await page.unroute("**/admin/settings*");
|
|
await mockAPIError(page, "**/admin/settings*", 500, "Failed to load settings");
|
|
|
|
await page.goto("/settings");
|
|
await expect(
|
|
page
|
|
.getByRole("alert")
|
|
.or(page.getByText(/error|failed|could not load/i))
|
|
.first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("shows error toast when save fails", async ({ page }) => {
|
|
await page.route("**/admin/settings", (route) => {
|
|
if (route.request().method() === "PATCH") {
|
|
return route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Server error" }),
|
|
});
|
|
}
|
|
return route.continue();
|
|
});
|
|
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: /sync/i }).click();
|
|
|
|
const toggle = page
|
|
.getByRole("switch", { name: /auto.?sync/i })
|
|
.or(page.getByLabel(/auto.?sync/i));
|
|
await toggle.click();
|
|
|
|
const saveBtn = page.getByRole("button", { name: /save/i });
|
|
if (await saveBtn.isVisible()) await saveBtn.click();
|
|
|
|
await expect(
|
|
page.getByText(/error|failed|could not save/i).first(),
|
|
).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|