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