test(web): MSW harness + Playwright smoke + QA pass

This commit is contained in:
Munkherdene 2026-08-22 21:09:59 +08:00
parent 3014385e9b
commit 95729de201
12 changed files with 300 additions and 8 deletions

96
e2e/smoke.spec.ts Normal file
View file

@ -0,0 +1,96 @@
import { test, expect } from "@playwright/test";
import * as fx from "../src/test/fixtures";
/**
* One smoke path: /login submit /home renders the real dashboard.
*
* There's no live Go backend in CI, so this intercepts the network at the
* browser layer (page.route) rather than standing up a stub server the
* Next dev server (and all of its own client/server code, including the
* httpOnly-cookie auth route and the middleware guard) runs for real; only
* the two things that would otherwise reach the Go backend
* (`/api/auth/login`, `/api/v1/*`) are fulfilled with fixtures.
*
* Also asserts the two security-relevant properties the review flagged:
* - the session cookie set on login is HttpOnly
* - no response body observed during the flow contains a raw bearer token
* (the Next auth route is supposed to strip `token` before it reaches the
* browser see src/app/api/auth/login/route.ts)
*/
test("login → home renders the dashboard; session cookie is httpOnly; no raw token leaks", async ({
page,
context,
}) => {
const responseBodies: string[] = [];
page.on("response", (response) => {
response
.text()
.then((body) => responseBodies.push(body))
.catch(() => {
/* non-text bodies (redirects, static assets, etc.) — not relevant here */
});
});
await page.route("**/api/auth/login", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"set-cookie": "mercury_session=e2e-fake-session; Path=/; HttpOnly; SameSite=Lax",
},
body: JSON.stringify({ user: fx.user }),
});
});
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const path = url.pathname.replace(/^\/api\/v1/, "");
const body: unknown =
path === "/networth"
? fx.netWorth
: path === "/analyze"
? url.searchParams.has("from") || url.searchParams.has("to")
? fx.analyzeToday
: fx.analyzeMonth
: path === "/budget"
? fx.budget
: path === "/transactions"
? { transactions: [fx.txn] }
: path === "/categories"
? { categories: fx.categories }
: path === "/subscriptions"
? { subscriptions: fx.subscriptions, bills: fx.bills }
: path === "/manual-assets"
? { manualAssets: fx.manualAssets }
: path === "/lending"
? { entries: [fx.lending] }
: path === "/settings"
? fx.settings
: path === "/connections"
? { connections: fx.connections }
: path === "/me"
? { user: fx.user }
: {};
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
});
await page.goto("/login");
await page.getByLabel("Имэйл хаяг").fill("test@example.com");
await page.getByLabel("Нууц үг").fill("password12345");
await page.getByRole("button", { name: "Нэвтрэх" }).click();
await page.waitForURL("**/home");
// The dashboard header wordmark, and the real safe-to-spend figure derived
// from the intercepted /networth + /analyze + /budget fixtures (900,000
// monthLimit 800,000 discretionaryExpense — see buildHome.test.ts).
await expect(page.getByText("MERCURY")).toBeVisible();
await expect(page.getByText("100,000₮")).toBeVisible();
const cookies = await context.cookies();
const session = cookies.find((c) => c.name === "mercury_session");
expect(session, "mercury_session cookie should be set after login").toBeTruthy();
expect(session?.httpOnly).toBe(true);
const leaked = responseBodies.some((body) => /"token"\s*:\s*"/.test(body));
expect(leaked, "no observed response body should contain a raw token field").toBe(false);
});

29
playwright.config.ts Normal file
View file

@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
/**
* The one smoke path (e2e/smoke.spec.ts) drives the real Next dev server
* no live Go backend in CI, so the spec intercepts `/api/auth/login` and
* `/api/v1/*` at the browser network layer (page.route) and fulfills them
* with fixtures, rather than pointing FMS_API_URL at a stub server. This
* keeps auth (httpOnly Set-Cookie) and the app's real client-side code
* exactly as shipped; only the network boundary is faked.
*/
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: "list",
use: {
baseURL: "http://localhost:3100",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "bun run dev -- -p 3100",
url: "http://localhost:3100",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});

View file

