diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..1e5482e --- /dev/null +++ b/e2e/smoke.spec.ts @@ -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); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..8d61cc4 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/src/ds/HideAmountsToggle.test.tsx b/src/ds/HideAmountsToggle.test.tsx new file mode 100644 index 0000000..9807ada --- /dev/null +++ b/src/ds/HideAmountsToggle.test.tsx @@ -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(); + 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(); + expect(screen.getByRole("switch")).toBeChecked(); +}); diff --git a/src/ds/HideAmountsToggle.tsx b/src/ds/HideAmountsToggle.tsx index e076181..a6fb7a4 100644 --- a/src/ds/HideAmountsToggle.tsx +++ b/src/ds/HideAmountsToggle.tsx @@ -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 ( + {/* 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. */} + diff --git a/src/features/auth/AuthForm.tsx b/src/features/auth/AuthForm.tsx index 314fe0c..cc8ffe3 100644 --- a/src/features/auth/AuthForm.tsx +++ b/src/features/auth/AuthForm.tsx @@ -247,7 +247,7 @@ export function AuthForm({ function FormError({ message }: { message?: string }) { if (!message) return null; return ( -

+

{message}

); diff --git a/src/features/home/dashboard.msw.test.tsx b/src/features/home/dashboard.msw.test.tsx new file mode 100644 index 0000000..ee26c3c --- /dev/null +++ b/src/features/home/dashboard.msw.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/src/features/profile/CategoriesManager.tsx b/src/features/profile/CategoriesManager.tsx index 05bb9b2..f3275e9 100644 --- a/src/features/profile/CategoriesManager.tsx +++ b/src/features/profile/CategoriesManager.tsx @@ -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} @@ -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} @@ -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)" }} > ✕ diff --git a/src/features/profile/SettingsForm.tsx b/src/features/profile/SettingsForm.tsx index b5e652d..a3abb80 100644 --- a/src/features/profile/SettingsForm.tsx +++ b/src/features/profile/SettingsForm.tsx @@ -173,7 +173,7 @@ export function SettingsForm({ onBack }: SettingsFormProps) {
{save.isError && ( -

+

{profileStrings.settings.error}

)} diff --git a/src/features/profile/SubscriptionsView.tsx b/src/features/profile/SubscriptionsView.tsx index 559553d..4340a0f 100644 --- a/src/features/profile/SubscriptionsView.tsx +++ b/src/features/profile/SubscriptionsView.tsx @@ -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({ {error && ( -

+

{error}

)} diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index acd857f..679b1db 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -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" }; diff --git a/src/test/handlers.ts b/src/test/handlers.ts new file mode 100644 index 0000000..c6636a8 --- /dev/null +++ b/src/test/handlers.ts @@ -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 })), +]; diff --git a/src/test/server.ts b/src/test/server.ts new file mode 100644 index 0000000..b720bd7 --- /dev/null +++ b/src/test/server.ts @@ -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);