From 3ab85f85f9415aa24132098a996cae0cdeb45e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:07:29 +0200 Subject: [PATCH] test(measurements): drive the measurement form in a real browser Type into the form, press Save and read the request body off the fetch boundary: blood pressure as one batch, a weight typed in lb stored in kg, a decimal comma, glucose typed in mmol/L stored in mg/dL with its meal context, a sleep typed as 7:30 stored as 450 minutes. Native required validation, the pending state, a refused write's localised message, the module-off type list, the last-used type and the menu's Reset are covered too. The unit-label and glucose-unit markup tests are deleted; their source pins are replaced by the wire assertions. The module-types file keeps its pure ownership checks and drops its render. The browser helper gains a Date-only clock pin, clears localStorage between tests, carries an envelope's meta, and answers the providers' locale mirror without counting it as the component's request. --- .../baselines/test-source-reads.json | 5 +- .../baselines/version-stamp-comments.json | 5 +- .../measurement-form-glucose-unit.test.tsx | 98 ------ ... => measurement-form-module-types.test.ts} | 49 +-- .../measurement-form-unit-label.test.tsx | 66 ---- .../measurement-form.browser.test.tsx | 309 ++++++++++++++++++ tests/browser/clock.ts | 14 + tests/browser/fetch-stub.ts | 21 +- tests/browser/setup.ts | 9 +- 9 files changed, 356 insertions(+), 220 deletions(-) delete mode 100644 src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx rename src/components/measurements/__tests__/{measurement-form-module-types.test.tsx => measurement-form-module-types.test.ts} (60%) delete mode 100644 src/components/measurements/__tests__/measurement-form-unit-label.test.tsx create mode 100644 src/components/measurements/__tests__/measurement-form.browser.test.tsx create mode 100644 tests/browser/clock.ts diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 4cafc7881..3e6c5204c 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 358, - "total": 358, + "count": 357, + "total": 357, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -213,7 +213,6 @@ "components/layout/__tests__/offline-banner.test.tsx": 1, "components/layout/__tests__/sidebar-nav.test.tsx": 1, "components/measurements/__tests__/duration-read-as.test.tsx": 1, - "components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1, "components/medications/__tests__/add-inventory-dialog.test.tsx": 1, "components/medications/__tests__/detail-page-cascade.test.tsx": 1, "components/medications/__tests__/inventory-section-dates.test.tsx": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 8e942a370..bf030cc41 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2949, - "total": 10293, + "count": 2948, + "total": 10292, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1122,7 +1122,6 @@ "components/measurement-reminders/vorsorge-dashboard-card.tsx": 2, "components/measurement-reminders/vorsorge-section.tsx": 26, "components/measurement-reminders/vorsorge-settings.tsx": 2, - "components/measurements/__tests__/measurement-form-unit-label.test.tsx": 1, "components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx": 4, "components/measurements/__tests__/measurement-list-bp-badge.test.tsx": 3, "components/measurements/__tests__/measurement-list-empty.test.tsx": 1, diff --git a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx deleted file mode 100644 index af91ed46d..000000000 --- a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * The manual entry form and the blood-glucose display unit. - * - * Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the - * account picked. Until the preference could be set at all, the form could - * hardcode "mg/dL" and be right by accident. It no longer can: a reader on - * mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the - * plausibility band (20–800) accepts without a word and every surface then - * reads back as real. So the label and the stored number have to move - * together — the field asks in the account's unit and the submit path - * inverts to canonical mg/dL. - * - * The label half is rendered. The submit half is asserted against the source: - * this project runs SSR-only component tests (`@testing-library/react` is not - * a dependency), so nothing here can type into the field and press save. What - * the source assertion pins is the wiring — that the glucose branch inverts - * through `toCanonicalMgdl` rather than sending the typed number on — and - * `src/lib/__tests__/glucose.test.ts` proves the conversion itself. - */ - -let glucoseUnit: string | null = null; - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "testuser", - role: "USER", - unitPreference: "metric", - glucoseUnit, - }, - isAuthenticated: true, - isLoading: false, - }), -})); - -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - }), -})); - -vi.mock("@/lib/api/api-fetch", () => ({ - apiPost: vi.fn().mockResolvedValue({}), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { MeasurementForm } from "../measurement-form"; - -function render(): string { - return renderToStaticMarkup( - - - , - ); -} - -const formSource = readFileSync( - join(process.cwd(), "src/components/measurements/measurement-form.tsx"), - "utf8", -); - -describe("MeasurementForm — blood-glucose entry unit", () => { - it("asks in mg/dL for an account that never chose", () => { - glucoseUnit = null; - const html = render(); - expect(html).toContain("mg/dL"); - expect(html).not.toContain("mmol/L"); - expect(html).toContain('placeholder="95"'); - }); - - it("asks in mmol/L once the account chose it", () => { - glucoseUnit = "mmol/L"; - const html = render(); - expect(html).toContain("mmol/L"); - // A mmol/L reader must not be shown a three-digit mg/dL example. - expect(html).toContain('placeholder="5.3"'); - expect(html).not.toContain('placeholder="95"'); - }); - - it("inverts a glucose entry to canonical mg/dL before it is sent", () => { - expect(formSource).toMatch( - /if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/, - ); - }); - - it("resolves the unit from the account rather than assuming one", () => { - expect(formSource).toMatch( - /const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/, - ); - }); -}); diff --git a/src/components/measurements/__tests__/measurement-form-module-types.test.tsx b/src/components/measurements/__tests__/measurement-form-module-types.test.ts similarity index 60% rename from src/components/measurements/__tests__/measurement-form-module-types.test.tsx rename to src/components/measurements/__tests__/measurement-form-module-types.test.ts index 98a2ec7c9..12e4efd15 100644 --- a/src/components/measurements/__tests__/measurement-form-module-types.test.tsx +++ b/src/components/measurements/__tests__/measurement-form-module-types.test.ts @@ -6,45 +6,18 @@ * file a reading into a domain whose pages, charts and reads are all hidden, * or be refused by the route. The form now filters its types through the * surface map's type ownership (`summary:`), which the test below holds - * equal to the measurement-scope ownership the server gates on. + * equal to the measurement-scope ownership the server gates on. That the + * mounted form neither opens on nor offers such a type is driven in + * `measurement-form.browser.test.tsx`. */ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { MeasurementType } from "@/generated/prisma/client"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { surfaceModule } from "@/lib/modules/surface"; -const auth = vi.hoisted(() => ({ - modules: undefined as Record | undefined, -})); -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - role: "USER", - unitPreference: "metric", - glucoseUnit: null, - modules: auth.modules, - }, - isAuthenticated: true, - isLoading: false, - }), -})); -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - }), -})); -vi.mock("@/lib/api/api-fetch", () => ({ - apiPost: vi.fn().mockResolvedValue({}), -})); - -import { I18nProvider } from "@/lib/i18n/context"; import { MEASUREMENT_FORM_TYPE_VALUES, - MeasurementForm, measurementFormTypes, } from "../measurement-form"; @@ -77,17 +50,3 @@ describe("measurementFormTypes", () => { expect(owned).toBeGreaterThan(0); }); }); - -describe(" with a type's module off", () => { - it("does not open on that type, even when asked to", () => { - auth.modules = { glucose: false }; - const html = renderToStaticMarkup( - - - , - ); - auth.modules = undefined; - expect(html).not.toContain("mg/dL"); - expect(html).toContain("mmHg"); - }); -}); diff --git a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx b/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx deleted file mode 100644 index b53d0d50a..000000000 --- a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.32.26 (issue #627) — the manual entry form labels its value field in the - * user's preferred unit for a type with a metric/imperial transform. A metric - * user sees "kg"; an imperial user sees "lb". The field is the entry boundary - * that converts the typed number back to canonical SI on submit, so the label - * and the stored unit must agree with the preference. - */ - -let preference: "metric" | "imperial" = "metric"; - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "testuser", - role: "USER", - unitPreference: preference, - }, - isAuthenticated: true, - isLoading: false, - }), -})); - -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - }), -})); - -vi.mock("@/lib/api/api-fetch", () => ({ - apiPost: vi.fn().mockResolvedValue({}), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { MeasurementForm } from "../measurement-form"; - -function render(): string { - return renderToStaticMarkup( - - - , - ); -} - -describe("MeasurementForm — preferred-unit value label", () => { - it("labels the weight field in kg for a metric user", () => { - preference = "metric"; - const html = render(); - expect(html).toContain("kg"); - expect(html).not.toContain("lb"); - // The metric placeholder (kg) is shown, not the imperial one. - expect(html).toContain('placeholder="75.5"'); - }); - - it("labels the weight field in lb for an imperial user", () => { - preference = "imperial"; - const html = render(); - expect(html).toContain("lb"); - // The imperial placeholder (lb) is shown. - expect(html).toContain('placeholder="165"'); - }); -}); diff --git a/src/components/measurements/__tests__/measurement-form.browser.test.tsx b/src/components/measurements/__tests__/measurement-form.browser.test.tsx new file mode 100644 index 000000000..10a5b9007 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form.browser.test.tsx @@ -0,0 +1,309 @@ +/** + * `` driven by typing and pressing Save, with the request + * body read off the network boundary. What the markup tests could only pin + * against the source is asserted on the wire here: a weight typed in lb, a + * glucose typed in mmol/L and a sleep typed as "7:30" each arrive in their + * canonical unit. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import type { AuthUser } from "@/hooks/use-auth"; + +import { pinDate } from "../../../../tests/browser/clock"; +import { + stubFetch, + type FetchStub, +} from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { MeasurementForm } from "../measurement-form"; + +const NOW = "2026-09-15T10:00:00.000Z"; + +let api: FetchStub; + +beforeEach(() => { + pinDate(NOW); + api = stubFetch().on("POST", "/api/measurements", { status: 201, data: {} }); +}); + +async function mount( + props: Parameters[0] = {}, + user: Partial = {}, +) { + const onSuccess = vi.fn(); + const { screen } = await renderWithApp( + {}} {...props} />, + { user }, + ); + const save = screen.getByRole("button", { name: "Save" }); + return { screen, onSuccess, save }; +} + +const posted = () => api.calls("POST", "/api/measurements").map((r) => r.body); + +describe(" blood pressure", () => { + it("sends systolic, diastolic and pulse as one batch at the chosen instant", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("128"); + await screen.getByLabelText(/^Diastolic/).fill("84"); + await screen.getByLabelText(/^Pulse/).fill("66"); + await screen.getByLabelText(/^Notes/).fill("after the stairs"); + await save.click(); + + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const at = NOW; + expect(posted()).toEqual([ + [ + { + type: "BLOOD_PRESSURE_SYS", + value: 128, + measuredAt: at, + notes: "after the stairs", + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 84, + measuredAt: at, + notes: "after the stairs", + }, + { type: "PULSE", value: 66, measuredAt: at, notes: "after the stairs" }, + ], + ]); + // Only the write: nothing else in the cache needed refetching. + expect(api.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + "POST /api/measurements", + ]); + await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null); + }); + + it("leaves the pulse out when it was not typed", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("118"); + await screen.getByLabelText(/^Diastolic/).fill("76"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const [batch] = posted() as Array>; + expect(batch.map((e) => e.type)).toEqual([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + ]); + }); + + it("sends nothing while a required reading is missing", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Diastolic/).fill("80"); + await save.click(); + const sys = screen + .getByLabelText(/^Systolic/) + .element() as HTMLInputElement; + expect(sys.validity.valueMissing).toBe(true); + expect(api.requests).toEqual([]); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it("holds Save and the menu while the write is pending", async () => { + const pending = api.deferred("POST", "/api/measurements"); + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("120"); + await screen.getByLabelText(/^Diastolic/).fill("80"); + await save.click(); + + await expect.element(save).toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "More options" })) + .toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "Cancel" })) + .toBeDisabled(); + expect(onSuccess).not.toHaveBeenCalled(); + + pending.release({ status: 201, data: {} }); + await expect.element(save).toBeEnabled(); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it("names a refused write in the reader's words and keeps what was typed", async () => { + api.on("POST", "/api/measurements", { + status: 409, + error: "Duplicate measurement", + meta: { errorCode: "measurement.duplicate_timestamp" }, + }); + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("120"); + await screen.getByLabelText(/^Diastolic/).fill("80"); + await save.click(); + + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("A measurement already exists for this time."); + await expect + .element(screen.getByLabelText(/^Systolic/)) + .toHaveAttribute("aria-invalid", "true"); + await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(120); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); + +describe(" units on the way out", () => { + it("asks an imperial reader for lb and stores kg", async () => { + const { screen, save, onSuccess } = await mount( + { defaultType: "WEIGHT" }, + { unitPreference: "imperial" }, + ); + const field = screen.getByLabelText("Value (lb)"); + await expect.element(field).toHaveAttribute("placeholder", "165"); + await field.fill("210"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { type: "WEIGHT", value: 95.25, measuredAt: NOW }, + ]); + }); + + it("reads a decimal comma for a metric reader", async () => { + const { screen, save, onSuccess } = await mount({ defaultType: "WEIGHT" }); + const field = screen.getByLabelText("Value (kg)"); + await expect.element(field).toHaveAttribute("placeholder", "75.5"); + await field.fill("75,5"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { type: "WEIGHT", value: 75.5, measuredAt: NOW }, + ]); + }); + + it("asks in mg/dL for an account that never chose a glucose unit", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_GLUCOSE", + }); + const field = screen.getByLabelText("Value (mg/dL)"); + await expect.element(field).toHaveAttribute("placeholder", "95"); + await field.fill("104"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { + type: "BLOOD_GLUCOSE", + value: 104, + measuredAt: NOW, + glucoseContext: "FASTING", + }, + ]); + }); + + it("asks a mmol/L reader in mmol/L, stores mg/dL and sends the meal context", async () => { + const { screen, save, onSuccess } = await mount( + { defaultType: "BLOOD_GLUCOSE" }, + { glucoseUnit: "mmol/L" }, + ); + const field = screen.getByLabelText("Value (mmol/L)"); + await expect.element(field).toHaveAttribute("placeholder", "5.3"); + await field.fill("5.5"); + await screen.getByRole("combobox", { name: /^Context/ }).click(); + await screen + .getByRole("option", { name: "Post-meal (2h after eating)" }) + .click(); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { + type: "BLOOD_GLUCOSE", + value: 99, + measuredAt: NOW, + glucoseContext: "POSTPRANDIAL", + }, + ]); + }); + + it("reads a sleep typed as hours and minutes and stores minutes", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "SLEEP_DURATION", + }); + await screen.getByLabelText("Value (hours)").fill("7:30"); + await expect.element(screen.getByText("Read as 7 h 30 min")).toBeVisible(); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { type: "SLEEP_DURATION", value: 450, measuredAt: NOW }, + ]); + }); + + it("refuses a duration it cannot read, under the field", async () => { + const { screen, save } = await mount({ defaultType: "SLEEP_DURATION" }); + const field = screen.getByLabelText("Value (hours)"); + await field.fill("most of the night"); + await save.click(); + await expect + .element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m.")) + .toBeVisible(); + await expect.element(field).toHaveAttribute("aria-invalid", "true"); + expect(api.requests).toEqual([]); + await field.fill("7.5"); + await expect + .element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m.")) + .not.toBeInTheDocument(); + }); +}); + +describe(" type", () => { + it("relabels the value field when the type changes", async () => { + const { screen } = await mount({ defaultType: "WEIGHT" }); + await screen.getByRole("combobox", { name: /^Type/ }).click(); + await screen.getByRole("option", { name: "Body temperature" }).click(); + await expect.element(screen.getByLabelText("Value (°C)")).toBeVisible(); + await screen.getByRole("combobox", { name: /^Type/ }).click(); + await screen.getByRole("option", { name: "Blood Pressure" }).click(); + await expect.element(screen.getByLabelText(/^Systolic/)).toBeVisible(); + }); + + it("does not open on, or offer, a type whose module is off", async () => { + const { screen } = await mount( + { defaultType: "BLOOD_GLUCOSE" }, + { modules: { glucose: false } }, + ); + const type = screen.getByRole("combobox", { name: /^Type/ }); + await expect.element(type).toHaveTextContent("Blood Pressure"); + await type.click(); + await expect + .element(screen.getByRole("option", { name: "Weight" })) + .toBeVisible(); + await expect + .element(screen.getByRole("option", { name: "Blood glucose" })) + .not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + }); + + it("opens on the type saved last when no type is asked for", async () => { + const first = await mount({ defaultType: "WEIGHT" }); + await first.screen.getByLabelText("Value (kg)").fill("80"); + await first.save.click(); + await expect.poll(() => first.onSuccess.mock.calls.length).toBe(1); + first.screen.unmount(); + + const { screen } = await mount(); + await expect + .element(screen.getByRole("combobox", { name: /^Type/ })) + .toHaveTextContent("Weight"); + }); + + it("clears every field from the menu's Reset", async () => { + const { screen } = await mount({ defaultType: "BLOOD_PRESSURE" }); + await screen.getByLabelText(/^Systolic/).fill("120"); + await screen.getByLabelText(/^Notes/).fill("left arm"); + await screen.getByRole("button", { name: "More options" }).click(); + await screen.getByRole("menuitem", { name: "Reset form" }).click(); + await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null); + await expect.element(screen.getByLabelText(/^Notes/)).toHaveValue(""); + }); +}); diff --git a/tests/browser/clock.ts b/tests/browser/clock.ts new file mode 100644 index 000000000..b1ffd8b5f --- /dev/null +++ b/tests/browser/clock.ts @@ -0,0 +1,14 @@ +/** + * Pin `Date` for a browser test, and nothing else. + * + * Forms default their timestamp to "now" and refuse a future one, so a test + * that asserts the instant a form sends has to say what now is. Only `Date` + * is faked: timers, animation frames and the microtask queue keep running, + * which is what Radix, TanStack Query and the URL-state queue wait on. + * `setup.ts` restores the real clock after every test. + */ +import { vi } from "vitest"; + +export function pinDate(iso: string): void { + vi.useFakeTimers({ toFake: ["Date"], now: new Date(iso) }); +} diff --git a/tests/browser/fetch-stub.ts b/tests/browser/fetch-stub.ts index de5a8cfbc..5bcbe4fd2 100644 --- a/tests/browser/fetch-stub.ts +++ b/tests/browser/fetch-stub.ts @@ -14,6 +14,11 @@ * state. A request no route matches fails with a 404 envelope and is recorded * too, so an unexpected call is visible rather than hanging. * + * One request is ambient: `I18nProvider` mirrors the active locale to + * `PUT /api/auth/me/locale` on every mount, as it does in the app. The stub + * answers it and leaves it out of `requests`, so "nothing was sent" means + * nothing the component under test sent. + * * `setup.ts` restores the real `fetch` after every test. */ export interface RecordedRequest { @@ -30,6 +35,8 @@ export interface StubResponse { /** The envelope's `data`. Ignored when `error` is set. */ data?: unknown; error?: string; + /** The envelope's `meta` (an error's `errorCode`, a list's totals). */ + meta?: Record; } type Responder = ( @@ -65,6 +72,9 @@ export interface FetchStub { let realFetch: typeof fetch | null = null; +/** Sent by the providers on every mount, not by the component under test. */ +const AMBIENT = [{ method: "PUT", path: "/api/auth/me/locale" }]; + function matches(route: Route, method: string, path: string): boolean { if (route.method !== method) return false; const bare = path.split("?")[0]; @@ -73,8 +83,11 @@ function matches(route: Route, method: string, path: string): boolean { : route.path.test(path); } -function envelope({ status = 200, data = null, error }: StubResponse) { - const body = error ? { data: null, error } : { data, error: null }; +function envelope({ status = 200, data = null, error, meta }: StubResponse) { + const body = { + ...(error ? { data: null, error } : { data, error: null }), + ...(meta ? { meta } : {}), + }; return new Response(JSON.stringify(body), { status: error && status === 200 ? 400 : status, headers: { "content-type": "application/json" }, @@ -110,6 +123,10 @@ export function stubFetch(): FetchStub { window.fetch = async (input, init) => { const request = await record(input, init); + const ambient = AMBIENT.some( + (a) => a.method === request.method && a.path === request.path, + ); + if (ambient) return envelope({ data: request.body ?? null }); requests.push(request); // The newest route wins, so a test can override a default it set up. const route = [...routes] diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts index d1ea7821f..2a6647028 100644 --- a/tests/browser/setup.ts +++ b/tests/browser/setup.ts @@ -8,13 +8,14 @@ * - The EN catalog, the fallback floor of every `t()` chain, loaded through * the production loader. `renderWithApp` loads any other locale it is * asked for. - * - After every test: the real `fetch` and the page's starting address come - * back, so a stub or a URL write never reaches the next test. Rendered + * - After every test: the real `fetch`, the real clock, the page's starting + * address and an empty `localStorage` come back, so a stub, a pinned date, + * a URL write or a remembered preference never reaches the next test. Rendered * trees are unmounted by `vitest-browser-react` itself. */ import "@/app/globals.css"; -import { afterEach, beforeAll, beforeEach } from "vitest"; +import { afterEach, beforeAll, beforeEach, vi } from "vitest"; import { loadMessages } from "@/lib/i18n/load-locale"; @@ -33,5 +34,7 @@ beforeEach(() => { afterEach(() => { restoreFetch(); + vi.useRealTimers(); restoreAddress(); + window.localStorage.clear(); });