@ -0,0 +1,35 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { it, expect, beforeEach } from "vitest";
import { HideAmountsToggle } from "./HideAmountsToggle";
import { isHidden, tugrik } from "./money";
beforeEach(() => {
try {
localStorage.clear();
} catch {}
});
// Flagged in review: the global hide-amounts switch must actually persist
// (write through to localStorage via setHidden), not just flip its own local
// `checked` state — so isHidden() (and every amount formatted with tugrik())
// reflects the change, and a freshly-mounted toggle picks the persisted value
// back up (e.g. after navigating to a different tab and back).
it("hideAmounts toggle persists: flips isHidden() and masks amounts, and survives remount", () => {
expect(isHidden()).toBe(false);
render(<HideAmountsToggle />);
const toggle = screen.getByRole("switch");
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(isHidden()).toBe(true);
expect(tugrik("52000")).toBe("••••••");
expect(toggle).toBeChecked();
// Persists across a remount (e.g. leaving and returning to the page) since
// the initial `checked` state is read from isHidden(), not re-initialized.
cleanup();
render(<HideAmountsToggle />);
expect(screen.getByRole("switch")).toBeChecked();
});

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel } from "@seed-design/react";
import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel, SwitchHiddenInput } from "@seed-design/react";
import { isHidden, setHidden } from "./money";
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
@ -25,6 +25,12 @@ export function HideAmountsToggle({ label = "Мөнгөн дүн нуух" }: Hi
return (
<SwitchRoot checked={checked} onCheckedChange={handleChange}>
{/* The actual interactive/accessible element (role="switch",
checked/onChange) lives on the hidden input SwitchControl and
SwitchThumb are purely decorative (aria-hidden). Without this the
switch renders but nothing is clickable or announced to
assistive tech. */}
<SwitchHiddenInput />
<SwitchControl>
<SwitchThumb />
</SwitchControl>

View file

@ -247,7 +247,7 @@ export function AuthForm({
function FormError({ message }: { message?: string }) {
if (!message) return null;
return (
<p role="alert" className="text-sm" style={{ color: "#d92626" }}>
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
{message}
</p>
);

View file

@ -0,0 +1,46 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { server } from "../../test/server";
import { DashboardView } from "./DashboardView";
/**
* Integration test: DashboardView wired to real react-query hooks, backed by
* MSW (not a hook mock) serving /networth + /analyze (month & today) +
* /budget from the fixtures in src/test/fixtures.ts. Asserts the dashboard
* renders the REAL computed figures (buildHome output) rather than falling
* back to the `sample` placeholder see buildHome.test.ts for the expected
* numbers this fixture set produces: safeToSpend 100,000 (900,000 monthLimit
* 800,000 discretionaryExpense) and monthlyExpense 800,000, both of which
* differ from `sample`'s 836,795 / 1,2сая.
*/
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
function renderDashboard() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<DashboardView />
</QueryClientProvider>,
);
}
describe("DashboardView (MSW integration)", () => {
it("renders the real safe-to-spend and monthly-spend figures, not the sample fallback", async () => {
renderDashboard();
// Real safe-to-spend (900,000 monthLimit 800,000 discretionaryExpense).
await waitFor(() => expect(screen.getByText("100,000₮")).toBeInTheDocument());
// Real this-month spend (discretionaryExpense, sub-million so shown in
// full) — appears both in the hero's "spent" line (AmountToggle button)
// and the "This month" card (plain text), so allow either/both.
expect(screen.getAllByText("800,000₮").length).toBeGreaterThan(0);
// The sample fallback's distinctive figures must NOT appear.
expect(screen.queryByText("836,795₮")).not.toBeInTheDocument();
expect(screen.queryByText("1,2сая₮")).not.toBeInTheDocument();
});
});

View file

@ -189,7 +189,7 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
variant="primary"
onClick={handleConfirmDelete}
loading={remove.isPending}
style={{ flex: 1, background: "#d92626", color: "#fff" }}
style={{ flex: 1, background: "var(--seed-color-fg-critical)", color: "#fff" }}
>
{profileStrings.categories.deleteAction}
</MercuryButton>
@ -230,7 +230,7 @@ function CategoryRow({
type="button"
onClick={onDelete}
aria-label={`${profileStrings.categories.deleteAction}: ${name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "#d92626" }}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--seed-color-fg-critical)" }}
>
{profileStrings.categories.deleteAction}
</button>
@ -272,7 +272,7 @@ function CategoryChip({
type="button"
onClick={onDelete}
aria-label={`${profileStrings.categories.deleteAction}: ${category.name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "#d92626" }}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-critical)" }}
>
</button>

View file

@ -173,7 +173,7 @@ export function SettingsForm({ onBack }: SettingsFormProps) {
</SwitchRoot>
{save.isError && (
<p role="alert" style={{ fontSize: 13, color: "#d92626" }}>
<p role="alert" style={{ fontSize: 13, color: "var(--seed-color-fg-critical)" }}>
{profileStrings.settings.error}
</p>
)}

View file

@ -154,7 +154,7 @@ function SubscriptionRow({
border: "none",
cursor: "pointer",
fontSize: 13,
color: destructive ? "#d92626" : "var(--seed-color-fg-muted, #6b7280)",
color: destructive ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-muted, #6b7280)",
}}
>
{actionLabel}
@ -248,7 +248,7 @@ function ManualSubscriptionForm({
</label>
{error && (
<p role="alert" style={{ fontSize: 13, color: "#d92626" }}>
<p role="alert" style={{ fontSize: 13, color: "var(--seed-color-fg-critical)" }}>
{error}
</p>
)}

View file

@ -12,3 +12,31 @@ export const txn = { date: "2026-08-01", amount: "52000", direction: "debit", ca
balanceAfter: "400200", accountId: 1, transfer: false, txnId: 10, note: "", matchKey: "mk1", salary: false };
export const lending = { id: 1, person: "Бат", principal: "100000", lentOn: "2026-07-01", dueOn: "2026-09-01", note: "",
repaid: "40000", remaining: "60000", status: "partial", overdue: false, txnId: null, repayments: [{ id: 1, amount: "40000", paidOn: "2026-08-01", note: "", txnId: null }] };
// --- Task 14: fixtures for the remaining read endpoints, so the MSW handler
// set (src/test/handlers.ts) can cover every /api/v1/* GET the app makes. ---
export const analyzeToday = { from: "2026-08-22", to: "2026-08-22", income: "0", expense: "52000",
discretionaryExpense: "52000", net: "-52000", months: null,
expenseCategories: [{ name: "Хоол", count: 1, total: "52000" }],
incomePayees: [], expensePayees: [{ name: "худалдан авалт", count: 1, total: "52000" }] };
export const category = { name: "Хоол", kind: "expense", depth: 0, icon: null };
export const categories = [category];
export const subscription = { label: "Netflix", amount: "12900", monthly: "12900", cadence: "monthly",
nextDue: "2026-09-01", matchKey: "netflix", id: 1, manual: false, category: "Хоол" };
export const subscriptions = [subscription];
export const bills: typeof subscriptions = [];
export const manualAsset = { name: "Toyota Prius", category: "vehicle", value: "25000000",
acquiredValue: "20000000", currency: "MNT", condition: "used", isLiability: false, change: "5000000" };
export const manualAssets = [manualAsset];
export const settings = { holderName: "Бат", employer: "ABC LLC", salaryKeywords: ["цалин"], payDays: [1],
ownAccounts: ["***1"], peerAccounts: [], hideAmounts: false };
export const connection = { bank: "khan", username: "user1", courierManaged: false };
export const connections = [connection];
export const user = { id: 1, email: "test@example.com", createdAt: "2026-01-01T00:00:00Z" };

42
src/test/handlers.ts Normal file
View file

@ -0,0 +1,42 @@
import { http, HttpResponse } from "msw";
import * as fixtures from "./fixtures";
/**
* MSW (v2) request handlers for every `/api/v1/*` read endpoint the app
* calls (see src/api/hooks/reads.ts), returning the fixtures in ./fixtures.ts.
* Reusable by any feature test that needs the whole read surface mocked
* `setupServer(...handlers)` in ./server.ts, or override per-test with
* `server.use(...)` for error/loading-state cases.
*/
export const handlers = [
http.get("/api/v1/networth", () => HttpResponse.json(fixtures.netWorth)),
// Same endpoint backs both the month view (no query) and the "today" view
// (?from=&to=); distinguish on the query string, matching useAnalyzeMonth
// vs useAnalyzeToday in src/api/hooks/reads.ts.
http.get("/api/v1/analyze", ({ request }) => {
const url = new URL(request.url);
const isToday = url.searchParams.has("from") || url.searchParams.has("to");
return HttpResponse.json(isToday ? fixtures.analyzeToday : fixtures.analyzeMonth);
}),
http.get("/api/v1/budget", () => HttpResponse.json(fixtures.budget)),
http.get("/api/v1/transactions", () => HttpResponse.json({ transactions: [fixtures.txn] })),
http.get("/api/v1/categories", () => HttpResponse.json({ categories: fixtures.categories })),
http.get("/api/v1/subscriptions", () =>
HttpResponse.json({ subscriptions: fixtures.subscriptions, bills: fixtures.bills }),
),
http.get("/api/v1/manual-assets", () => HttpResponse.json({ manualAssets: fixtures.manualAssets })),
http.get("/api/v1/lending", () => HttpResponse.json({ entries: [fixtures.lending] })),
http.get("/api/v1/settings", () => HttpResponse.json(fixtures.settings)),
http.get("/api/v1/connections", () => HttpResponse.json({ connections: fixtures.connections })),
http.get("/api/v1/me", () => HttpResponse.json({ user: fixtures.user })),
];

10
src/test/server.ts Normal file
View file

@ -0,0 +1,10 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
/**
* The shared MSW (v2) node server for integration tests. A feature test
* starts/resets/closes it itself (beforeAll/afterEach/afterAll) rather than
* this file doing it globally, so unit tests that don't need network mocking
* pay no cost. See src/features/home/dashboard.msw.test.tsx for the pattern.
*/
export const server = setupServer(...handlers);