Files
CookieBridge/web/tests/e2e/05-settings.spec.ts
徐枫 f4144c96f1 test(web): add Playwright E2E and admin API test suite for RCA-19
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>
2026-03-17 20:24:22 +08:00

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 });
});
});