test(web): MSW harness + Playwright smoke + QA pass
This commit is contained in:
parent
3014385e9b
commit
95729de201
12 changed files with 300 additions and 8 deletions
96
e2e/smoke.spec.ts
Normal file
96
e2e/smoke.spec.ts
Normal 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
29
playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
35
src/ds/HideAmountsToggle.test.tsx
Normal file
35
src/ds/HideAmountsToggle.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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";
|
import { isHidden, setHidden } from "./money";
|
||||||
|
|
||||||
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
|
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
|
||||||
|
|
@ -25,6 +25,12 @@ export function HideAmountsToggle({ label = "Мөнгөн дүн нуух" }: Hi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwitchRoot checked={checked} onCheckedChange={handleChange}>
|
<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>
|
<SwitchControl>
|
||||||
<SwitchThumb />
|
<SwitchThumb />
|
||||||
</SwitchControl>
|
</SwitchControl>
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ export function AuthForm({
|
||||||
function FormError({ message }: { message?: string }) {
|
function FormError({ message }: { message?: string }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return (
|
return (
|
||||||
<p role="alert" className="text-sm" style={{ color: "#d92626" }}>
|
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
46
src/features/home/dashboard.msw.test.tsx
Normal file
46
src/features/home/dashboard.msw.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -189,7 +189,7 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
loading={remove.isPending}
|
loading={remove.isPending}
|
||||||
style={{ flex: 1, background: "#d92626", color: "#fff" }}
|
style={{ flex: 1, background: "var(--seed-color-fg-critical)", color: "#fff" }}
|
||||||
>
|
>
|
||||||
{profileStrings.categories.deleteAction}
|
{profileStrings.categories.deleteAction}
|
||||||
</MercuryButton>
|
</MercuryButton>
|
||||||
|
|
@ -230,7 +230,7 @@ function CategoryRow({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
aria-label={`${profileStrings.categories.deleteAction}: ${name}`}
|
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}
|
{profileStrings.categories.deleteAction}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -272,7 +272,7 @@ function CategoryChip({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
aria-label={`${profileStrings.categories.deleteAction}: ${category.name}`}
|
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>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export function SettingsForm({ onBack }: SettingsFormProps) {
|
||||||
</SwitchRoot>
|
</SwitchRoot>
|
||||||
|
|
||||||
{save.isError && (
|
{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}
|
{profileStrings.settings.error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ function SubscriptionRow({
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 13,
|
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}
|
{actionLabel}
|
||||||
|
|
@ -248,7 +248,7 @@ function ManualSubscriptionForm({
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" style={{ fontSize: 13, color: "#d92626" }}>
|
<p role="alert" style={{ fontSize: 13, color: "var(--seed-color-fg-critical)" }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
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: "",
|
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 }] };
|
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
42
src/test/handlers.ts
Normal 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
10
src/test/server.ts
Normal 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);
|
||||||
Loading…
Add table
Reference in a new issue