From 54d0647a6d2df27a335cec550cda377f82031255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:58:37 +0100 Subject: [PATCH 01/10] =?UTF-8?q?fix(stats):=20keep=20r=C2=B2=20within=20[?= =?UTF-8?q?0,=201]=20when=20y=20sits=20far=20from=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The centred sums lose their last digits when every y is close to one large value, and a two-point fit came out at r² 1.00000004. Clamp the ratio to 1 and the y sum of squares at 0, and pin the two shrunk counterexamples the nightly property run found. --- .../stats/__tests__/stats.property.test.ts | 21 ++++++++++++++++++- src/lib/stats/linear-fit.ts | 9 ++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/lib/stats/__tests__/stats.property.test.ts b/src/lib/stats/__tests__/stats.property.test.ts index 15991c4a1..6471ed73b 100644 --- a/src/lib/stats/__tests__/stats.property.test.ts +++ b/src/lib/stats/__tests__/stats.property.test.ts @@ -244,11 +244,30 @@ describe("least-squares line", () => { return; } expect(fit.r2).toBeGreaterThanOrEqual(0); - expect(fit.r2).toBeLessThanOrEqual(1 + 1e-9); + expect(fit.r2).toBeLessThanOrEqual(1); }), ); }); + // Shrunk counterexamples of the property above from two nightly seeds + // (802398975 and 1070990592): two points on a line far from zero, where + // the centred sums rounded r² above 1. + it.each([ + [ + [0, 1], + [-999.87, -999.97], + ], + [ + [0, -1], + [9996.28, 9993.55], + ], + ])("keeps r² at most 1 for x %j, y %j", (xs, ys) => { + const fit = linearFit(xs, ys)!; + expect(fit.r2).not.toBeNull(); + expect(fit.r2!).toBeLessThanOrEqual(1); + expect(fit.r2!).toBeGreaterThan(0.999); + }); + it("refuses a vertical line instead of inventing a slope", () => { assertProperty( fc.property( diff --git a/src/lib/stats/linear-fit.ts b/src/lib/stats/linear-fit.ts index 2b05f8592..870d280c2 100644 --- a/src/lib/stats/linear-fit.ts +++ b/src/lib/stats/linear-fit.ts @@ -48,7 +48,9 @@ export function fitFromSums(sums: FitSums): LinearFit | null { if (n < 2) return null; const sxx = sumXx - (sumX * sumX) / n; const sxy = sumXy - (sumX * sumY) / n; - const syy = sumYy - (sumY * sumY) / n; + // A sum of squares is never negative; the subtraction can still land a + // hair below zero when every y sits close to one large value. + const syy = Math.max(0, sumYy - (sumY * sumY) / n); if (sxx === 0) return null; const slope = sxy / sxx; const meanX = sumX / n; @@ -56,7 +58,10 @@ export function fitFromSums(sums: FitSums): LinearFit | null { return { slope, intercept: meanY - slope * meanX, - r2: syy === 0 ? null : (sxy * sxy) / (sxx * syy), + // r² is at most 1 by Cauchy–Schwarz. With y far from zero and little + // spread (two weighings of about 1000, a tenth apart) the centred sums + // lose their last digits and the ratio came out at 1.00000004. + r2: syy === 0 ? null : Math.min(1, (sxy * sxy) / (sxx * syy)), n, meanX, meanY, From 4f7802ba287b808d4f658aa231588061c18ef5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:58:38 +0100 Subject: [PATCH 02/10] test(browser): leave no state behind for the next case Await the form's explicit unmount: an unmount left running overlaps the next render's act, and React then commits nothing for the rest of the file. Return to the newest history entry after stepping back, so the next case's push adds an entry instead of replacing the forward one. --- .../measurements/__tests__/measurement-form.browser.test.tsx | 5 ++++- src/hooks/__tests__/use-url-filter-sync.browser.test.tsx | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/measurements/__tests__/measurement-form.browser.test.tsx b/src/components/measurements/__tests__/measurement-form.browser.test.tsx index 10a5b9007..cd837a113 100644 --- a/src/components/measurements/__tests__/measurement-form.browser.test.tsx +++ b/src/components/measurements/__tests__/measurement-form.browser.test.tsx @@ -289,7 +289,10 @@ describe(" type", () => { 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(); + // Awaited: the unmount runs inside `act`, and one left running overlaps + // the next render's `act`, after which React commits nothing for the + // rest of the file (every later case finds an empty container). + await first.screen.unmount(); const { screen } = await mount(); await expect diff --git a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx index 6897b0b2e..deadc0e47 100644 --- a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx +++ b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx @@ -242,6 +242,11 @@ describe("useUrlFilterSync with FilterBar", () => { window.history.back(); await expectSearch("?type=WEIGHT"); await expect.element(pill("Type: Weight")).toBeInTheDocument(); + // Back to the newest entry. Left one step behind, the page keeps a + // forward entry, and the next case's push replaces it instead of adding + // one: `history.length` stays put and its count of entries is off by one. + window.history.forward(); + await expectSearch("?type=PULSE"); }); it("keeps the state in memory and the address untouched with syncToUrl off", async () => { From 227c80878c1b5eeb19d1ac6e36543912deca2d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:58:38 +0100 Subject: [PATCH 03/10] test: give every case its own mock state under a shuffled order Cases leaned on what an earlier case set up or left behind: a once-value the code under test never consumed, an implementation swapped in and kept by clearAllMocks, a payload only another block's setup provided, the zone resolver's per-user cache, module state in a hoisted object, and a scope set one case discovered for the next. Each now resets or states what it reads. --- .../provider-health/__tests__/route.test.ts | 10 +++- .../__tests__/route-concurrency.test.ts | 5 +- .../__tests__/soft-delete-filter.test.ts | 10 ++++ .../[id]/attachments/__tests__/route.test.ts | 9 ++++ .../chat/__tests__/route-tool-mode.test.ts | 5 +- .../__tests__/dashboard-header.test.tsx | 14 +++--- .../use-illness-episodes-paging.test.ts | 10 ++-- .../__tests__/usage-level-simulation.test.ts | 47 +++++++++++-------- .../__tests__/derived-assessment-ai.test.ts | 4 ++ .../__tests__/backup-handlers.test.ts | 7 +++ .../__tests__/sync-plausibility.test.ts | 6 ++- 11 files changed, 90 insertions(+), 37 deletions(-) diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 75fb1b373..bcc30b8ef 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -60,6 +60,14 @@ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { requireAdmin } from "@/lib/api-handler"; +// Reset, not clear, before every case: cases replace the paused rows, the +// settings row and the account groups, and a cleared mock would hand the +// next case whatever the last one left there. A reset mock goes back to the +// empty answer it was created with. +beforeEach(() => { + vi.resetAllMocks(); +}); + const groupBy = vi.mocked(prisma.providerHealth.groupBy); const usageRows = vi.mocked(prisma.coachUsage.findMany); @@ -101,7 +109,6 @@ function group( describe("GET /api/admin/provider-health", () => { beforeEach(() => { - vi.clearAllMocks(); vi.mocked(requireAdmin).mockResolvedValue({ user: { id: "admin1" }, } as never); @@ -302,7 +309,6 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", () const settings = vi.mocked(prisma.appSettings.findUnique); beforeEach(() => { - vi.clearAllMocks(); vi.mocked(requireAdmin).mockResolvedValue({ user: { id: "admin" }, } as never); diff --git a/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts b/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts index c84a45162..249de579b 100644 --- a/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts @@ -131,7 +131,10 @@ function putReq( } beforeEach(() => { - vi.clearAllMocks(); + // Reset, not clear: cases replace the profile reads and the question store + // with their own implementations, and a cleared mock keeps them. A reset + // mock returns to the implementation it was created with. + vi.resetAllMocks(); requireModuleEnabled.mockResolvedValue({ enabled: true }); }); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index ef58cd010..50363cc58 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -174,6 +174,16 @@ beforeEach(() => { vi.mocked(isModuleEnabled).mockResolvedValue(true); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // A session that signed in just now. Reset, not only cleared: a case below + // swaps in a stale session, and `clearAllMocks` keeps the implementation + // it left behind for whichever case runs next. + vi.mocked(prisma.session.findUnique) + .mockReset() + .mockImplementation((async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })) as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, limit: 60, diff --git a/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts index a715ba6c3..be0ab6d2e 100644 --- a/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts +++ b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts @@ -87,6 +87,15 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(validateAttachmentCandidate).mockResolvedValue({ ok: true }); + // Open again for every case: the detach case closes every capability, and + // `clearAllMocks` would carry that refusal into whichever case runs next. + vi.mocked(requireAiCapability) + .mockReset() + .mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + } as never); }); describe("POST attach", () => { diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 947964daa..8a7019860 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -276,7 +276,10 @@ function chatReq(body: Record): Request { describe("coach chat — tool-mode routing (F1)", () => { beforeEach(() => { - vi.clearAllMocks(); + // Reset, not clear: cases swap in their own tool loop and parsers, and a + // cleared mock would hand those to the next case. Every mock here goes + // back to the implementation it was created with. + vi.resetAllMocks(); reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000, diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 680ec64e8..6948538e8 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -52,6 +52,12 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales"; primeLocales("de"); +// Every case starts on an account that never set a module, whichever case +// ran before it and left its own modules behind. +beforeEach(() => { + modulesRef.value = undefined; +}); + function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") { return renderToStaticMarkup( {node}, @@ -162,10 +168,6 @@ describe(" — quick-add menu", () => { }); describe(" — Log workout", () => { - beforeEach(() => { - modulesRef.value = undefined; - }); - it("offers it beside the other capture entries", () => { modulesRef.value = { workouts: true }; const html = renderSSR( @@ -187,10 +189,6 @@ describe(" — Log workout", () => { }); describe(" — every entry follows its module, as the capture picker does", () => { - beforeEach(() => { - modulesRef.value = undefined; - }); - it("withholds Log mood when the mood module is off", () => { modulesRef.value = { mood: false }; const html = renderSSR( diff --git a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts index e447bc115..79814a155 100644 --- a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts +++ b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts @@ -5,7 +5,7 @@ * off the illness page, its settings and every picker once a record held more * than that, and they could then be found only through search. */ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const calls = vi.hoisted(() => [] as string[]); @@ -41,6 +41,12 @@ vi.mock("@/lib/api/api-fetch", () => ({ import { useIllnessEpisodes } from "../use-illness"; describe("useIllnessEpisodes", () => { + // Each case states its own journal and counts only its own requests. + beforeEach(() => { + calls.length = 0; + journal.total = 230; + }); + it("reads page after page until the whole journal is in", async () => { const options = useIllnessEpisodes(true) as unknown as { queryFn: () => Promise<{ id: string }[]>; @@ -55,7 +61,6 @@ describe("useIllnessEpisodes", () => { }); it("stops on a full last page the route says is the end", async () => { - calls.length = 0; journal.total = 200; const options = useIllnessEpisodes(true) as unknown as { queryFn: () => Promise<{ id: string }[]>; @@ -63,6 +68,5 @@ describe("useIllnessEpisodes", () => { expect(await options.queryFn()).toHaveLength(200); // Two full pages of 100, no third request for an empty one. expect(calls).toHaveLength(2); - journal.total = 230; }); }); diff --git a/src/lib/insights/__tests__/usage-level-simulation.test.ts b/src/lib/insights/__tests__/usage-level-simulation.test.ts index 1f51ab309..b09e8e657 100644 --- a/src/lib/insights/__tests__/usage-level-simulation.test.ts +++ b/src/lib/insights/__tests__/usage-level-simulation.test.ts @@ -129,9 +129,27 @@ function day(): Event[] { return events.sort((a, b) => a.at.localeCompare(b.at)); } -const ALL_SCOPES_AT_WARM = new Set(); +/** + * Every scope the day dirties: run it once on `full` with no warm, which + * enqueues each scope at least once. Each case derives it itself rather than + * reading what an earlier case left behind. + */ +async function discoverWarmScopes(): Promise> { + sim.notes.clear(); + sim.level = "FULL"; + for (const event of day()) { + vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`)); + await invalidateStatusInsightsForTypes("u1", event.types, { + source: event.source, + }); + } + return new Set(sim.notes.keys()); +} -async function simulate(level: string | null): Promise<{ +async function simulate( + level: string | null, + warmScopes: ReadonlySet, +): Promise<{ warm: number; ingest: number; bySource: Record<"sync" | "manual", number>; @@ -141,8 +159,8 @@ async function simulate(level: string | null): Promise<{ sim.level = level; // The 04:30 warm regenerates every scope the day will touch. const warmAt = Date.parse("2026-10-15T04:30:00Z"); - for (const scope of ALL_SCOPES_AT_WARM) sim.notes.set(scope, warmAt); - const warm = ALL_SCOPES_AT_WARM.size; + for (const scope of warmScopes) sim.notes.set(scope, warmAt); + const warm = warmScopes.size; const bySource = { sync: 0, manual: 0 }; for (const event of day()) { vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`)); @@ -161,25 +179,14 @@ beforeEach(() => { describe("generations per day on a watch-synced account", () => { it("counts the scopes once to seed the warm", async () => { - // Discover every scope the day dirties: run it once on `full` with no - // warm, which enqueues each scope at least once. - ALL_SCOPES_AT_WARM.clear(); - sim.notes.clear(); - sim.level = "FULL"; - for (const event of day()) { - vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`)); - await invalidateStatusInsightsForTypes("u1", event.types, { - source: event.source, - }); - } - for (const scope of sim.notes.keys()) ALL_SCOPES_AT_WARM.add(scope); - expect(ALL_SCOPES_AT_WARM.size).toBe(19); + expect((await discoverWarmScopes()).size).toBe(19); }); it("before v1.43 (every ingest at one hour) versus each level", async () => { - const full = await simulate("FULL"); // the behaviour before v1.43 - const standard = await simulate("STANDARD"); - const frugal = await simulate("FRUGAL"); + const warmScopes = await discoverWarmScopes(); + const full = await simulate("FULL", warmScopes); // the behaviour before v1.43 + const standard = await simulate("STANDARD", warmScopes); + const frugal = await simulate("FRUGAL", warmScopes); const result = { full, standard, frugal }; process.stdout.write(`SIMULATION ${JSON.stringify(result)}\n`); diff --git a/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts b/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts index 553b3c8bc..b1a6e3b3a 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts @@ -34,6 +34,7 @@ import { resolveReadOnlyStatusMiss, } from "@/lib/insights/status-cache"; import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; import { resolveDerivedAssessment, generateDerivedScoreAssessment, @@ -71,6 +72,9 @@ function okDerived(): Derived { beforeEach(() => { vi.resetAllMocks(); + // The zone resolver keeps a per-user cache for a minute; without this the + // first case to run fills it and every later one skips the read. + invalidateUserTimezone("u-ny"); findUnique.mockResolvedValue({ timezone: "America/New_York" }); }); diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts index 5f386a2d7..637a97952 100644 --- a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -248,6 +248,13 @@ describe("handleDataBackup on a large instance", () => { beforeEach(() => { vi.clearAllMocks(); mocks.store.mockResolvedValue(undefined); + // Stated here rather than inherited: without a payload the pass stores + // nothing, and these cases used to pass only when another block's setup + // had run first and left its payload behind. + mocks.buildFullBackupPayload.mockResolvedValue({ + payload: { schemaVersion: "1", userId: "user-dr" }, + counts: {}, + }); }); it("backs up the accounts with the oldest copies first and stops on the budget", async () => { diff --git a/src/lib/withings/__tests__/sync-plausibility.test.ts b/src/lib/withings/__tests__/sync-plausibility.test.ts index 60901ebb6..cc8bb6105 100644 --- a/src/lib/withings/__tests__/sync-plausibility.test.ts +++ b/src/lib/withings/__tests__/sync-plausibility.test.ts @@ -211,8 +211,10 @@ describe("syncUserMeasurements — plausibility gate", () => { it("does not overwrite a stored reading with an out-of-band one", async () => { // The slot already holds a real pulse; the provider re-sends the same // instant carrying a decode slip. Ungated, the update branch rewrote the - // good value with the bad one. - vi.mocked(prisma.measurement.findMany).mockResolvedValueOnce([ + // good value with the bad one. Not `mockResolvedValueOnce`: the gate + // drops the reading before the stored rows are read, so a once-value + // would sit unconsumed and answer the next test's read instead. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { id: "existing-row", type: "PULSE", From 713806fe9bd8114e7cdf2de3f870d65a74e945cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:35:56 +0100 Subject: [PATCH 04/10] test(parity): keep each route walk in order under a shuffled run A parity file is one account walked through a route's cases, each request reading what the earlier ones stored, and the recorded files pin that sequence. The nightly shuffle reordered the steps and every snapshot after the first moved. Keep each walk in order with shuffle: false, wrapping the files whose top-level blocks build on each other, and note the rule for order-bound files in the testing guide. --- docs/contributing/testing.md | 11 + .../routes/parity/analytics.parity.test.ts | 2 +- .../parity/dashboard-summary.parity.test.ts | 2 +- .../parity/dashboard-widgets.parity.test.ts | 2 +- .../parity/documents-inbound.parity.test.ts | 236 +++--- .../routes/parity/import-csv.parity.test.ts | 2 +- .../routes/parity/import-json.parity.test.ts | 2 +- .../insights-comprehensive.parity.test.ts | 2 +- .../parity/insights-generate.parity.test.ts | 732 +++++++++--------- .../parity/measurement-series.parity.test.ts | 2 +- .../parity/measurements-batch.parity.test.ts | 2 +- .../routes/parity/measurements.parity.test.ts | 349 +++++---- .../parity/medication-detail.parity.test.ts | 404 +++++----- .../medication-intake-bulk.parity.test.ts | 2 +- .../medication-intake-event.parity.test.ts | 232 +++--- .../medication-intake-status.parity.test.ts | 237 +++--- .../parity/medication-intake.parity.test.ts | 342 ++++---- .../routes/parity/medications.parity.test.ts | 357 ++++----- .../parity/mood-entries-bulk.parity.test.ts | 2 +- .../parity/oidc-callback.parity.test.ts | 2 +- .../routes/parity/sync-changes.parity.test.ts | 2 +- .../parity/tokens-mirror.parity.test.ts | 122 +-- .../parity/workout-detail.parity.test.ts | 108 +-- .../parity/workouts-batch.parity.test.ts | 2 +- 24 files changed, 1613 insertions(+), 1543 deletions(-) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 18e2a30b2..c7694cad1 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -284,3 +284,14 @@ earns its place; do not extend the date. Order dependence is looked for on purpose: the nightly workflow runs both suites with `--sequence.shuffle` and prints the seed. Replay a failure with `--sequence.shuffle --sequence.seed=`. + +A case resets or states what it reads: `vi.resetAllMocks()` (or +`mockReset()`) where a case swaps in its own implementation, since +`clearAllMocks` keeps it and a `mockResolvedValueOnce` the code never consumed +answers the next case; a module cache dropped through its own invalidator; a +row the case needs written by the case or its own `beforeAll`. A file that is +one walk by design (a parity sequence, a migration's before and after, a +dry run followed by the import and the same file again) says so in a comment +and passes `{ shuffle: false }` to its `describe`, which keeps that block in +order while the rest of the suite still shuffles. Wrap the whole file in one +such block when its top-level blocks build on each other. diff --git a/tests/integration/routes/parity/analytics.parity.test.ts b/tests/integration/routes/parity/analytics.parity.test.ts index d68a489d9..c251e592b 100644 --- a/tests/integration/routes/parity/analytics.parity.test.ts +++ b/tests/integration/routes/parity/analytics.parity.test.ts @@ -210,7 +210,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/analytics parity", () => { +describe("GET /api/analytics parity", { shuffle: false }, () => { it("01 a slice outside the closed set", async () => { await step("01-invalid-slice", { query: { slice: "summary" } }); }); diff --git a/tests/integration/routes/parity/dashboard-summary.parity.test.ts b/tests/integration/routes/parity/dashboard-summary.parity.test.ts index 7467d68fe..4840f50a8 100644 --- a/tests/integration/routes/parity/dashboard-summary.parity.test.ts +++ b/tests/integration/routes/parity/dashboard-summary.parity.test.ts @@ -159,7 +159,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/dashboard/summary parity", () => { +describe("GET /api/dashboard/summary parity", { shuffle: false }, () => { it("01 cold, then warm", async () => { await step("01-cold"); await step("02-warm"); diff --git a/tests/integration/routes/parity/dashboard-widgets.parity.test.ts b/tests/integration/routes/parity/dashboard-widgets.parity.test.ts index 0faca0745..fa2bdfa03 100644 --- a/tests/integration/routes/parity/dashboard-widgets.parity.test.ts +++ b/tests/integration/routes/parity/dashboard-widgets.parity.test.ts @@ -169,7 +169,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("/api/dashboard/widgets parity", () => { +describe("/api/dashboard/widgets parity", { shuffle: false }, () => { let lastToken = ""; it("01 the first read, filled into the cache", async () => { diff --git a/tests/integration/routes/parity/documents-inbound.parity.test.ts b/tests/integration/routes/parity/documents-inbound.parity.test.ts index 81ef63c24..173012620 100644 --- a/tests/integration/routes/parity/documents-inbound.parity.test.ts +++ b/tests/integration/routes/parity/documents-inbound.parity.test.ts @@ -215,129 +215,133 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/documents/inbound parity", () => { - it("a stored upload and the same bytes again", async () => { - const body = () => - form(PDF("discharge"), "discharge.pdf", { - title: "Discharge letter", - kind: "DISCHARGE_LETTER", - documentDate: "2026-10-02", - episodeIds: [episodeId], +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("documents-inbound parity walk", { shuffle: false }, () => { + describe("POST /api/documents/inbound parity", () => { + it("a stored upload and the same bytes again", async () => { + const body = () => + form(PDF("discharge"), "discharge.pdf", { + title: "Discharge letter", + kind: "DISCHARGE_LETTER", + documentDate: "2026-10-02", + episodeIds: [episodeId], + }); + await upload("post-01-stored", body()); + await upload("post-02-duplicate", body()); + await upload( + "post-03-second", + form(PDF("labs"), "labs.pdf", { + title: "Blood test", + kind: "LAB_RESULT", + documentDate: "2025-03-04", + }), + ); + }); + + it("the refusals", async () => { + await upload("post-04-no-file", form(null, "x", { title: "Nothing" })); + await upload( + "post-05-invalid-metadata", + form(PDF("bad-kind"), "bad.pdf", { kind: "SHOPPING_LIST" }), + ); + await upload("post-06-empty", form(Buffer.alloc(0), "empty.pdf")); + await upload( + "post-07-unsupported", + form( + Buffer.from("hi\n"), + "page.html", + ), + ); + await upload( + "post-08-unknown-episode", + form(PDF("orphan"), "orphan.pdf", { + episodeIds: ["cmissingepisode000000000"], + }), + ); + await upload("post-09-half-key", form(PDF("half"), "half.pdf"), { + query: { sourceSystem: "PAPERLESS" }, }); - await upload("post-01-stored", body()); - await upload("post-02-duplicate", body()); - await upload( - "post-03-second", - form(PDF("labs"), "labs.pdf", { - title: "Blood test", - kind: "LAB_RESULT", - documentDate: "2025-03-04", - }), - ); + await upload( + "post-10-keys-differ", + form(PDF("differ"), "differ.pdf", { + sourceSystem: "PAPERLESS", + sourceId: "77", + }), + { query: { sourceSystem: "PAPERLESS", sourceId: "78" } }, + ); + }); + + it("a narrow token storing under a source key", async () => { + const stored = await upload( + "post-11-token-stored", + form(PDF("imported"), "imported.pdf", { + sourceSystem: "PAPERLESS", + sourceId: "41", + aiRead: "defer", + }), + { auth: writer }, + ); + await upload("post-12-token-query-key", form(PDF("imported"), "x.pdf"), { + auth: writer, + query: { sourceSystem: "PAPERLESS", sourceId: "41" }, + }); + await prisma.inboundDocument.update({ + where: { id: stored.id! }, + data: { deletedAt: new Date("2026-10-14T09:00:00.000Z") }, + }); + await upload( + "post-13-token-deleted", + form(PDF("imported"), "imported.pdf", { + sourceSystem: "PAPERLESS", + sourceId: "41", + }), + { auth: writer }, + ); + }); + + it("the module switched off", async () => { + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { inboundDocuments: false } }, + }); + await upload("post-14-module-off", form(PDF("dark"), "dark.pdf")); + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { inboundDocuments: true } }, + }); + }); }); - it("the refusals", async () => { - await upload("post-04-no-file", form(null, "x", { title: "Nothing" })); - await upload( - "post-05-invalid-metadata", - form(PDF("bad-kind"), "bad.pdf", { kind: "SHOPPING_LIST" }), - ); - await upload("post-06-empty", form(Buffer.alloc(0), "empty.pdf")); - await upload( - "post-07-unsupported", - form( - Buffer.from("hi\n"), - "page.html", - ), - ); - await upload( - "post-08-unknown-episode", - form(PDF("orphan"), "orphan.pdf", { - episodeIds: ["cmissingepisode000000000"], - }), - ); - await upload("post-09-half-key", form(PDF("half"), "half.pdf"), { - query: { sourceSystem: "PAPERLESS" }, + describe("GET /api/documents/inbound parity", () => { + it("the default page, a search and the filters", async () => { + await list("get-01-default", {}); + await list("get-02-search", { q: "blood" }); + await list("get-03-kind", { kind: "DISCHARGE_LETTER,LAB_RESULT" }); + await list("get-04-episode", { episodeId }); + await list("get-05-year", { year: 2025 }); + await list("get-06-range", { from: "2026-10-01", to: "2026-10-02" }); }); - await upload( - "post-10-keys-differ", - form(PDF("differ"), "differ.pdf", { - sourceSystem: "PAPERLESS", - sourceId: "77", - }), - { query: { sourceSystem: "PAPERLESS", sourceId: "78" } }, - ); - }); - it("a narrow token storing under a source key", async () => { - const stored = await upload( - "post-11-token-stored", - form(PDF("imported"), "imported.pdf", { - sourceSystem: "PAPERLESS", - sourceId: "41", - aiRead: "defer", - }), - { auth: writer }, - ); - await upload("post-12-token-query-key", form(PDF("imported"), "x.pdf"), { - auth: writer, - query: { sourceSystem: "PAPERLESS", sourceId: "41" }, + it("a sorted page and the page after it", async () => { + const first = await list("get-07-sorted", { + sort: "title", + order: "asc", + limit: 1, + }); + await list("get-08-next-page", { + sort: "title", + order: "asc", + limit: 1, + cursor: first.nextCursor!, + }); }); - await prisma.inboundDocument.update({ - where: { id: stored.id! }, - data: { deletedAt: new Date("2026-10-14T09:00:00.000Z") }, - }); - await upload( - "post-13-token-deleted", - form(PDF("imported"), "imported.pdf", { - sourceSystem: "PAPERLESS", - sourceId: "41", - }), - { auth: writer }, - ); - }); - it("the module switched off", async () => { - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: { inboundDocuments: false } }, - }); - await upload("post-14-module-off", form(PDF("dark"), "dark.pdf")); - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: { inboundDocuments: true } }, + it("a refused query, and a delegate reading the record", async () => { + await list("get-09-invalid", { sort: "size" }); + await switchSessionTo(delegate.sessionId, userId); + await list("get-10-delegated", {}, delegate); + await switchSessionTo(delegate.sessionId, null); }); }); }); - -describe("GET /api/documents/inbound parity", () => { - it("the default page, a search and the filters", async () => { - await list("get-01-default", {}); - await list("get-02-search", { q: "blood" }); - await list("get-03-kind", { kind: "DISCHARGE_LETTER,LAB_RESULT" }); - await list("get-04-episode", { episodeId }); - await list("get-05-year", { year: 2025 }); - await list("get-06-range", { from: "2026-10-01", to: "2026-10-02" }); - }); - - it("a sorted page and the page after it", async () => { - const first = await list("get-07-sorted", { - sort: "title", - order: "asc", - limit: 1, - }); - await list("get-08-next-page", { - sort: "title", - order: "asc", - limit: 1, - cursor: first.nextCursor!, - }); - }); - - it("a refused query, and a delegate reading the record", async () => { - await list("get-09-invalid", { sort: "size" }); - await switchSessionTo(delegate.sessionId, userId); - await list("get-10-delegated", {}, delegate); - await switchSessionTo(delegate.sessionId, null); - }); -}); diff --git a/tests/integration/routes/parity/import-csv.parity.test.ts b/tests/integration/routes/parity/import-csv.parity.test.ts index 60ee5ba50..2a84aac5b 100644 --- a/tests/integration/routes/parity/import-csv.parity.test.ts +++ b/tests/integration/routes/parity/import-csv.parity.test.ts @@ -199,7 +199,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/import/csv parity", () => { +describe("POST /api/import/csv parity", { shuffle: false }, () => { it("01 a dry run", async () => { await step("01-dry-run", FILE, { dryRun: "1" }); }); diff --git a/tests/integration/routes/parity/import-json.parity.test.ts b/tests/integration/routes/parity/import-json.parity.test.ts index 265de2378..a63e05b6f 100644 --- a/tests/integration/routes/parity/import-json.parity.test.ts +++ b/tests/integration/routes/parity/import-json.parity.test.ts @@ -263,7 +263,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/import parity", () => { +describe("POST /api/import parity", { shuffle: false }, () => { it("01 a first file", async () => { await step("01-first-file", FILE); }); diff --git a/tests/integration/routes/parity/insights-comprehensive.parity.test.ts b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts index 72c1ae5b9..78adab744 100644 --- a/tests/integration/routes/parity/insights-comprehensive.parity.test.ts +++ b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts @@ -408,7 +408,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/insights/comprehensive parity", () => { +describe("GET /api/insights/comprehensive parity", { shuffle: false }, () => { it("01 an anonymous call", async () => { await step("01-anonymous", { auth: anonymous }); }); diff --git a/tests/integration/routes/parity/insights-generate.parity.test.ts b/tests/integration/routes/parity/insights-generate.parity.test.ts index 0cdb9d831..b451b8b07 100644 --- a/tests/integration/routes/parity/insights-generate.parity.test.ts +++ b/tests/integration/routes/parity/insights-generate.parity.test.ts @@ -635,380 +635,394 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/insights/generate parity", () => { - it("G01 an anonymous call", async () => { - await get("G01-anonymous", { auth: anonymous }); - }); - - it("G02 the AI analysis opt-out", async () => { - await get("G02-opted-out", { auth: optedOut, userId: OPTED_OUT_ID }); - }); - - it("G03 no cached briefing", async () => { - await resetBriefing(OWNER_ID); - await get("G03-empty"); - }); - - it("G04 a briefing written today", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "en", +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("insights-generate parity walk", { shuffle: false }, () => { + describe("GET /api/insights/generate parity", () => { + it("G01 an anonymous call", async () => { + await get("G01-anonymous", { auth: anonymous }); }); - await get("G04-fresh"); - }); - it("G05 a briefing written yesterday", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), - cachedAt: new Date(NOW.getTime() - 2 * HOUR), - cachedLocale: "en", + it("G02 the AI analysis opt-out", async () => { + await get("G02-opted-out", { auth: optedOut, userId: OPTED_OUT_ID }); }); - await get("G05-stale"); - }); - it("G06 a briefing in another language", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "de", - }); - await get("G06-other-language"); - }); - - it("G07 an unparseable cache row", async () => { - await resetBriefing(OWNER_ID, { - text: "{not json", - cachedAt: new Date(NOW.getTime() - HOUR), - }); - await get("G07-unparseable"); - }); - - it("G08 a legacy payload, untagged", async () => { - await resetBriefing(OWNER_ID, { - text: { summary: "Legacy", recommendations: ["Drink water."] }, - cachedAt: new Date(NOW.getTime() - HOUR), - }); - await get("G08-legacy"); - }); - - it("G09 a failure newer than the last good briefing", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 3 * HOUR)), - cachedAt: new Date(NOW.getTime() - 3 * HOUR), - cachedLocale: "en", - markers: [ - { - reason: "all-providers-failed", - at: new Date(NOW.getTime() - HOUR), - httpStatus: 429, - }, - ], - }); - await get("G09-held-after-failure"); - }); - - it("G10 a failure with no good briefing, and an older superseded one", async () => { - await resetBriefing(OWNER_ID, { - markers: [ - { reason: "provider-error", at: new Date(NOW.getTime() - 5 * HOUR) }, - { reason: "invalid-json", at: new Date(NOW.getTime() - HOUR) }, - ], - }); - await get("G10-failed-empty"); - }); - - it("G11 a briefing the grounding gate withheld", async () => { - await resetBriefing(OWNER_ID, { - text: cached(null, new Date(NOW.getTime() - 2 * HOUR)), - cachedAt: new Date(NOW.getTime() - 2 * HOUR), - cachedLocale: "en", - markers: [ - { reason: "briefing-ungrounded", at: new Date(NOW.getTime() - HOUR) }, - ], - }); - await get("G11-withheld"); - }); -}); - -describe("POST /api/insights/generate parity", () => { - it("P01 an anonymous call", async () => { - await post("P01-anonymous", { force: true }, { auth: anonymous }); - }); - - it("P02 the AI analysis opt-out", async () => { - await post( - "P02-opted-out", - { force: true }, - { auth: optedOut, userId: OPTED_OUT_ID }, - ); - }); - - it("P03 a body over the cap", async () => { - await resetBriefing(OWNER_ID); - await post("P03-body-too-large", { pad: "x".repeat(17 * 1024) }); - }); - - it("P04 a cached briefing written today", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "en", - }); - await post("P04-cache-hit", {}); - }); - - it("P05 a cached briefing from yesterday, regenerated", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), - cachedAt: new Date(NOW.getTime() - 2 * HOUR), - cachedLocale: "en", - }); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P05-yesterday-regenerated", {}); - }); - - it("P06 a cache in another language, regenerated", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "de", - }); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P06-other-language-regenerated", {}); - }); - - it("P07 a briefingless cache when the hour's quota is spent", async () => { - await resetBriefing(OWNER_ID, { - text: cached(null, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "en", - quotaUsed: 10, - }); - await post("P07-quota-cached-fallback", {}); - }); - - it("P08 a forced generation when the hour's quota is spent", async () => { - await resetBriefing(OWNER_ID, { quotaUsed: 10 }); - await post("P08-quota-refused"); - }); - - it("P09 a key no resolver can open", async () => { - await resetBriefing(UNREADABLE_KEY_ID); - await post( - "P09-unreadable-key", - { force: true }, - { auth: unreadableKey, userId: UNREADABLE_KEY_ID }, - ); - }); - - it("P10 a fresh generation", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ content: reply(GROUNDED_BRIEFING), tokensUsed: 2100 }); - await post("P10-generated"); - }); - - it("P11 a reply the strict schema accepts", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING, { disclaimer: "Consult your doctor." }), - }); - await post("P11-strict-reply"); - }); - - it("P12 a generation in German with the comparison, about-me and short form", async () => { - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { - locale: "de", - aiUsageLevel: "FRUGAL", - dashboardWidgetsJson: { comparisonBaseline: "lastMonth" }, - }, - }); - await prisma.allergy.create({ - data: { userId: OWNER_ID, substance: "Penicillin" }, - }); - await resetBriefing(OWNER_ID); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P12-generated-de-short"); - await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { locale: "en", aiUsageLevel: null, dashboardWidgetsJson: {} }, - }); - }); - - it("P13 an oversized raw payload, downgraded once", async () => { - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { insightsPrivacyMode: "raw" }, - }); - await resetBriefing(OWNER_ID); - seams.oversize.push(6_100_000); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P13-downgraded"); - }); - - it("P14 an oversized aggregate too, downgraded twice", async () => { - await resetBriefing(OWNER_ID); - seams.oversize.push(6_100_000, 5_400_000); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P14-hard-downgraded"); - }); - - it("P15 oversized on every rung", async () => { - await resetBriefing(OWNER_ID); - seams.oversize.push(6_100_000, 5_400_000, 5_200_000); - await post("P15-too-large"); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { insightsPrivacyMode: "aggregated" }, - }); - }); - - it("P16 the day's token ceiling", async () => { - await resetBriefing(OWNER_ID, { spentToday: 5_000_000 }); - await post("P16-budget-exceeded"); - }); - - it("P17 every provider refused the credential", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(401), hop(403, 2)]), - }); - await post("P17-chain-auth"); - }); - - it("P18 every provider rate-limited", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ error: () => new AllProvidersFailedError([hop(429)]) }); - await post("P18-chain-rate-limited"); - }); - - it("P19 a provider in a 5xx", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(401), hop(502, 2)]), - }); - await post("P19-chain-5xx"); - }); - - it("P20 every provider unreachable", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(null), hop(null, 2)]), - }); - await post("P20-chain-transport"); - }); - - it("P21 a mixed chain failure", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(404), hop(429, 2)]), - }); - await post("P21-chain-mixed"); - }); - - it("P22 one provider error per status class", async () => { - for (const status of [401, 429, 500, 400]) { + it("G03 no cached briefing", async () => { await resetBriefing(OWNER_ID); - seams.script.push({ error: providerError(status) }); - await post(`P22-provider-${status}`); - } - }); - - it("P23 a reply that is not JSON", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ content: "Sure! Here is your briefing." }); - await post("P23-invalid-json"); - }); - - it("P24 a reply cut off at the token ceiling", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ content: '{"summary": "Your', finishReason: "length" }); - await post("P24-truncated"); - }); - - it("P25 an ungrounded briefing the retry fixes", async () => { - await resetBriefing(OWNER_ID); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: reply(GROUNDED_BRIEFING), tokensUsed: 900 }, - ); - await post("P25-grounding-retry-kept"); - }); - - it("P26 still ungrounded, the previous briefing stands in", async () => { - await resetBriefing(OWNER_ID, { - text: cached(PREVIOUS_BRIEFING, new Date(NOW.getTime() - 26 * HOUR)), - cachedAt: new Date(NOW.getTime() - 26 * HOUR), - cachedLocale: "en", + await get("G03-empty"); }); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: reply(UNGROUNDED_BRIEFING) }, - ); - await post("P26-grounding-previous"); - }); - it("P27 still ungrounded, nothing to stand in", async () => { - await resetBriefing(OWNER_ID); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: reply(UNGROUNDED_BRIEFING) }, - ); - await post("P27-grounding-stripped"); - }); + it("G04 a briefing written today", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + }); + await get("G04-fresh"); + }); - it("P28 an ungrounded briefing whose retry is not JSON", async () => { - await resetBriefing(OWNER_ID); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: "not json" }, - ); - await post("P28-grounding-retry-unparseable"); - }); + it("G05 a briefing written yesterday", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + }); + await get("G05-stale"); + }); - it("P29 a reply the outbound screen blocks", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING, { - summary: - "Your 10-year cardiovascular risk is elevated — consider stepping your dose up.", - }), + it("G06 a briefing in another language", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "de", + }); + await get("G06-other-language"); + }); + + it("G07 an unparseable cache row", async () => { + await resetBriefing(OWNER_ID, { + text: "{not json", + cachedAt: new Date(NOW.getTime() - HOUR), + }); + await get("G07-unparseable"); + }); + + it("G08 a legacy payload, untagged", async () => { + await resetBriefing(OWNER_ID, { + text: { summary: "Legacy", recommendations: ["Drink water."] }, + cachedAt: new Date(NOW.getTime() - HOUR), + }); + await get("G08-legacy"); + }); + + it("G09 a failure newer than the last good briefing", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 3 * HOUR)), + cachedAt: new Date(NOW.getTime() - 3 * HOUR), + cachedLocale: "en", + markers: [ + { + reason: "all-providers-failed", + at: new Date(NOW.getTime() - HOUR), + httpStatus: 429, + }, + ], + }); + await get("G09-held-after-failure"); + }); + + it("G10 a failure with no good briefing, and an older superseded one", async () => { + await resetBriefing(OWNER_ID, { + markers: [ + { reason: "provider-error", at: new Date(NOW.getTime() - 5 * HOUR) }, + { reason: "invalid-json", at: new Date(NOW.getTime() - HOUR) }, + ], + }); + await get("G10-failed-empty"); + }); + + it("G11 a briefing the grounding gate withheld", async () => { + await resetBriefing(OWNER_ID, { + text: cached(null, new Date(NOW.getTime() - 2 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + markers: [ + { reason: "briefing-ungrounded", at: new Date(NOW.getTime() - HOUR) }, + ], + }); + await get("G11-withheld"); }); - await post("P29-outbound-screened", { force: true }); }); - it("P30 the about-me text changing during the generation", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING), - during: async () => { - await prisma.allergy.create({ - data: { userId: OWNER_ID, substance: "Latex" }, - }); - }, + describe("POST /api/insights/generate parity", () => { + it("P01 an anonymous call", async () => { + await post("P01-anonymous", { force: true }, { auth: anonymous }); }); - await post("P30-profile-scope-changed"); - await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); - }); - it("P31 the profile changing during the generation", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING), - during: async () => { - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { displayName: "Renamed Person" }, - }); - }, + it("P02 the AI analysis opt-out", async () => { + await post( + "P02-opted-out", + { force: true }, + { auth: optedOut, userId: OPTED_OUT_ID }, + ); }); - await post("P31-scope-changed"); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { displayName: "Parity Person" }, + + it("P03 a body over the cap", async () => { + await resetBriefing(OWNER_ID); + await post("P03-body-too-large", { pad: "x".repeat(17 * 1024) }); + }); + + it("P04 a cached briefing written today", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + }); + await post("P04-cache-hit", {}); + }); + + it("P05 a cached briefing from yesterday, regenerated", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + }); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P05-yesterday-regenerated", {}); + }); + + it("P06 a cache in another language, regenerated", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "de", + }); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P06-other-language-regenerated", {}); + }); + + it("P07 a briefingless cache when the hour's quota is spent", async () => { + await resetBriefing(OWNER_ID, { + text: cached(null, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + quotaUsed: 10, + }); + await post("P07-quota-cached-fallback", {}); + }); + + it("P08 a forced generation when the hour's quota is spent", async () => { + await resetBriefing(OWNER_ID, { quotaUsed: 10 }); + await post("P08-quota-refused"); + }); + + it("P09 a key no resolver can open", async () => { + await resetBriefing(UNREADABLE_KEY_ID); + await post( + "P09-unreadable-key", + { force: true }, + { auth: unreadableKey, userId: UNREADABLE_KEY_ID }, + ); + }); + + it("P10 a fresh generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + tokensUsed: 2100, + }); + await post("P10-generated"); + }); + + it("P11 a reply the strict schema accepts", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING, { + disclaimer: "Consult your doctor.", + }), + }); + await post("P11-strict-reply"); + }); + + it("P12 a generation in German with the comparison, about-me and short form", async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { + locale: "de", + aiUsageLevel: "FRUGAL", + dashboardWidgetsJson: { comparisonBaseline: "lastMonth" }, + }, + }); + await prisma.allergy.create({ + data: { userId: OWNER_ID, substance: "Penicillin" }, + }); + await resetBriefing(OWNER_ID); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P12-generated-de-short"); + await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { locale: "en", aiUsageLevel: null, dashboardWidgetsJson: {} }, + }); + }); + + it("P13 an oversized raw payload, downgraded once", async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { insightsPrivacyMode: "raw" }, + }); + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P13-downgraded"); + }); + + it("P14 an oversized aggregate too, downgraded twice", async () => { + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000, 5_400_000); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P14-hard-downgraded"); + }); + + it("P15 oversized on every rung", async () => { + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000, 5_400_000, 5_200_000); + await post("P15-too-large"); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { insightsPrivacyMode: "aggregated" }, + }); + }); + + it("P16 the day's token ceiling", async () => { + await resetBriefing(OWNER_ID, { spentToday: 5_000_000 }); + await post("P16-budget-exceeded"); + }); + + it("P17 every provider refused the credential", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(401), hop(403, 2)]), + }); + await post("P17-chain-auth"); + }); + + it("P18 every provider rate-limited", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(429)]), + }); + await post("P18-chain-rate-limited"); + }); + + it("P19 a provider in a 5xx", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(401), hop(502, 2)]), + }); + await post("P19-chain-5xx"); + }); + + it("P20 every provider unreachable", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(null), hop(null, 2)]), + }); + await post("P20-chain-transport"); + }); + + it("P21 a mixed chain failure", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(404), hop(429, 2)]), + }); + await post("P21-chain-mixed"); + }); + + it("P22 one provider error per status class", async () => { + for (const status of [401, 429, 500, 400]) { + await resetBriefing(OWNER_ID); + seams.script.push({ error: providerError(status) }); + await post(`P22-provider-${status}`); + } + }); + + it("P23 a reply that is not JSON", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ content: "Sure! Here is your briefing." }); + await post("P23-invalid-json"); + }); + + it("P24 a reply cut off at the token ceiling", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: '{"summary": "Your', + finishReason: "length", + }); + await post("P24-truncated"); + }); + + it("P25 an ungrounded briefing the retry fixes", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(GROUNDED_BRIEFING), tokensUsed: 900 }, + ); + await post("P25-grounding-retry-kept"); + }); + + it("P26 still ungrounded, the previous briefing stands in", async () => { + await resetBriefing(OWNER_ID, { + text: cached(PREVIOUS_BRIEFING, new Date(NOW.getTime() - 26 * HOUR)), + cachedAt: new Date(NOW.getTime() - 26 * HOUR), + cachedLocale: "en", + }); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(UNGROUNDED_BRIEFING) }, + ); + await post("P26-grounding-previous"); + }); + + it("P27 still ungrounded, nothing to stand in", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(UNGROUNDED_BRIEFING) }, + ); + await post("P27-grounding-stripped"); + }); + + it("P28 an ungrounded briefing whose retry is not JSON", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: "not json" }, + ); + await post("P28-grounding-retry-unparseable"); + }); + + it("P29 a reply the outbound screen blocks", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING, { + summary: + "Your 10-year cardiovascular risk is elevated — consider stepping your dose up.", + }), + }); + await post("P29-outbound-screened", { force: true }); + }); + + it("P30 the about-me text changing during the generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + during: async () => { + await prisma.allergy.create({ + data: { userId: OWNER_ID, substance: "Latex" }, + }); + }, + }); + await post("P30-profile-scope-changed"); + await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); + }); + + it("P31 the profile changing during the generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + during: async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { displayName: "Renamed Person" }, + }); + }, + }); + await post("P31-scope-changed"); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { displayName: "Parity Person" }, + }); }); }); }); diff --git a/tests/integration/routes/parity/measurement-series.parity.test.ts b/tests/integration/routes/parity/measurement-series.parity.test.ts index 3c2e82bc6..9b9f9f2c5 100644 --- a/tests/integration/routes/parity/measurement-series.parity.test.ts +++ b/tests/integration/routes/parity/measurement-series.parity.test.ts @@ -239,7 +239,7 @@ beforeAll(async () => { delegate = { auth: session }; }); -describe("GET /api/measurements/series parity", () => { +describe("GET /api/measurements/series parity", { shuffle: false }, () => { it("the plain readers", async () => { await step("01-weight", { kind: "weight" }); await step("02-steps", { kind: "steps", days: 14 }); diff --git a/tests/integration/routes/parity/measurements-batch.parity.test.ts b/tests/integration/routes/parity/measurements-batch.parity.test.ts index 2cb309931..9315aa8ab 100644 --- a/tests/integration/routes/parity/measurements-batch.parity.test.ts +++ b/tests/integration/routes/parity/measurements-batch.parity.test.ts @@ -327,7 +327,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/measurements/batch parity", () => { +describe("POST /api/measurements/batch parity", { shuffle: false }, () => { it("01 a first sync of mixed entries", async () => { await step("01-first-sync", { entries: FIRST_SYNC, diff --git a/tests/integration/routes/parity/measurements.parity.test.ts b/tests/integration/routes/parity/measurements.parity.test.ts index 5055c39e0..8401da77f 100644 --- a/tests/integration/routes/parity/measurements.parity.test.ts +++ b/tests/integration/routes/parity/measurements.parity.test.ts @@ -220,185 +220,192 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/measurements parity", () => { - it("a single reading, its duplicate, a refused one", async () => { - const weight = { - type: "WEIGHT", - value: 80.4, - measuredAt: "2026-10-14T06:30:00.000Z", - notes: "after the run", - deviceType: "scale", - }; - await post("post-01-single", weight); - await post("post-02-single-duplicate", weight); - await post("post-03-single-invalid", { - type: "WEIGHT", - value: "heavy", - measuredAt: "2026-10-14T06:30:00.000Z", - }); - await post("post-04-single-glucose", { - type: "BLOOD_GLUCOSE", - value: 104, - measuredAt: "2026-10-14T07:00:00.000Z", - glucoseContext: "FASTING", - symptomsPresent: false, - }); - }); - - it("a combined two-value reading, its duplicate, a refused one", async () => { - const reading = [ - { - type: "BLOOD_PRESSURE_SYS", - value: 128, - measuredAt: "2026-10-14T08:00:00.000Z", - }, - { - type: "BLOOD_PRESSURE_DIA", - value: 82, - measuredAt: "2026-10-14T08:00:00.000Z", - }, - { - type: "PULSE", - value: 66, - measuredAt: "2026-10-14T08:00:00.000Z", - notes: "seated", - symptomsPresent: true, - }, - ]; - await post("post-05-batch", reading); - await post("post-06-batch-duplicate", reading); - await post("post-07-batch-invalid", [{ type: "PULSE", value: -1 }]); - }); - - it("a named sleep-stage segment, its re-post and its mirror", async () => { - const segment = { - type: "SLEEP_DURATION", - sleepStage: "DEEP", - startDate: "2026-10-14T01:00:00.000Z", - endDate: "2026-10-14T02:30:00.000Z", - externalId: "bridge-sleep-1", - }; - await post("post-08-sleep-stage", segment); - await post("post-09-sleep-stage-again", segment); - await post("post-10-sleep-stage-mirror", { - ...segment, - externalId: "hk-sleep-1", - source: "APPLE_HEALTH", - }); - await post("post-11-sleep-stage-invalid", { - type: "SLEEP_DURATION", - sleepStage: "DEEP", - }); - }); - - it("a narrow bridge token with and without a source", async () => { - const bridge = await makeToken( - { id: ownerId }, - { permissions: [MEASUREMENTS_WRITE_SCOPE] }, - ); - await post( - "post-12-bridge-with-source", - { +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("measurements parity walk", { shuffle: false }, () => { + describe("POST /api/measurements parity", () => { + it("a single reading, its duplicate, a refused one", async () => { + const weight = { type: "WEIGHT", - value: 80.1, - measuredAt: "2026-10-15T06:30:00.000Z", - source: "MANUAL", - }, - bridge, - ); - await post( - "post-13-bridge", - { + value: 80.4, + measuredAt: "2026-10-14T06:30:00.000Z", + notes: "after the run", + deviceType: "scale", + }; + await post("post-01-single", weight); + await post("post-02-single-duplicate", weight); + await post("post-03-single-invalid", { type: "WEIGHT", - value: 80.1, - measuredAt: "2026-10-15T06:30:00.000Z", - }, - bridge, - ); - }); - - it("a delegate writing into a shared record", async () => { - const delegateUser = await makeUser({ timezone: "UTC" }); - await makeGrant({ id: ownerId }, delegateUser, { access: "WRITE" }); - const delegate = await makeSession(delegateUser); - await switchSessionTo(delegate.sessionId, ownerId); - await post( - "post-14-delegated", - { - type: "PULSE", - value: 71, - measuredAt: "2026-10-15T07:00:00.000Z", - }, - delegate, - ); - await switchSessionTo(delegate.sessionId, null); - }); -}); - -describe("GET /api/measurements parity", () => { - beforeAll(async () => { - // Day totals for the cumulative list modes, from the phone. - for (const [day, value] of [ - ["2026-10-12", 7_100], - ["2026-10-13", 8_400], - ["2026-10-14", 5_100], - ] as const) { - await prisma.measurement.create({ - data: { - userId: ownerId, - type: "ACTIVITY_STEPS", - value, - unit: "steps", - source: "APPLE_HEALTH", - measuredAt: new Date(`${day}T12:00:00.000Z`), - externalId: `stats:${STEPS}:${day}`, - aggregationProvenance: "HEALTHKIT_STATISTICS", - }, + value: "heavy", + measuredAt: "2026-10-14T06:30:00.000Z", + }); + await post("post-04-single-glucose", { + type: "BLOOD_GLUCOSE", + value: 104, + measuredAt: "2026-10-14T07:00:00.000Z", + glucoseContext: "FASTING", + symptomsPresent: false, }); - } - }); - - it("the plain page and its filters", async () => { - await get("get-01-page", { limit: 50 }); - await get("get-02-type-sorted", { - type: "PULSE", - sortBy: "value", - sortDir: "asc", }); - await get("get-03-filtered", { - sourceEq: "MANUAL", - valueMin: 70, - valueMax: 130, - from: "2026-10-14T00:00:00.000Z", - to: "2026-10-15T23:59:59.000Z", - limit: 2, - offset: 1, + + it("a combined two-value reading, its duplicate, a refused one", async () => { + const reading = [ + { + type: "BLOOD_PRESSURE_SYS", + value: 128, + measuredAt: "2026-10-14T08:00:00.000Z", + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 82, + measuredAt: "2026-10-14T08:00:00.000Z", + }, + { + type: "PULSE", + value: 66, + measuredAt: "2026-10-14T08:00:00.000Z", + notes: "seated", + symptomsPresent: true, + }, + ]; + await post("post-05-batch", reading); + await post("post-06-batch-duplicate", reading); + await post("post-07-batch-invalid", [{ type: "PULSE", value: -1 }]); + }); + + it("a named sleep-stage segment, its re-post and its mirror", async () => { + const segment = { + type: "SLEEP_DURATION", + sleepStage: "DEEP", + startDate: "2026-10-14T01:00:00.000Z", + endDate: "2026-10-14T02:30:00.000Z", + externalId: "bridge-sleep-1", + }; + await post("post-08-sleep-stage", segment); + await post("post-09-sleep-stage-again", segment); + await post("post-10-sleep-stage-mirror", { + ...segment, + externalId: "hk-sleep-1", + source: "APPLE_HEALTH", + }); + await post("post-11-sleep-stage-invalid", { + type: "SLEEP_DURATION", + sleepStage: "DEEP", + }); + }); + + it("a narrow bridge token with and without a source", async () => { + const bridge = await makeToken( + { id: ownerId }, + { permissions: [MEASUREMENTS_WRITE_SCOPE] }, + ); + await post( + "post-12-bridge-with-source", + { + type: "WEIGHT", + value: 80.1, + measuredAt: "2026-10-15T06:30:00.000Z", + source: "MANUAL", + }, + bridge, + ); + await post( + "post-13-bridge", + { + type: "WEIGHT", + value: 80.1, + measuredAt: "2026-10-15T06:30:00.000Z", + }, + bridge, + ); + }); + + it("a delegate writing into a shared record", async () => { + const delegateUser = await makeUser({ timezone: "UTC" }); + await makeGrant({ id: ownerId }, delegateUser, { access: "WRITE" }); + const delegate = await makeSession(delegateUser); + await switchSessionTo(delegate.sessionId, ownerId); + await post( + "post-14-delegated", + { + type: "PULSE", + value: 71, + measuredAt: "2026-10-15T07:00:00.000Z", + }, + delegate, + ); + await switchSessionTo(delegate.sessionId, null); }); }); - it("the list modes", async () => { - await get("get-04-sleep", { type: "SLEEP_DURATION" }); - await get("get-05-drill-down", { - type: "ACTIVITY_STEPS", - dayKey: "2026-10-13", + describe("GET /api/measurements parity", () => { + beforeAll(async () => { + // Day totals for the cumulative list modes, from the phone. + for (const [day, value] of [ + ["2026-10-12", 7_100], + ["2026-10-13", 8_400], + ["2026-10-14", 5_100], + ] as const) { + await prisma.measurement.create({ + data: { + userId: ownerId, + type: "ACTIVITY_STEPS", + value, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date(`${day}T12:00:00.000Z`), + externalId: `stats:${STEPS}:${day}`, + aggregationProvenance: "HEALTHKIT_STATISTICS", + }, + }); + } }); - await get("get-06-day-grouped", { type: "ACTIVITY_STEPS", groupBy: "day" }); - await get("get-07-rollup-series", { - type: "ACTIVITY_STEPS", - source: "rollup", - aggregate: "daily", - from: "2026-10-10T00:00:00.000Z", - to: "2026-10-15T23:59:59.000Z", - }); - await get("get-08-live-aggregate", { - type: "PULSE", - aggregate: "weekly", - from: "2026-09-01T00:00:00.000Z", - to: "2026-10-15T23:59:59.000Z", - }); - }); - it("a refused query", async () => { - await get("get-09-invalid", { limit: 0, sortDir: "sideways" }); + it("the plain page and its filters", async () => { + await get("get-01-page", { limit: 50 }); + await get("get-02-type-sorted", { + type: "PULSE", + sortBy: "value", + sortDir: "asc", + }); + await get("get-03-filtered", { + sourceEq: "MANUAL", + valueMin: 70, + valueMax: 130, + from: "2026-10-14T00:00:00.000Z", + to: "2026-10-15T23:59:59.000Z", + limit: 2, + offset: 1, + }); + }); + + it("the list modes", async () => { + await get("get-04-sleep", { type: "SLEEP_DURATION" }); + await get("get-05-drill-down", { + type: "ACTIVITY_STEPS", + dayKey: "2026-10-13", + }); + await get("get-06-day-grouped", { + type: "ACTIVITY_STEPS", + groupBy: "day", + }); + await get("get-07-rollup-series", { + type: "ACTIVITY_STEPS", + source: "rollup", + aggregate: "daily", + from: "2026-10-10T00:00:00.000Z", + to: "2026-10-15T23:59:59.000Z", + }); + await get("get-08-live-aggregate", { + type: "PULSE", + aggregate: "weekly", + from: "2026-09-01T00:00:00.000Z", + to: "2026-10-15T23:59:59.000Z", + }); + }); + + it("a refused query", async () => { + await get("get-09-invalid", { limit: 0, sortDir: "sideways" }); + }); }); }); diff --git a/tests/integration/routes/parity/medication-detail.parity.test.ts b/tests/integration/routes/parity/medication-detail.parity.test.ts index 8ca3932e7..d914179d9 100644 --- a/tests/integration/routes/parity/medication-detail.parity.test.ts +++ b/tests/integration/routes/parity/medication-detail.parity.test.ts @@ -358,212 +358,216 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/medications/{id} parity", () => { - it("01 a rolling injection with stock, a composition and a custom category", async () => { - await get("01-rolling", med.rolling); +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-detail parity walk", { shuffle: false }, () => { + describe("GET /api/medications/{id} parity", () => { + it("01 a rolling injection with stock, a composition and a custom category", async () => { + await get("01-rolling", med.rolling); + }); + + it("02 a daily tablet without stock", async () => { + await get("02-daily", med.tablet); + }); + + it("03 a record-only medication", async () => { + await get("03-record-only", med.recordOnly); + }); + + it("04 another account's medication", async () => { + await get("04-not-found", med.foreign); + }); + + it("05 a reading delegate, and nobody", async () => { + await get("05a-reader", med.rolling, reader); + await get("05b-anonymous", med.rolling, { auth: anonymous }); + }); }); - it("02 a daily tablet without stock", async () => { - await get("02-daily", med.tablet); + describe("PUT /api/medications/{id} parity", () => { + it("11 plain fields", async () => { + await put("11-plain-fields", med.tablet, { + name: "Daily tablet renamed", + dose: "20mg", + treatmentClass: "GENERIC", + dosesPerUnit: 30, + reorderLeadDays: 5, + deliveryForm: "ORAL", + notificationsEnabled: false, + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "C09AA05", + rxNormCode: null, + }); + }); + + it("12 a schedule replace whose dose time leaves the stale window", async () => { + await put("12-schedule-replace", med.tablet, { + schedules: [ + { + windowStart: "08:00", + windowEnd: "10:00", + timesOfDay: ["11:30"], + label: "Late morning", + dose: "10mg", + unitsPerDose: 2, + reminderGraceMinutes: 30, + }, + { + windowStart: "20:00", + windowEnd: "22:00", + daysOfWeek: [1, 3, 5], + intervalWeeks: 2, + doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], + }, + ], + }); + }); + + it("13 the same schedule echoed back", async () => { + await put("13-schedule-echo", med.tablet, { + schedules: [ + { + windowStart: "11:30", + windowEnd: "11:30", + timesOfDay: ["11:30"], + label: "Late morning", + dose: "10mg", + unitsPerDose: 2, + reminderGraceMinutes: 30, + rrule: "FREQ=DAILY", + }, + { + windowStart: "20:00", + windowEnd: "22:00", + daysOfWeek: [1, 3, 5], + intervalWeeks: 2, + doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], + }, + ], + }); + }); + + it("14 the top-level reminder window", async () => { + await put("14-grace-bridge", med.tablet, { reminderGraceMinutes: 45 }); + }); + + it("15 a pause and a resume", async () => { + await put("15a-pause", med.tablet, { active: false }); + await put("15b-resume", med.tablet, { active: true }); + }); + + it("16 automatic recording switched on", async () => { + await put("16-auto-mark-taken", med.tablet, { autoMarkTaken: true }); + }); + + it("17 the category writes", async () => { + await put("17a-custom-category", med.tablet, { category: CUSTOM_KEY }); + await put( + "17b-native-other-kept", + med.tablet, + { category: "OTHER" }, + { auth: ownerBearer }, + ); + await put("17c-web-other", med.tablet, { category: "OTHER" }); + await put("17d-unknown-category", med.tablet, { + category: "custom:0a6c2d4e-1111-4222-8333-944455556666", + }); + }); + + it("18 a record-only medication and both tracking transitions", async () => { + await put("18a-record-schedule-kept", med.recordOnly, { + name: "Record only renamed", + schedules: [daily("06:00", "07:00")], + oneShot: false, + }); + await put("18b-tracking-on", med.recordOnly, { trackIntake: true }); + await put("18c-tracking-off", med.recordOnly, { trackIntake: false }); + }); + + it("19 the as-needed flips and their refusals", async () => { + await put("19a-scheduled-without-schedules", med.asNeeded, { + asNeeded: false, + }); + await put("19b-scheduled", med.asNeeded, { + asNeeded: false, + schedules: [daily("08:00", "09:00")], + }); + await put("19c-as-needed-keeping-schedules", med.asNeeded, { + asNeeded: true, + }); + await put("19d-as-needed", med.asNeeded, { + asNeeded: true, + schedules: [], + }); + }); + + it("20 the one-shot refusals and a conversion", async () => { + await put("20a-one-shot-two-schedules", med.booster, { + oneShot: true, + startsOn: "2026-10-20", + schedules: [daily("09:00", "10:00"), daily("18:00", "19:00")], + }); + await put("20b-one-shot-recurrence", med.booster, { + oneShot: true, + startsOn: "2026-10-20", + schedules: [ + { windowStart: "09:00", windowEnd: "10:00", rrule: "FREQ=DAILY" }, + ], + }); + await put("20c-one-shot", med.booster, { + oneShot: true, + startsOn: "2026-10-20", + schedules: [{ windowStart: "09:00", windowEnd: "10:00" }], + }); + }); + + it("21 the course refusals and a course edit", async () => { + await put("21a-course-overlap", med.course, { startsOn: "2026-08-20" }); + await put("21b-course-window-required", med.course, { + startsOn: null, + endsOn: null, + }); + await put("21c-course-end", med.course, { endsOn: "2026-12-31" }); + }); + + it("22 a units change on a medication with a composition", async () => { + await put("22-composition-units", med.rolling, { unitsPerDose: 2 }); + }); + + it("23 a body the schema refuses, and another account's medication", async () => { + await put("23a-invalid", med.tablet, { + name: "", + unitsPerDose: -1, + schedules: "daily", + }); + await put("23b-not-found", med.foreign, { name: "Taken over" }); + }); + + it("24 a delegate who may write, one who may manage, and nobody", async () => { + await put("24a-writer", med.tablet, { name: "By a writer" }, writer); + await put("24b-manager", med.tablet, { name: "By a manager" }, manager); + await put( + "24c-anonymous", + med.tablet, + { name: "By nobody" }, + { auth: anonymous }, + ); + }); }); - it("03 a record-only medication", async () => { - await get("03-record-only", med.recordOnly); - }); + describe("DELETE /api/medications/{id} parity", () => { + it("31 the owner deletes a medication with a composition and an ingest token", async () => { + await del("31-delete", med.doomed); + }); - it("04 another account's medication", async () => { - await get("04-not-found", med.foreign); - }); + it("32 the same medication again", async () => { + await del("32-not-found", med.doomed); + }); - it("05 a reading delegate, and nobody", async () => { - await get("05a-reader", med.rolling, reader); - await get("05b-anonymous", med.rolling, { auth: anonymous }); - }); -}); - -describe("PUT /api/medications/{id} parity", () => { - it("11 plain fields", async () => { - await put("11-plain-fields", med.tablet, { - name: "Daily tablet renamed", - dose: "20mg", - treatmentClass: "GENERIC", - dosesPerUnit: 30, - reorderLeadDays: 5, - deliveryForm: "ORAL", - notificationsEnabled: false, - liveActivityEnabled: true, - criticalAlarmEnabled: true, - atcCode: "C09AA05", - rxNormCode: null, + it("33 a managing delegate, and nobody", async () => { + await del("33a-manager", med.booster, manager); + await del("33b-anonymous", med.booster, { auth: anonymous }); }); - }); - - it("12 a schedule replace whose dose time leaves the stale window", async () => { - await put("12-schedule-replace", med.tablet, { - schedules: [ - { - windowStart: "08:00", - windowEnd: "10:00", - timesOfDay: ["11:30"], - label: "Late morning", - dose: "10mg", - unitsPerDose: 2, - reminderGraceMinutes: 30, - }, - { - windowStart: "20:00", - windowEnd: "22:00", - daysOfWeek: [1, 3, 5], - intervalWeeks: 2, - doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], - }, - ], - }); - }); - - it("13 the same schedule echoed back", async () => { - await put("13-schedule-echo", med.tablet, { - schedules: [ - { - windowStart: "11:30", - windowEnd: "11:30", - timesOfDay: ["11:30"], - label: "Late morning", - dose: "10mg", - unitsPerDose: 2, - reminderGraceMinutes: 30, - rrule: "FREQ=DAILY", - }, - { - windowStart: "20:00", - windowEnd: "22:00", - daysOfWeek: [1, 3, 5], - intervalWeeks: 2, - doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], - }, - ], - }); - }); - - it("14 the top-level reminder window", async () => { - await put("14-grace-bridge", med.tablet, { reminderGraceMinutes: 45 }); - }); - - it("15 a pause and a resume", async () => { - await put("15a-pause", med.tablet, { active: false }); - await put("15b-resume", med.tablet, { active: true }); - }); - - it("16 automatic recording switched on", async () => { - await put("16-auto-mark-taken", med.tablet, { autoMarkTaken: true }); - }); - - it("17 the category writes", async () => { - await put("17a-custom-category", med.tablet, { category: CUSTOM_KEY }); - await put( - "17b-native-other-kept", - med.tablet, - { category: "OTHER" }, - { auth: ownerBearer }, - ); - await put("17c-web-other", med.tablet, { category: "OTHER" }); - await put("17d-unknown-category", med.tablet, { - category: "custom:0a6c2d4e-1111-4222-8333-944455556666", - }); - }); - - it("18 a record-only medication and both tracking transitions", async () => { - await put("18a-record-schedule-kept", med.recordOnly, { - name: "Record only renamed", - schedules: [daily("06:00", "07:00")], - oneShot: false, - }); - await put("18b-tracking-on", med.recordOnly, { trackIntake: true }); - await put("18c-tracking-off", med.recordOnly, { trackIntake: false }); - }); - - it("19 the as-needed flips and their refusals", async () => { - await put("19a-scheduled-without-schedules", med.asNeeded, { - asNeeded: false, - }); - await put("19b-scheduled", med.asNeeded, { - asNeeded: false, - schedules: [daily("08:00", "09:00")], - }); - await put("19c-as-needed-keeping-schedules", med.asNeeded, { - asNeeded: true, - }); - await put("19d-as-needed", med.asNeeded, { - asNeeded: true, - schedules: [], - }); - }); - - it("20 the one-shot refusals and a conversion", async () => { - await put("20a-one-shot-two-schedules", med.booster, { - oneShot: true, - startsOn: "2026-10-20", - schedules: [daily("09:00", "10:00"), daily("18:00", "19:00")], - }); - await put("20b-one-shot-recurrence", med.booster, { - oneShot: true, - startsOn: "2026-10-20", - schedules: [ - { windowStart: "09:00", windowEnd: "10:00", rrule: "FREQ=DAILY" }, - ], - }); - await put("20c-one-shot", med.booster, { - oneShot: true, - startsOn: "2026-10-20", - schedules: [{ windowStart: "09:00", windowEnd: "10:00" }], - }); - }); - - it("21 the course refusals and a course edit", async () => { - await put("21a-course-overlap", med.course, { startsOn: "2026-08-20" }); - await put("21b-course-window-required", med.course, { - startsOn: null, - endsOn: null, - }); - await put("21c-course-end", med.course, { endsOn: "2026-12-31" }); - }); - - it("22 a units change on a medication with a composition", async () => { - await put("22-composition-units", med.rolling, { unitsPerDose: 2 }); - }); - - it("23 a body the schema refuses, and another account's medication", async () => { - await put("23a-invalid", med.tablet, { - name: "", - unitsPerDose: -1, - schedules: "daily", - }); - await put("23b-not-found", med.foreign, { name: "Taken over" }); - }); - - it("24 a delegate who may write, one who may manage, and nobody", async () => { - await put("24a-writer", med.tablet, { name: "By a writer" }, writer); - await put("24b-manager", med.tablet, { name: "By a manager" }, manager); - await put( - "24c-anonymous", - med.tablet, - { name: "By nobody" }, - { auth: anonymous }, - ); - }); -}); - -describe("DELETE /api/medications/{id} parity", () => { - it("31 the owner deletes a medication with a composition and an ingest token", async () => { - await del("31-delete", med.doomed); - }); - - it("32 the same medication again", async () => { - await del("32-not-found", med.doomed); - }); - - it("33 a managing delegate, and nobody", async () => { - await del("33a-manager", med.booster, manager); - await del("33b-anonymous", med.booster, { auth: anonymous }); }); }); diff --git a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts index f51313028..4175929d7 100644 --- a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts @@ -375,7 +375,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/medications/intake/bulk parity", () => { +describe("POST /api/medications/intake/bulk parity", { shuffle: false }, () => { it("01 a mixed first batch", async () => { await post("01-mixed", mixedBatch()); }); diff --git a/tests/integration/routes/parity/medication-intake-event.parity.test.ts b/tests/integration/routes/parity/medication-intake-event.parity.test.ts index acfbe8d4b..775a38dba 100644 --- a/tests/integration/routes/parity/medication-intake-event.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-event.parity.test.ts @@ -229,131 +229,135 @@ afterAll(() => { vi.useRealTimers(); }); -describe("PUT /api/medications/{id}/intake/{eventId} parity", () => { - it("01 a pending dose taken inside its own window", async () => { - await put("01-take-in-place", events.pending, { - takenAt: "2026-10-13T08:40:00.000Z", +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-intake-event parity walk", { shuffle: false }, () => { + describe("PUT /api/medications/{id}/intake/{eventId} parity", () => { + it("01 a pending dose taken inside its own window", async () => { + await put("01-take-in-place", events.pending, { + takenAt: "2026-10-13T08:40:00.000Z", + }); + }); + + it("02 the same dose flipped to a skip", async () => { + await put("02-skip-flip", events.pending, { skipped: true }); + }); + + it("03 a take corrected into the other window, onto a skipped slot", async () => { + const res = await put("03-move-by-band", events.morningTaken, { + takenAt: "2026-10-14T20:30:00.000Z", + }); + events.moved = (res.data as { id: string }).id; + }); + + it("04 a legacy take pinned onto a free slot", async () => { + const res = await put("04-pin-free-slot", events.legacyTaken, { + forceSlotInstant: "2026-10-12T20:00:00.000Z", + }); + events.pinned = (res.data as { id: string }).id; + }); + + it("05 a pin onto an instant that is no slot", async () => { + await put("05-pin-not-a-slot", events.moved, { + forceSlotInstant: "2026-10-12T15:00:00.000Z", + }); + }); + + it("06 a pin onto a slot another dose holds", async () => { + await put("06-pin-occupied", events.moved, { + forceSlotInstant: "2026-10-12T20:00:00.000Z", + }); + }); + + it("07 an unpin", async () => { + const res = await put("07-unpin", events.pinned, { + forceSlotInstant: null, + }); + events.pinned = (res.data as { id: string }).id; + }); + + it("08 an explicit slot in the body", async () => { + const res = await put("08-explicit-slot", events.moved, { + scheduledFor: "2026-10-14T08:00:00.000Z", + takenAt: "2026-10-14T08:45:00.000Z", + }); + events.moved = (res.data as { id: string }).id; + }); + + it("09 a take before the start day", async () => { + await put("09-before-start", events.pinned, { + takenAt: "2026-09-20T08:00:00.000Z", + }); + }); + + it("10 a body the schema refuses", async () => { + await put("10-invalid", events.pinned, { + takenAt: "yesterday", + skipped: "no", + }); + }); + + it("11 a tombstoned dose, and a dose under another medication", async () => { + await put("11a-tombstoned", events.tombstoned, { skipped: true }); + await put( + "11b-other-medication", + events.pinned, + { skipped: true }, + { medicationId: oneShotId }, + ); + }); + + it("12 a delegate who may write, and one who may manage", async () => { + await put( + "12a-writer", + events.pinned, + { takenAt: "2026-10-13T20:50:00.000Z" }, + writer, + ); + await put( + "12b-manager", + events.pinned, + { takenAt: "2026-10-13T20:50:00.000Z" }, + manager, + ); + }); + + it("13 no credentials", async () => { + await put( + "13-anonymous", + events.pinned, + { skipped: true }, + { + auth: anonymous, + }, + ); }); }); - it("02 the same dose flipped to a skip", async () => { - await put("02-skip-flip", events.pending, { skipped: true }); - }); - - it("03 a take corrected into the other window, onto a skipped slot", async () => { - const res = await put("03-move-by-band", events.morningTaken, { - takenAt: "2026-10-14T20:30:00.000Z", + describe("DELETE /api/medications/{id}/intake/{eventId} parity", () => { + it("21 the owner undoes a dose", async () => { + await del("21-delete", events.forDelete); }); - events.moved = (res.data as { id: string }).id; - }); - it("04 a legacy take pinned onto a free slot", async () => { - const res = await put("04-pin-free-slot", events.legacyTaken, { - forceSlotInstant: "2026-10-12T20:00:00.000Z", + it("22 undoing the only dose of a one-shot medication", async () => { + await del("22-delete-one-shot", events.oneShotDose, { + medicationId: oneShotId, + }); }); - events.pinned = (res.data as { id: string }).id; - }); - it("05 a pin onto an instant that is no slot", async () => { - await put("05-pin-not-a-slot", events.moved, { - forceSlotInstant: "2026-10-12T15:00:00.000Z", + it("23 a dose under another medication", async () => { + await del("23-other-medication", events.forManagerDelete, { + medicationId: oneShotId, + }); }); - }); - it("06 a pin onto a slot another dose holds", async () => { - await put("06-pin-occupied", events.moved, { - forceSlotInstant: "2026-10-12T20:00:00.000Z", + it("24 a delegate who may write, and one who may manage", async () => { + await del("24a-writer", events.forManagerDelete, writer); + await del("24b-manager", events.forManagerDelete, manager); }); - }); - it("07 an unpin", async () => { - const res = await put("07-unpin", events.pinned, { - forceSlotInstant: null, + it("25 no credentials", async () => { + await del("25-anonymous", events.forDelete, { auth: anonymous }); }); - events.pinned = (res.data as { id: string }).id; - }); - - it("08 an explicit slot in the body", async () => { - const res = await put("08-explicit-slot", events.moved, { - scheduledFor: "2026-10-14T08:00:00.000Z", - takenAt: "2026-10-14T08:45:00.000Z", - }); - events.moved = (res.data as { id: string }).id; - }); - - it("09 a take before the start day", async () => { - await put("09-before-start", events.pinned, { - takenAt: "2026-09-20T08:00:00.000Z", - }); - }); - - it("10 a body the schema refuses", async () => { - await put("10-invalid", events.pinned, { - takenAt: "yesterday", - skipped: "no", - }); - }); - - it("11 a tombstoned dose, and a dose under another medication", async () => { - await put("11a-tombstoned", events.tombstoned, { skipped: true }); - await put( - "11b-other-medication", - events.pinned, - { skipped: true }, - { medicationId: oneShotId }, - ); - }); - - it("12 a delegate who may write, and one who may manage", async () => { - await put( - "12a-writer", - events.pinned, - { takenAt: "2026-10-13T20:50:00.000Z" }, - writer, - ); - await put( - "12b-manager", - events.pinned, - { takenAt: "2026-10-13T20:50:00.000Z" }, - manager, - ); - }); - - it("13 no credentials", async () => { - await put( - "13-anonymous", - events.pinned, - { skipped: true }, - { - auth: anonymous, - }, - ); - }); -}); - -describe("DELETE /api/medications/{id}/intake/{eventId} parity", () => { - it("21 the owner undoes a dose", async () => { - await del("21-delete", events.forDelete); - }); - - it("22 undoing the only dose of a one-shot medication", async () => { - await del("22-delete-one-shot", events.oneShotDose, { - medicationId: oneShotId, - }); - }); - - it("23 a dose under another medication", async () => { - await del("23-other-medication", events.forManagerDelete, { - medicationId: oneShotId, - }); - }); - - it("24 a delegate who may write, and one who may manage", async () => { - await del("24a-writer", events.forManagerDelete, writer); - await del("24b-manager", events.forManagerDelete, manager); - }); - - it("25 no credentials", async () => { - await del("25-anonymous", events.forDelete, { auth: anonymous }); }); }); diff --git a/tests/integration/routes/parity/medication-intake-status.parity.test.ts b/tests/integration/routes/parity/medication-intake-status.parity.test.ts index 92f6c25bb..1c4859108 100644 --- a/tests/integration/routes/parity/medication-intake-status.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-status.parity.test.ts @@ -317,138 +317,145 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/medications/intake parity", () => { - it("01 today's list", async () => { - await get("01-today", { scope: "today" }); +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-intake-status parity walk", { shuffle: false }, () => { + describe("GET /api/medications/intake parity", () => { + it("01 today's list", async () => { + await get("01-today", { scope: "today" }); + }); + + it("02 the compliance buckets, cold and from the cache", async () => { + await get("02a-compliance-cold", { scope: "compliance", days: "7" }); + await get("02b-compliance-warm", { scope: "compliance", days: "7" }); + }); + + it("03 a query the schema refuses", async () => { + await get("03-invalid", { scope: "yesterday", days: "0" }); + }); + + it("04 a reading delegate, and nobody", async () => { + await get("04a-reader", { scope: "today" }, reader); + await get("04b-anonymous", { scope: "today" }, { auth: anonymous }); + }); }); - it("02 the compliance buckets, cold and from the cache", async () => { - await get("02a-compliance-cold", { scope: "compliance", days: "7" }); - await get("02b-compliance-warm", { scope: "compliance", days: "7" }); - }); + describe("POST /api/medications/intake parity", () => { + it("11 a take in place", async () => { + await post("11-take-in-place", { + intakeId: ev.todayMorning, + status: "taken", + takenAt: "2026-10-15T08:30:00.000Z", + }); + }); - it("03 a query the schema refuses", async () => { - await get("03-invalid", { scope: "yesterday", days: "0" }); - }); + it("12 a take that re-binds to another slot", async () => { + await post("12-take-slot-move", { + intakeId: ev.moveMe, + status: "taken", + takenAt: "2026-10-14T20:30:00.000Z", + }); + }); - it("04 a reading delegate, and nobody", async () => { - await get("04a-reader", { scope: "today" }, reader); - await get("04b-anonymous", { scope: "today" }, { auth: anonymous }); - }); -}); - -describe("POST /api/medications/intake parity", () => { - it("11 a take in place", async () => { - await post("11-take-in-place", { - intakeId: ev.todayMorning, - status: "taken", - takenAt: "2026-10-15T08:30:00.000Z", + it("13 a pinned take, and a pin that is no slot", async () => { + await post("13a-pin", { + intakeId: ev.pinMe, + status: "taken", + takenAt: "2026-10-13T11:00:00.000Z", + forceSlotInstant: "2026-10-13T08:00:00.000Z", + }); + await post("13b-pin-not-a-slot", { + intakeId: ev.snoozeMe, + status: "taken", + forceSlotInstant: "2026-10-11T15:00:00.000Z", + }); }); - }); - it("12 a take that re-binds to another slot", async () => { - await post("12-take-slot-move", { - intakeId: ev.moveMe, - status: "taken", - takenAt: "2026-10-14T20:30:00.000Z", + it("14 a skip over a recorded dose", async () => { + await post("14-skip", { intakeId: ev.skipMe, status: "skipped" }); }); - }); - it("13 a pinned take, and a pin that is no slot", async () => { - await post("13a-pin", { - intakeId: ev.pinMe, - status: "taken", - takenAt: "2026-10-13T11:00:00.000Z", - forceSlotInstant: "2026-10-13T08:00:00.000Z", - }); - await post("13b-pin-not-a-slot", { - intakeId: ev.snoozeMe, - status: "taken", - forceSlotInstant: "2026-10-11T15:00:00.000Z", + it("15 a snooze with its end, and one without", async () => { + await post("15a-snooze-until", { + intakeId: ev.snoozeMe, + status: "snoozed", + snoozedUntil: "2026-10-15T13:00:00.000Z", + }); + await post("15b-snooze-default", { + intakeId: ev.snoozeMe, + status: "snoozed", + }); }); - }); - it("14 a skip over a recorded dose", async () => { - await post("14-skip", { intakeId: ev.skipMe, status: "skipped" }); - }); - - it("15 a snooze with its end, and one without", async () => { - await post("15a-snooze-until", { - intakeId: ev.snoozeMe, - status: "snoozed", - snoozedUntil: "2026-10-15T13:00:00.000Z", - }); - await post("15b-snooze-default", { - intakeId: ev.snoozeMe, - status: "snoozed", + it("16 the not-found answers", async () => { + await post("16a-unknown", { + intakeId: "cmv0000000000000000000000", + status: "taken", + }); + await post("16b-foreign", { intakeId: ev.foreign, status: "taken" }); + await post("16c-tombstoned", { + intakeId: ev.tombstoned, + status: "taken", + }); }); - }); - it("16 the not-found answers", async () => { - await post("16a-unknown", { - intakeId: "cmv0000000000000000000000", - status: "taken", + it("17 the record-only rule both ways", async () => { + await post("17a-record-only-open", { + intakeId: ev.recordOpen, + status: "taken", + }); + await post("17b-record-only-correction", { + intakeId: ev.recordTaken, + status: "taken", + takenAt: "2026-10-13T07:20:00.000Z", + }); }); - await post("16b-foreign", { intakeId: ev.foreign, status: "taken" }); - await post("16c-tombstoned", { intakeId: ev.tombstoned, status: "taken" }); - }); - it("17 the record-only rule both ways", async () => { - await post("17a-record-only-open", { - intakeId: ev.recordOpen, - status: "taken", + it("18 the injection-site refusal, and an allowed site", async () => { + await post("18a-site-not-allowed", { + intakeId: ev.penToday, + status: "taken", + takenAt: "2026-10-15T09:15:00.000Z", + injectionSite: "ABDOMEN_RIGHT", + }); + await post("18b-site", { + intakeId: ev.penToday, + status: "taken", + takenAt: "2026-10-15T09:15:00.000Z", + injectionSite: "ABDOMEN_LEFT", + }); }); - await post("17b-record-only-correction", { - intakeId: ev.recordTaken, - status: "taken", - takenAt: "2026-10-13T07:20:00.000Z", - }); - }); - it("18 the injection-site refusal, and an allowed site", async () => { - await post("18a-site-not-allowed", { - intakeId: ev.penToday, - status: "taken", - takenAt: "2026-10-15T09:15:00.000Z", - injectionSite: "ABDOMEN_RIGHT", - }); - await post("18b-site", { - intakeId: ev.penToday, - status: "taken", - takenAt: "2026-10-15T09:15:00.000Z", - injectionSite: "ABDOMEN_LEFT", + it("19 a body the schema refuses", async () => { + await post("19-invalid", { intakeId: "", status: "forgotten" }); }); - }); - it("19 a body the schema refuses", async () => { - await post("19-invalid", { intakeId: "", status: "forgotten" }); - }); - - it("20 a delegate who marks an open dose, a snooze, a correction, a reader and nobody", async () => { - await post( - "20a-writer-take", - { intakeId: ev.todayEvening, status: "taken" }, - { ...writer }, - ); - await post( - "20b-writer-snooze-refused", - { intakeId: ev.snoozeMe, status: "snoozed" }, - writer, - ); - await post( - "20c-writer-correction-refused", - { intakeId: ev.resolved, status: "skipped" }, - writer, - ); - await post( - "20d-reader", - { intakeId: ev.snoozeMe, status: "taken" }, - reader, - ); - await post( - "20e-anonymous", - { intakeId: ev.snoozeMe, status: "taken" }, - { auth: anonymous }, - ); + it("20 a delegate who marks an open dose, a snooze, a correction, a reader and nobody", async () => { + await post( + "20a-writer-take", + { intakeId: ev.todayEvening, status: "taken" }, + { ...writer }, + ); + await post( + "20b-writer-snooze-refused", + { intakeId: ev.snoozeMe, status: "snoozed" }, + writer, + ); + await post( + "20c-writer-correction-refused", + { intakeId: ev.resolved, status: "skipped" }, + writer, + ); + await post( + "20d-reader", + { intakeId: ev.snoozeMe, status: "taken" }, + reader, + ); + await post( + "20e-anonymous", + { intakeId: ev.snoozeMe, status: "taken" }, + { auth: anonymous }, + ); + }); }); }); diff --git a/tests/integration/routes/parity/medication-intake.parity.test.ts b/tests/integration/routes/parity/medication-intake.parity.test.ts index e87cf639b..e5ac70486 100644 --- a/tests/integration/routes/parity/medication-intake.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake.parity.test.ts @@ -343,189 +343,193 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/medications/{id}/intake parity", () => { - it("01 a take inside its band, onto the pending reminder row", async () => { - await post("01-take-in-band", { takenAt: "2026-10-15T08:30:00.000Z" }); - }); - - it("02 an as-needed take recorded standalone, and its repeat inside the dedup window", async () => { - await post( - "02a-take-standalone", - { takenAt: "2026-10-15T11:00:00.000Z" }, - { medicationId: med.asNeeded }, - ); - await post( - "02b-take-standalone-repeat", - { takenAt: "2026-10-15T11:00:00.000Z" }, - { medicationId: med.asNeeded }, - ); - }); - - it("03 a skip on a named slot, a slot-less skip, and a skip onto a standalone dose", async () => { - await post("03a-skip-named-slot", { - skipped: true, - scheduledFor: "2026-10-15T20:00:00.000Z", +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-intake parity walk", { shuffle: false }, () => { + describe("POST /api/medications/{id}/intake parity", () => { + it("01 a take inside its band, onto the pending reminder row", async () => { + await post("01-take-in-band", { takenAt: "2026-10-15T08:30:00.000Z" }); }); - await post("03b-skip-slotless", { skipped: true }); - await post( - "03c-delegate-skip-standalone-refused", - { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, - { ...writer, medicationId: med.asNeeded, settle: () => refusalRows(1) }, - ); - await post( - "03d-owner-skip-standalone", - { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, - { medicationId: med.asNeeded }, - ); - }); - it("04 a pinned take and the two pin refusals", async () => { - await post("04a-pin", { - takenAt: "2026-10-14T11:00:00.000Z", - forceSlotInstant: "2026-10-14T08:00:00.000Z", + it("02 an as-needed take recorded standalone, and its repeat inside the dedup window", async () => { + await post( + "02a-take-standalone", + { takenAt: "2026-10-15T11:00:00.000Z" }, + { medicationId: med.asNeeded }, + ); + await post( + "02b-take-standalone-repeat", + { takenAt: "2026-10-15T11:00:00.000Z" }, + { medicationId: med.asNeeded }, + ); }); - await post("04b-pin-not-a-slot", { - takenAt: "2026-10-14T16:00:00.000Z", - forceSlotInstant: "2026-10-14T15:00:00.000Z", + + it("03 a skip on a named slot, a slot-less skip, and a skip onto a standalone dose", async () => { + await post("03a-skip-named-slot", { + skipped: true, + scheduledFor: "2026-10-15T20:00:00.000Z", + }); + await post("03b-skip-slotless", { skipped: true }); + await post( + "03c-delegate-skip-standalone-refused", + { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, + { ...writer, medicationId: med.asNeeded, settle: () => refusalRows(1) }, + ); + await post( + "03d-owner-skip-standalone", + { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, + { medicationId: med.asNeeded }, + ); }); - await post("04c-pin-occupied", { - takenAt: "2026-10-14T23:00:00.000Z", - forceSlotInstant: "2026-10-14T20:00:00.000Z", + + it("04 a pinned take and the two pin refusals", async () => { + await post("04a-pin", { + takenAt: "2026-10-14T11:00:00.000Z", + forceSlotInstant: "2026-10-14T08:00:00.000Z", + }); + await post("04b-pin-not-a-slot", { + takenAt: "2026-10-14T16:00:00.000Z", + forceSlotInstant: "2026-10-14T15:00:00.000Z", + }); + await post("04c-pin-occupied", { + takenAt: "2026-10-14T23:00:00.000Z", + forceSlotInstant: "2026-10-14T20:00:00.000Z", + }); + }); + + it("05 a backfill onto a named earlier slot", async () => { + await post("05-named-backfill", { + takenAt: "2026-10-15T11:30:00.000Z", + scheduledFor: "2026-10-13T08:00:00.000Z", + }); + }); + + it("06 a body idempotency key and its replay", async () => { + const body = { + takenAt: "2026-10-13T20:30:00.000Z", + idempotencyKey: "dose-key-0001", + }; + await post("06a-body-key", body); + await post("06b-body-key-replay", body); + }); + + it("07 an Idempotency-Key header and its replay", async () => { + const headers = { + "idempotency-key": "4b7e1c9a-2d3f-4a5b-8c6d-7e8f9a0b1c2d", + }; + const body = { takenAt: "2026-10-12T08:15:00.000Z" }; + await post("07a-header-key", body, { headers }); + await post("07b-header-key-replay", body, { headers }); + }); + + it("08 a native take", async () => { + await post( + "08-native", + { takenAt: "2026-10-11T20:10:00.000Z" }, + { auth: ownerBearer }, + ); + }); + + it("09 a skip over a recorded dose, by the owner and by a delegate", async () => { + await post( + "09a-delegate-skip-refused", + { skipped: true, scheduledFor: "2026-10-14T20:00:00.000Z" }, + { ...writer, settle: () => refusalRows(2) }, + ); + await post("09b-owner-skip-flip", { + skipped: true, + scheduledFor: "2026-10-14T20:00:00.000Z", + }); + }); + + it("10 a delegate's take on an open slot, a reader, and nobody", async () => { + await post( + "10a-writer-take", + { takenAt: "2026-10-11T08:20:00.000Z" }, + writer, + ); + await post("10b-reader", { takenAt: "2026-10-10T08:20:00.000Z" }, reader); + await post( + "10c-anonymous", + { takenAt: "2026-10-10T08:20:00.000Z" }, + { auth: anonymous }, + ); + }); + + it("11 the injection-site refusal, and a site with a dose override", async () => { + await post( + "11a-site-not-allowed", + { takenAt: "2026-10-15T09:10:00.000Z", injectionSite: "ABDOMEN_RIGHT" }, + { medicationId: med.pen }, + ); + await post( + "11b-site-and-dose", + { + takenAt: "2026-10-15T09:10:00.000Z", + injectionSite: "ABDOMEN_LEFT", + doseTaken: "0.5 pen", + }, + { medicationId: med.pen }, + ); + }); + + it("12 a one-shot dose", async () => { + await post( + "12-one-shot", + { takenAt: "2026-10-15T10:00:00.000Z" }, + { medicationId: med.oneShot }, + ); + }); + + it("13 a body the schema refuses", async () => { + await post("13-invalid", { + takenAt: "yesterday", + idempotencyKey: "", + }); + }); + + it("14 another account's medication, and a record-only one", async () => { + await post( + "14a-not-found", + { takenAt: "2026-10-15T08:30:00.000Z" }, + { medicationId: med.foreign }, + ); + await post( + "14b-record-only", + { takenAt: "2026-10-15T07:10:00.000Z" }, + { medicationId: med.recordOnly }, + ); }); }); - it("05 a backfill onto a named earlier slot", async () => { - await post("05-named-backfill", { - takenAt: "2026-10-15T11:30:00.000Z", - scheduledFor: "2026-10-13T08:00:00.000Z", + describe("GET /api/medications/{id}/intake parity", () => { + it("21 the default list", async () => { + await list("21-default"); }); - }); - it("06 a body idempotency key and its replay", async () => { - const body = { - takenAt: "2026-10-13T20:30:00.000Z", - idempotencyKey: "dose-key-0001", - }; - await post("06a-body-key", body); - await post("06b-body-key-replay", body); - }); - - it("07 an Idempotency-Key header and its replay", async () => { - const headers = { - "idempotency-key": "4b7e1c9a-2d3f-4a5b-8c6d-7e8f9a0b1c2d", - }; - const body = { takenAt: "2026-10-12T08:15:00.000Z" }; - await post("07a-header-key", body, { headers }); - await post("07b-header-key-replay", body, { headers }); - }); - - it("08 a native take", async () => { - await post( - "08-native", - { takenAt: "2026-10-11T20:10:00.000Z" }, - { auth: ownerBearer }, - ); - }); - - it("09 a skip over a recorded dose, by the owner and by a delegate", async () => { - await post( - "09a-delegate-skip-refused", - { skipped: true, scheduledFor: "2026-10-14T20:00:00.000Z" }, - { ...writer, settle: () => refusalRows(2) }, - ); - await post("09b-owner-skip-flip", { - skipped: true, - scheduledFor: "2026-10-14T20:00:00.000Z", + it("22 a completed page sorted by take time", async () => { + await list("22-completed-page", { + status: "completed", + sortBy: "takenAt", + sortDir: "asc", + limit: "3", + offset: "1", + }); }); - }); - it("10 a delegate's take on an open slot, a reader, and nobody", async () => { - await post( - "10a-writer-take", - { takenAt: "2026-10-11T08:20:00.000Z" }, - writer, - ); - await post("10b-reader", { takenAt: "2026-10-10T08:20:00.000Z" }, reader); - await post( - "10c-anonymous", - { takenAt: "2026-10-10T08:20:00.000Z" }, - { auth: anonymous }, - ); - }); - - it("11 the injection-site refusal, and a site with a dose override", async () => { - await post( - "11a-site-not-allowed", - { takenAt: "2026-10-15T09:10:00.000Z", injectionSite: "ABDOMEN_RIGHT" }, - { medicationId: med.pen }, - ); - await post( - "11b-site-and-dose", - { - takenAt: "2026-10-15T09:10:00.000Z", - injectionSite: "ABDOMEN_LEFT", - doseTaken: "0.5 pen", - }, - { medicationId: med.pen }, - ); - }); - - it("12 a one-shot dose", async () => { - await post( - "12-one-shot", - { takenAt: "2026-10-15T10:00:00.000Z" }, - { medicationId: med.oneShot }, - ); - }); - - it("13 a body the schema refuses", async () => { - await post("13-invalid", { - takenAt: "yesterday", - idempotencyKey: "", + it("23 the skips, and the takes", async () => { + await list("23a-skipped", { status: "skipped" }); + await list("23b-taken", { status: "taken", sortDir: "asc" }); }); - }); - it("14 another account's medication, and a record-only one", async () => { - await post( - "14a-not-found", - { takenAt: "2026-10-15T08:30:00.000Z" }, - { medicationId: med.foreign }, - ); - await post( - "14b-record-only", - { takenAt: "2026-10-15T07:10:00.000Z" }, - { medicationId: med.recordOnly }, - ); - }); -}); - -describe("GET /api/medications/{id}/intake parity", () => { - it("21 the default list", async () => { - await list("21-default"); - }); - - it("22 a completed page sorted by take time", async () => { - await list("22-completed-page", { - status: "completed", - sortBy: "takenAt", - sortDir: "asc", - limit: "3", - offset: "1", + it("24 a query the schema refuses", async () => { + await list("24-invalid", { limit: "lots", status: "maybe" }); }); - }); - - it("23 the skips, and the takes", async () => { - await list("23a-skipped", { status: "skipped" }); - await list("23b-taken", { status: "taken", sortDir: "asc" }); - }); - it("24 a query the schema refuses", async () => { - await list("24-invalid", { limit: "lots", status: "maybe" }); - }); - - it("25 another account's medication, a reading delegate, and nobody", async () => { - await list("25a-not-found", {}, { medicationId: med.foreign }); - await list("25b-reader", {}, reader); - await list("25c-anonymous", {}, { auth: anonymous }); + it("25 another account's medication, a reading delegate, and nobody", async () => { + await list("25a-not-found", {}, { medicationId: med.foreign }); + await list("25b-reader", {}, reader); + await list("25c-anonymous", {}, { auth: anonymous }); + }); }); }); diff --git a/tests/integration/routes/parity/medications.parity.test.ts b/tests/integration/routes/parity/medications.parity.test.ts index 0e3b15478..84b671309 100644 --- a/tests/integration/routes/parity/medications.parity.test.ts +++ b/tests/integration/routes/parity/medications.parity.test.ts @@ -138,197 +138,204 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/medications parity", () => { - it("01 the owner's list", async () => { - await list("01-list"); - }); +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medications parity walk", { shuffle: false }, () => { + describe("GET /api/medications parity", () => { + it("01 the owner's list", async () => { + await list("01-list"); + }); - it("02 a reading delegate's view of it, and nobody's", async () => { - await list("02a-list-reader", reader); - await list("02b-list-anonymous", { auth: anonymous }); - }); -}); - -describe("POST /api/medications parity", () => { - it("11 a plain create", async () => { - await create("11-plain", { - name: "Plain tablet", - dose: "5mg", - schedules: [schedule("07:00", "09:00")], + it("02 a reading delegate's view of it, and nobody's", async () => { + await list("02a-list-reader", reader); + await list("02b-list-anonymous", { auth: anonymous }); }); }); - it("12 a full create with a custom category, a course and auto-take", async () => { - await create("12-full", { - name: "Full injection", - dose: "1 pen", - category: CUSTOM_KEY, - treatmentClass: "GLP1", - dosesPerUnit: 4, - unitsPerDose: 0.5, - reorderLeadDays: 7, - deliveryForm: "INJECTION", - trackInjectionSites: true, - allowedInjectionSites: ["ABDOMEN_LEFT", "THIGH_RIGHT"], - notificationsEnabled: false, - liveActivityEnabled: true, - criticalAlarmEnabled: true, - atcCode: "A10BJ06", - rxNormCode: "1991302", - startsOn: "2026-10-10", - endsOn: "2026-12-31", - autoMarkTaken: true, - schedules: [ - schedule("18:00", "20:00", { - label: "Evening", - dose: "0.5 pen", - unitsPerDose: 1, - timesOfDay: ["18:30"], - reminderGraceMinutes: 45, - rrule: "FREQ=WEEKLY;BYDAY=MO", - doseWindows: [{ timeOfDay: "18:30", start: "18:00", end: "20:00" }], - }), - ], + describe("POST /api/medications parity", () => { + it("11 a plain create", async () => { + await create("11-plain", { + name: "Plain tablet", + dose: "5mg", + schedules: [schedule("07:00", "09:00")], + }); }); - }); - it("13 an as-needed create, a one-shot one and a PRN schedule", async () => { - await create("13a-as-needed", { - name: "As needed", - dose: "400mg", - category: "PAIN_RELIEF", - asNeeded: true, + it("12 a full create with a custom category, a course and auto-take", async () => { + await create("12-full", { + name: "Full injection", + dose: "1 pen", + category: CUSTOM_KEY, + treatmentClass: "GLP1", + dosesPerUnit: 4, + unitsPerDose: 0.5, + reorderLeadDays: 7, + deliveryForm: "INJECTION", + trackInjectionSites: true, + allowedInjectionSites: ["ABDOMEN_LEFT", "THIGH_RIGHT"], + notificationsEnabled: false, + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "A10BJ06", + rxNormCode: "1991302", + startsOn: "2026-10-10", + endsOn: "2026-12-31", + autoMarkTaken: true, + schedules: [ + schedule("18:00", "20:00", { + label: "Evening", + dose: "0.5 pen", + unitsPerDose: 1, + timesOfDay: ["18:30"], + reminderGraceMinutes: 45, + rrule: "FREQ=WEEKLY;BYDAY=MO", + doseWindows: [{ timeOfDay: "18:30", start: "18:00", end: "20:00" }], + }), + ], + }); }); - await create("13b-one-shot", { - name: "One shot", - dose: "1 dose", - oneShot: true, - startsOn: "2026-10-20", - endsOn: "2026-11-20", - schedules: [schedule("09:00", "11:00")], - }); - await create("13c-prn-and-legacy-days", { - name: "Mixed schedules", - dose: "2mg", - trackIntake: false, - schedules: [ - schedule("06:00", "07:00", { scheduleType: "PRN" }), - schedule("21:00", "22:00", { daysOfWeek: [1, 3, 5], intervalWeeks: 2 }), - schedule("12:00", "13:00", { rollingIntervalDays: 3 }), - ], - }); - await create("13d-ended-window", { - name: "Ended window", - dose: "3mg", - endsOn: "2026-10-05", - schedules: [schedule("08:00", "09:00")], - }); - }); - it("14 the refusals", async () => { - await create("14a-unknown-category", { - name: "Unknown category", - dose: "1mg", - category: "custom:0a6c2d4e-1111-4222-8333-944455556666", - schedules: [schedule("08:00", "09:00")], + it("13 an as-needed create, a one-shot one and a PRN schedule", async () => { + await create("13a-as-needed", { + name: "As needed", + dose: "400mg", + category: "PAIN_RELIEF", + asNeeded: true, + }); + await create("13b-one-shot", { + name: "One shot", + dose: "1 dose", + oneShot: true, + startsOn: "2026-10-20", + endsOn: "2026-11-20", + schedules: [schedule("09:00", "11:00")], + }); + await create("13c-prn-and-legacy-days", { + name: "Mixed schedules", + dose: "2mg", + trackIntake: false, + schedules: [ + schedule("06:00", "07:00", { scheduleType: "PRN" }), + schedule("21:00", "22:00", { + daysOfWeek: [1, 3, 5], + intervalWeeks: 2, + }), + schedule("12:00", "13:00", { rollingIntervalDays: 3 }), + ], + }); + await create("13d-ended-window", { + name: "Ended window", + dose: "3mg", + endsOn: "2026-10-05", + schedules: [schedule("08:00", "09:00")], + }); }); - await create("14b-course-ends-before-start", { - name: "Backwards course", - dose: "1mg", - startsOn: "2026-10-20", - endsOn: "2026-10-10", - schedules: [schedule("08:00", "09:00")], - }); - await create("14c-one-shot-two-schedules", { - name: "One shot twice", - dose: "1mg", - oneShot: true, - startsOn: "2026-10-20", - schedules: [schedule("08:00", "09:00"), schedule("20:00", "21:00")], - }); - await create("14d-one-shot-recurrence", { - name: "One shot recurring", - dose: "1mg", - oneShot: true, - startsOn: "2026-10-20", - schedules: [schedule("08:00", "09:00", { rrule: "FREQ=DAILY" })], - }); - await create("14e-unstable-concept-id", { - name: "Phantom", - dose: "1mg", - externalSource: "APPLE_HEALTH", - externalId: "", - schedules: [schedule("08:00", "09:00")], - }); - await create("14f-invalid", { name: "", dose: 5, schedules: "daily" }); - }); - it("15 a mirrored create and its replay", async () => { - const mirrored = { - name: "Mirrored", - dose: "50mg", - externalSource: "APPLE_HEALTH", - externalId: "hk-concept-0001", - schedules: [schedule("08:00", "09:00")], - }; - await create("15a-mirror", mirrored); - await create("15b-mirror-replay", { ...mirrored, name: "Renamed" }); - }); - - it("16 the mirror cap", async () => { - const capped = await makeUser({ timezone: "UTC" }); - await prisma.medication.createMany({ - data: Array.from({ length: 30 }, (_, i) => ({ - userId: capped.id, - name: `Mirror ${String(i).padStart(2, "0")}`, + it("14 the refusals", async () => { + await create("14a-unknown-category", { + name: "Unknown category", dose: "1mg", - externalSource: "APPLE_HEALTH" as const, - externalId: `hk-cap-${String(i).padStart(2, "0")}`, - })), - }); - await create( - "16-mirror-cap", - { - name: "One too many", + category: "custom:0a6c2d4e-1111-4222-8333-944455556666", + schedules: [schedule("08:00", "09:00")], + }); + await create("14b-course-ends-before-start", { + name: "Backwards course", + dose: "1mg", + startsOn: "2026-10-20", + endsOn: "2026-10-10", + schedules: [schedule("08:00", "09:00")], + }); + await create("14c-one-shot-two-schedules", { + name: "One shot twice", + dose: "1mg", + oneShot: true, + startsOn: "2026-10-20", + schedules: [schedule("08:00", "09:00"), schedule("20:00", "21:00")], + }); + await create("14d-one-shot-recurrence", { + name: "One shot recurring", + dose: "1mg", + oneShot: true, + startsOn: "2026-10-20", + schedules: [schedule("08:00", "09:00", { rrule: "FREQ=DAILY" })], + }); + await create("14e-unstable-concept-id", { + name: "Phantom", dose: "1mg", externalSource: "APPLE_HEALTH", - externalId: "hk-cap-30", + externalId: "", schedules: [schedule("08:00", "09:00")], - }, - { auth: await makeSession(capped), userIds: [capped.id] }, - ); - }); + }); + await create("14f-invalid", { name: "", dose: 5, schedules: "daily" }); + }); - it("17 a retried create under one idempotency key", async () => { - const body = { - name: "Retried", - dose: "10mg", - schedules: [schedule("08:00", "09:00")], - }; - const headers = { - "idempotency-key": "6f9d2c4a-8b1e-4f3a-9d7c-5e2b1a0c9d8e", - }; - await create("17a-idempotent-first", body, { headers }); - await create("17b-idempotent-replay", body, { headers }); - }); + it("15 a mirrored create and its replay", async () => { + const mirrored = { + name: "Mirrored", + dose: "50mg", + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-0001", + schedules: [schedule("08:00", "09:00")], + }; + await create("15a-mirror", mirrored); + await create("15b-mirror-replay", { ...mirrored, name: "Renamed" }); + }); - it("18 a delegate who may write, and one who may only read", async () => { - const body = { - name: "Brought by a delegate", - dose: "20mg", - schedules: [schedule("08:00", "09:00")], - }; - await create("18a-writer", body, writer); - await create("18b-reader", body, reader); - }); + it("16 the mirror cap", async () => { + const capped = await makeUser({ timezone: "UTC" }); + await prisma.medication.createMany({ + data: Array.from({ length: 30 }, (_, i) => ({ + userId: capped.id, + name: `Mirror ${String(i).padStart(2, "0")}`, + dose: "1mg", + externalSource: "APPLE_HEALTH" as const, + externalId: `hk-cap-${String(i).padStart(2, "0")}`, + })), + }); + await create( + "16-mirror-cap", + { + name: "One too many", + dose: "1mg", + externalSource: "APPLE_HEALTH", + externalId: "hk-cap-30", + schedules: [schedule("08:00", "09:00")], + }, + { auth: await makeSession(capped), userIds: [capped.id] }, + ); + }); - it("19 no credentials", async () => { - await create( - "19-anonymous", - { name: "Nobody", dose: "1mg" }, - { - auth: anonymous, - }, - ); + it("17 a retried create under one idempotency key", async () => { + const body = { + name: "Retried", + dose: "10mg", + schedules: [schedule("08:00", "09:00")], + }; + const headers = { + "idempotency-key": "6f9d2c4a-8b1e-4f3a-9d7c-5e2b1a0c9d8e", + }; + await create("17a-idempotent-first", body, { headers }); + await create("17b-idempotent-replay", body, { headers }); + }); + + it("18 a delegate who may write, and one who may only read", async () => { + const body = { + name: "Brought by a delegate", + dose: "20mg", + schedules: [schedule("08:00", "09:00")], + }; + await create("18a-writer", body, writer); + await create("18b-reader", body, reader); + }); + + it("19 no credentials", async () => { + await create( + "19-anonymous", + { name: "Nobody", dose: "1mg" }, + { + auth: anonymous, + }, + ); + }); }); }); diff --git a/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts b/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts index 6c4a79519..6f58473a1 100644 --- a/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts +++ b/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts @@ -214,7 +214,7 @@ afterAll(async () => { await prisma.moodTag.delete({ where: { id: SHORT_FACTOR_ID } }); }); -describe("POST /api/mood-entries/bulk parity", () => { +describe("POST /api/mood-entries/bulk parity", { shuffle: false }, () => { it("01 a first drain", async () => { await step("01-first-drain", { entries: firstDrain }); }); diff --git a/tests/integration/routes/parity/oidc-callback.parity.test.ts b/tests/integration/routes/parity/oidc-callback.parity.test.ts index 661d78770..c49bc1a85 100644 --- a/tests/integration/routes/parity/oidc-callback.parity.test.ts +++ b/tests/integration/routes/parity/oidc-callback.parity.test.ts @@ -270,7 +270,7 @@ afterAll(() => { } }); -describe("GET /api/auth/oidc/callback parity", () => { +describe("GET /api/auth/oidc/callback parity", { shuffle: false }, () => { it("01 SSO switched off", async () => { await step("01-disabled", { state: {} }); process.env.OIDC_ISSUER_URL = ISSUER; diff --git a/tests/integration/routes/parity/sync-changes.parity.test.ts b/tests/integration/routes/parity/sync-changes.parity.test.ts index d4d092ff9..c1c39b39e 100644 --- a/tests/integration/routes/parity/sync-changes.parity.test.ts +++ b/tests/integration/routes/parity/sync-changes.parity.test.ts @@ -298,7 +298,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/sync/changes parity", () => { +describe("GET /api/sync/changes parity", { shuffle: false }, () => { let firstCursor: string; it("drains the feed in small pages to the end", async () => { diff --git a/tests/integration/routes/parity/tokens-mirror.parity.test.ts b/tests/integration/routes/parity/tokens-mirror.parity.test.ts index 141c811ec..5d69444d7 100644 --- a/tests/integration/routes/parity/tokens-mirror.parity.test.ts +++ b/tests/integration/routes/parity/tokens-mirror.parity.test.ts @@ -176,63 +176,67 @@ beforeAll(async () => { }); }); -describe("the Apple Health write-back of a measurement token", () => { - it("walks the cases in order", async () => { - await step("01-summary", { kind: "summary" }); - await step("02-on", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: true }, +describe( + "the Apple Health write-back of a measurement token", + { shuffle: false }, + () => { + it("walks the cases in order", async () => { + await step("01-summary", { kind: "summary" }); + await step("02-on", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("03-on-again", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("04-list", { kind: "list" }); + const revoked = await callRoute(revoke, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: scale.tokenId }, + auth: owner, + }); + expect(revoked.status).toBe(200); + await step("05-off-revoked", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: false }, + }); + await step("06-wildcard", { + kind: "patch", + id: wildcard.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("07-connector", { + kind: "patch", + id: connector.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("08-another-account", { + kind: "patch", + id: theirs.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("09-unknown", { + kind: "patch", + id: "missing-token", + body: { mirrorToAppleHealth: true }, + }); + await step("10-invalid-body", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: "yes", name: "Renamed" }, + }); + await step("11-bearer", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + auth: wildcard, + }); + await step("12-summary-bearer", { kind: "summary", auth: wildcard }); }); - await step("03-on-again", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("04-list", { kind: "list" }); - const revoked = await callRoute(revoke, { - method: "DELETE", - route: "/api/tokens/{id}", - params: { id: scale.tokenId }, - auth: owner, - }); - expect(revoked.status).toBe(200); - await step("05-off-revoked", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: false }, - }); - await step("06-wildcard", { - kind: "patch", - id: wildcard.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("07-connector", { - kind: "patch", - id: connector.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("08-another-account", { - kind: "patch", - id: theirs.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("09-unknown", { - kind: "patch", - id: "missing-token", - body: { mirrorToAppleHealth: true }, - }); - await step("10-invalid-body", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: "yes", name: "Renamed" }, - }); - await step("11-bearer", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: true }, - auth: wildcard, - }); - await step("12-summary-bearer", { kind: "summary", auth: wildcard }); - }); -}); + }, +); diff --git a/tests/integration/routes/parity/workout-detail.parity.test.ts b/tests/integration/routes/parity/workout-detail.parity.test.ts index 3b5e8debf..bb4ee814b 100644 --- a/tests/integration/routes/parity/workout-detail.parity.test.ts +++ b/tests/integration/routes/parity/workout-detail.parity.test.ts @@ -248,64 +248,68 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/workouts/{id} parity", () => { - it("the detail reads", async () => { - await step("get-01-canonical", { method: "GET", id: ids.run }); - await step("get-02-compact", { - method: "GET", - id: ids.run, - query: { compact: "1" }, +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("workout-detail parity walk", { shuffle: false }, () => { + describe("GET /api/workouts/{id} parity", () => { + it("the detail reads", async () => { + await step("get-01-canonical", { method: "GET", id: ids.run }); + await step("get-02-compact", { + method: "GET", + id: ids.run, + query: { compact: "1" }, + }); + await step("get-03-hidden-twin", { method: "GET", id: ids.twin }); + await step("get-04-ride", { method: "GET", id: ids.ride }); + await step("get-05-other-account", { + method: "GET", + id: ids.run, + auth: stranger, + }); + await step("get-06-missing", { + method: "GET", + id: "cmissingworkout000000000", + }); }); - await step("get-03-hidden-twin", { method: "GET", id: ids.twin }); - await step("get-04-ride", { method: "GET", id: ids.ride }); - await step("get-05-other-account", { - method: "GET", - id: ids.run, - auth: stranger, - }); - await step("get-06-missing", { - method: "GET", - id: "cmissingworkout000000000", + + it("the module switched off", async () => { + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { workouts: false } }, + }); + await step("get-07-module-off", { method: "GET", id: ids.run }); + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: {} }, + }); }); }); - it("the module switched off", async () => { - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: { workouts: false } }, + describe("DELETE /api/workouts/{id} parity", () => { + it("a synced workout, a missing one, another account's", async () => { + await step("delete-01-synced", { + method: "DELETE", + id: ids.ride, + readBack: true, + }); + await step("delete-02-missing", { + method: "DELETE", + id: "cmissingworkout000000000", + }); + await step("delete-03-other-account", { + method: "DELETE", + id: ids.walk, + auth: stranger, + readBack: true, + }); }); - await step("get-07-module-off", { method: "GET", id: ids.run }); - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: {} }, - }); - }); -}); -describe("DELETE /api/workouts/{id} parity", () => { - it("a synced workout, a missing one, another account's", async () => { - await step("delete-01-synced", { - method: "DELETE", - id: ids.ride, - readBack: true, - }); - await step("delete-02-missing", { - method: "DELETE", - id: "cmissingworkout000000000", - }); - await step("delete-03-other-account", { - method: "DELETE", - id: ids.walk, - auth: stranger, - readBack: true, - }); - }); - - it("a hand-entered workout and the record it set", async () => { - await step("delete-04-manual", { - method: "DELETE", - id: ids.walk, - readBack: true, + it("a hand-entered workout and the record it set", async () => { + await step("delete-04-manual", { + method: "DELETE", + id: ids.walk, + readBack: true, + }); }); }); }); diff --git a/tests/integration/routes/parity/workouts-batch.parity.test.ts b/tests/integration/routes/parity/workouts-batch.parity.test.ts index 66b2ff8b6..ae4e77588 100644 --- a/tests/integration/routes/parity/workouts-batch.parity.test.ts +++ b/tests/integration/routes/parity/workouts-batch.parity.test.ts @@ -238,7 +238,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/workouts/batch parity", () => { +describe("POST /api/workouts/batch parity", { shuffle: false }, () => { it("01 a first batch", async () => { await step("01-first-batch", { workouts: FIRST_BATCH }); }); From d7046c97376ef195675fbbf7b8432f8e729f4a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:35:57 +0100 Subject: [PATCH 05/10] test(integration): keep the stateful walks in order, give the rest their own rows The migration before-and-after files, the dump directory, the geo backfill's three passes, the settings trip, the backup parity steps, the lab conversion record and the pulse method's dry run before its rebuild are each one sequence by design; they now say so and keep their order with shuffle: false. The CSV import's zone and crash cases wrote into the account the lab and medication walks count, and now use accounts of their own. --- tests/integration/backup-parity.test.ts | 5 +- tests/integration/database-backup.test.ts | 5 +- .../integration/geo-backfill-attempt.test.ts | 5 +- .../healthkit-type-sync-migration.test.ts | 151 +++--- .../hrv-rmssd-fitbit-google-migration.test.ts | 4 +- .../integration/labs-unit-conversion.test.ts | 449 +++++++++--------- .../integration/pulse-day-mean-method.test.ts | 394 +++++++-------- tests/integration/record-csv-import.test.ts | 31 +- tests/integration/settings-round-trip.test.ts | 346 +++++++------- 9 files changed, 728 insertions(+), 662 deletions(-) diff --git a/tests/integration/backup-parity.test.ts b/tests/integration/backup-parity.test.ts index 3e83e614c..e8b8d8484 100644 --- a/tests/integration/backup-parity.test.ts +++ b/tests/integration/backup-parity.test.ts @@ -1113,7 +1113,10 @@ async function recreateOwner(prisma: PrismaClient) { // ── The cases ──────────────────────────────────────────────────────────────── -describe("backup parity", () => { +// One account through the numbered steps above, in order: each restore and +// export reads what the step before it left. The order holds under a +// shuffled run. +describe("backup parity", { shuffle: false }, () => { const prisma = getPrismaClient(); let adminId = ""; let drJson = ""; diff --git a/tests/integration/database-backup.test.ts b/tests/integration/database-backup.test.ts index e82f4965f..8e3ec5b76 100644 --- a/tests/integration/database-backup.test.ts +++ b/tests/integration/database-backup.test.ts @@ -147,7 +147,10 @@ afterAll(() => { rmSync(work, { recursive: true, force: true }); }); -describe("the nightly database dump", () => { +// One backup directory through the cases in order: the first dump is the one +// the retention, failure and restore cases count and restore. The order holds +// under a shuffled run. +describe("the nightly database dump", { shuffle: false }, () => { let dumpFile = ""; it("writes a checked dump and manifest, records the run and copies both off-host", async () => { diff --git a/tests/integration/geo-backfill-attempt.test.ts b/tests/integration/geo-backfill-attempt.test.ts index 8c71bf735..f2899238d 100644 --- a/tests/integration/geo-backfill-attempt.test.ts +++ b/tests/integration/geo-backfill-attempt.test.ts @@ -71,7 +71,10 @@ beforeAll(async () => { await prisma.$executeRawUnsafe("ANALYZE audit_logs"); }, 120_000); -describe("runGeoBackfill", () => { +// Three passes in order: the second must find what the first marked, the +// third runs past the wait the first one set. The order holds under a +// shuffled run. +describe("runGeoBackfill", { shuffle: false }, () => { it("reads its candidates through the partial index", async () => { const before = await indexScans(); const summary = await runGeoBackfill(prisma, NOW); diff --git a/tests/integration/healthkit-type-sync-migration.test.ts b/tests/integration/healthkit-type-sync-migration.test.ts index 5876d2370..3d6e14c4c 100644 --- a/tests/integration/healthkit-type-sync-migration.test.ts +++ b/tests/integration/healthkit-type-sync-migration.test.ts @@ -106,84 +106,93 @@ afterAll(async () => { if (column.udt_name === "measurement_type") await runMigration(); }); -describe("migration 0401 over enum-typed ledger rows", () => { - it("starts from the enum column", async () => { - expect((await typeColumn()).udt_name).toBe("measurement_type"); - }); +// Before the migration, the migration, after it: the cases are its steps, +// in order, under a shuffled run too. +describe( + "migration 0401 over enum-typed ledger rows", + { shuffle: false }, + () => { + it("starts from the enum column", async () => { + expect((await typeColumn()).udt_name).toBe("measurement_type"); + }); - it("keeps every row, its key and its dates, and seeds the background date", async () => { - await runMigration(); + it("keeps every row, its key and its dates, and seeds the background date", async () => { + await runMigration(); - expect(await typeColumn()).toEqual({ data_type: "text", udt_name: "text" }); - const rows = await readRows(); - expect(rows.map((r) => r.type)).toEqual([ - "PULSE", - "RESPIRATORY_RATE", - "WEIGHT", - ]); - const byType = Object.fromEntries(rows.map((r) => [r.type, r])); - for (const [type, at] of Object.entries(RECEIVED)) { - expect(byType[type].last_received_at.toISOString()).toBe( - `${at.replace(" ", "T")}.000Z`, + expect(await typeColumn()).toEqual({ + data_type: "text", + udt_name: "text", + }); + const rows = await readRows(); + expect(rows.map((r) => r.type)).toEqual([ + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const byType = Object.fromEntries(rows.map((r) => [r.type, r])); + for (const [type, at] of Object.entries(RECEIVED)) { + expect(byType[type].last_received_at.toISOString()).toBe( + `${at.replace(" ", "T")}.000Z`, + ); + } + expect(byType.WEIGHT.last_trigger).toBe("background"); + expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", ); - } - expect(byType.WEIGHT.last_trigger).toBe("background"); - expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe( - "2026-09-01T06:00:00.000Z", - ); - expect(byType.PULSE.last_new_sample_at).toBeNull(); - // Only a row whose latest arrival was a background one knows a date. - expect(byType.WEIGHT.last_background_at?.toISOString()).toBe( - "2026-09-01T06:00:00.000Z", - ); - expect(byType.PULSE.last_background_at).toBeNull(); - expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull(); - }); + expect(byType.PULSE.last_new_sample_at).toBeNull(); + // Only a row whose latest arrival was a background one knows a date. + expect(byType.WEIGHT.last_background_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + expect(byType.PULSE.last_background_at).toBeNull(); + expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull(); + }); - it("keeps the primary key on (user, type)", async () => { - const pkey = await prisma.$queryRaw>` + it("keeps the primary key on (user, type)", async () => { + const pkey = await prisma.$queryRaw>` SELECT indexdef FROM pg_indexes WHERE tablename = 'healthkit_type_syncs' AND indexname = 'healthkit_type_syncs_pkey' `; - expect(pkey[0]?.indexdef).toContain("(user_id, type)"); - await expect( - prisma.$executeRawUnsafe( - `INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`, - ), - ).rejects.toThrow(); - }); + expect(pkey[0]?.indexdef).toContain("(user_id, type)"); + await expect( + prisma.$executeRawUnsafe( + `INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`, + ), + ).rejects.toThrow(); + }); - it("lets the writer upsert a measurement type and record a dose", async () => { - const at = new Date("2026-10-01T09:00:00.000Z"); - await recordHealthKitTypeArrivals(prisma, { - userId: USER, - counts: new Map([ - ["WEIGHT", { accepted: 1, newSamples: 1 }], - ["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }], - ]), - trigger: "foreground", - at, + it("lets the writer upsert a measurement type and record a dose", async () => { + const at = new Date("2026-10-01T09:00:00.000Z"); + await recordHealthKitTypeArrivals(prisma, { + userId: USER, + counts: new Map([ + ["WEIGHT", { accepted: 1, newSamples: 1 }], + ["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }], + ]), + trigger: "foreground", + at, + }); + const rows = await prisma.healthKitTypeSync.findMany({ + where: { userId: USER }, + orderBy: { type: "asc" }, + }); + expect(rows.map((r) => r.type)).toEqual([ + "MEDICATION_DOSE", + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const weight = rows.find((r) => r.type === "WEIGHT")!; + expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString()); + expect(weight.lastTrigger).toBe("foreground"); + // The foreground arrival leaves the seeded background date standing. + expect(weight.lastBackgroundAt?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!; + expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString()); + expect(dose.lastBackgroundAt).toBeNull(); }); - const rows = await prisma.healthKitTypeSync.findMany({ - where: { userId: USER }, - orderBy: { type: "asc" }, - }); - expect(rows.map((r) => r.type)).toEqual([ - "MEDICATION_DOSE", - "PULSE", - "RESPIRATORY_RATE", - "WEIGHT", - ]); - const weight = rows.find((r) => r.type === "WEIGHT")!; - expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString()); - expect(weight.lastTrigger).toBe("foreground"); - // The foreground arrival leaves the seeded background date standing. - expect(weight.lastBackgroundAt?.toISOString()).toBe( - "2026-09-01T06:00:00.000Z", - ); - const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!; - expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString()); - expect(dose.lastBackgroundAt).toBeNull(); - }); -}); + }, +); diff --git a/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts b/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts index cbbadf508..3a1a06e40 100644 --- a/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts +++ b/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts @@ -180,7 +180,9 @@ beforeAll(async () => { await runMigration(); }, 120_000); -describe("migration 0420", () => { +// The migration's effects, then the refold and the rerun that change them: +// the cases read in order, under a shuffled run too. +describe("migration 0420", { shuffle: false }, () => { it("moves every Fitbit and Google Health SDNN row to HRV_RMSSD, tombstones included", async () => { const moved = await prisma.measurement.findMany({ where: { userId: USER, source: { in: ["FITBIT", "GOOGLE_HEALTH"] } }, diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts index 89af81ce8..a5b06f235 100644 --- a/tests/integration/labs-unit-conversion.test.ts +++ b/tests/integration/labs-unit-conversion.test.ts @@ -130,244 +130,251 @@ type Dto = { rangeStatus: string; }; -describe("POST /api/labs", () => { - it("converts a reading in another unit and keeps what the report printed", async () => { - const { status, json } = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 5.6, - unit: "mmol/L", - sourceReferenceLow: 3.9, - sourceReferenceHigh: 6.0, - takenAt: "2026-05-01T08:00:00.000Z", - }); - expect(status).toBe(201); - const dto = json.data!; - expect(dto.converted).toBe(true); - expect(dto.unit).toBe("mg/dL"); - expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); - expect(dto.sourceValue).toBe(5.6); - expect(dto.sourceUnit).toBe("mmol/L"); - // The report's own window moved with the value. - expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); - expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9); - // And the verdict is judged against it: 5.6 of 3.9-6.0 is in range. - expect(dto.rangeStatus).toBe("in-range"); +// One record through the cases in order: the OCR commit is committed again +// as a duplicate, and the edits and the list read the readings the earlier +// cases wrote. The order holds under a shuffled run. +describe("lab unit conversion, one record", { shuffle: false }, () => { + describe("POST /api/labs", () => { + it("converts a reading in another unit and keeps what the report printed", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-01T08:00:00.000Z", + }); + expect(status).toBe(201); + const dto = json.data!; + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(dto.sourceValue).toBe(5.6); + expect(dto.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9); + // And the verdict is judged against it: 5.6 of 3.9-6.0 is in range. + expect(dto.rangeStatus).toBe("in-range"); - const row = await prisma.labResult.findUniqueOrThrow({ - where: { id: dto.id }, + const row = await prisma.labResult.findUniqueOrThrow({ + where: { id: dto.id }, + }); + expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(row.sourceValue).toBe(5.6); + expect(row.sourceUnit).toBe("mmol/L"); + }); + + it("flags a converted reading outside the printed window as such", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 7.2, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-02T08:00:00.000Z", + }); + expect(json.data!.rangeStatus).toBe("above"); + }); + + it("stores a reading in the marker's unit as stated, with no conversion record", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 95, + unit: "mg/dL", + takenAt: "2026-05-03T08:00:00.000Z", + }); + const dto = json.data!; + expect(dto.converted).toBe(false); + expect(dto.value).toBe(95); + expect(dto.sourceValue).toBeNull(); + expect(dto.sourceUnit).toBeNull(); + }); + + it("links a marker that had no key yet, the first time it is needed", async () => { + const before = await prisma.biomarker.findUniqueOrThrow({ + where: { id: "bm-glucose" }, + }); + // The marker was created without a key in this test; the first conversion + // above has already filled it. + expect(before.analyteKey).toBe("fasting-glucose"); + }); + + it("still refuses a pair that cannot be converted, now with the reason", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-free", + value: 5.6, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(status).toBe(422); + expect(json.meta).toMatchObject({ + errorCode: "labs.unit.mismatch", + markerUnit: "mg/dL", + readingUnit: "mmol/L", + reason: "unknown_analyte", + }); + expect( + await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }), + ).toBe(0); }); - expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); - expect(row.sourceValue).toBe(5.6); - expect(row.sourceUnit).toBe("mmol/L"); }); - it("flags a converted reading outside the printed window as such", async () => { - const { json } = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 7.2, - unit: "mmol/L", - sourceReferenceLow: 3.9, - sourceReferenceHigh: 6.0, - takenAt: "2026-05-02T08:00:00.000Z", - }); - expect(json.data!.rangeStatus).toBe("above"); - }); - - it("stores a reading in the marker's unit as stated, with no conversion record", async () => { - const { json } = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 95, - unit: "mg/dL", - takenAt: "2026-05-03T08:00:00.000Z", - }); - const dto = json.data!; - expect(dto.converted).toBe(false); - expect(dto.value).toBe(95); - expect(dto.sourceValue).toBeNull(); - expect(dto.sourceUnit).toBeNull(); - }); - - it("links a marker that had no key yet, the first time it is needed", async () => { - const before = await prisma.biomarker.findUniqueOrThrow({ - where: { id: "bm-glucose" }, - }); - // The marker was created without a key in this test; the first conversion - // above has already filled it. - expect(before.analyteKey).toBe("fasting-glucose"); - }); - - it("still refuses a pair that cannot be converted, now with the reason", async () => { - const { status, json } = await call("labs", "POST", { - biomarkerId: "bm-free", - value: 5.6, - unit: "mmol/L", - takenAt: "2026-05-04T08:00:00.000Z", - }); - expect(status).toBe(422); - expect(json.meta).toMatchObject({ - errorCode: "labs.unit.mismatch", - markerUnit: "mg/dL", - readingUnit: "mmol/L", - reason: "unknown_analyte", - }); - expect( - await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }), - ).toBe(0); - }); -}); - -describe("the OCR commit", () => { - const row = { - analyte: "Glucose", - value: 5.0, - unit: "mmol/L", - referenceText: "3.9 - 5.6", - takenAt: "2026-06-01T08:00:00.000Z", - }; - - it("converts the row and reports it", async () => { - const { status, json } = await call<{ - inserted: Dto[]; - skipped: unknown[]; - }>("ocr", "POST", { rows: [row] }); - expect(status).toBe(200); - expect(json.data!.skipped).toEqual([]); - const [inserted] = json.data!.inserted; - expect(inserted.converted).toBe(true); - expect(inserted.unit).toBe("mg/dL"); - expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9); - expect(inserted.sourceValue).toBe(5.0); - expect(inserted.sourceUnit).toBe("mmol/L"); - expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); - expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); - }); - - it("treats the same scan committed again as a duplicate", async () => { - const { json } = await call<{ - inserted: unknown[]; - skipped: Array<{ reason: string }>; - }>("ocr", "POST", { rows: [row] }); - expect(json.data!.inserted).toEqual([]); - expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); - }); - - it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => { - const { annotateRow } = await import("@/lib/labs/ocr-extract"); - const scanned = { + describe("the OCR commit", () => { + const row = { analyte: "Glucose", value: 5.0, - valueText: null, unit: "mmol/L", - referenceLow: null, - referenceHigh: null, - referenceText: null, - takenAt: "2026-06-01", - confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + referenceText: "3.9 - 5.6", + takenAt: "2026-06-01T08:00:00.000Z", }; - const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC"); - expect(annotated).toMatchObject({ - biomarkerMatch: "existing", - markerUnit: "mg/dL", - markerAnalyteKey: "fasting-glucose", + + it("converts the row and reports it", async () => { + const { status, json } = await call<{ + inserted: Dto[]; + skipped: unknown[]; + }>("ocr", "POST", { rows: [row] }); + expect(status).toBe(200); + expect(json.data!.skipped).toEqual([]); + const [inserted] = json.data!.inserted; + expect(inserted.converted).toBe(true); + expect(inserted.unit).toBe("mg/dL"); + expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9); + expect(inserted.sourceValue).toBe(5.0); + expect(inserted.sourceUnit).toBe("mmol/L"); + expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); }); - // The converted reading from the commit above holds 90.09 mg/dL; the scan - // says 5.0 mmol/L, which is what the report printed for it. - expect(annotated.duplicateOf).not.toBeNull(); - // A different value is not a duplicate. - const other = await annotateRow( - USER, - { ...scanned, value: 6.5 }, - "2026-06-01", - "UTC", - ); - expect(other.duplicateOf).toBeNull(); + it("treats the same scan committed again as a duplicate", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { rows: [row] }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); + }); - // A marker the registry knows nothing about reports no analyte. - const free = await annotateRow( - USER, - { ...scanned, analyte: "My own marker", unit: "mg/dL" }, - "2026-06-01", - "UTC", - ); - expect(free.markerAnalyteKey).toBeNull(); + it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => { + const { annotateRow } = await import("@/lib/labs/ocr-extract"); + const scanned = { + analyte: "Glucose", + value: 5.0, + valueText: null, + unit: "mmol/L", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-01", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + }; + const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC"); + expect(annotated).toMatchObject({ + biomarkerMatch: "existing", + markerUnit: "mg/dL", + markerAnalyteKey: "fasting-glucose", + }); + // The converted reading from the commit above holds 90.09 mg/dL; the scan + // says 5.0 mmol/L, which is what the report printed for it. + expect(annotated.duplicateOf).not.toBeNull(); + + // A different value is not a duplicate. + const other = await annotateRow( + USER, + { ...scanned, value: 6.5 }, + "2026-06-01", + "UTC", + ); + expect(other.duplicateOf).toBeNull(); + + // A marker the registry knows nothing about reports no analyte. + const free = await annotateRow( + USER, + { ...scanned, analyte: "My own marker", unit: "mg/dL" }, + "2026-06-01", + "UTC", + ); + expect(free.markerAnalyteKey).toBeNull(); + }); + + it("skips a row whose unit cannot be converted, as before", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { + rows: [ + { + analyte: "My own marker", + value: 3, + unit: "mmol/L", + takenAt: "2026-06-02T08:00:00.000Z", + }, + ], + }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual([ + "unit_mismatch", + ]); + }); }); - it("skips a row whose unit cannot be converted, as before", async () => { - const { json } = await call<{ - inserted: unknown[]; - skipped: Array<{ reason: string }>; - }>("ocr", "POST", { - rows: [ + describe("editing and reading back", () => { + it("clears the conversion record when the value is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.0, + unit: "mmol/L", + takenAt: "2026-07-01T08:00:00.000Z", + }); + const id = created.json.data!.id; + expect(created.json.data!.converted).toBe(true); + + const edited = await call("one", "PUT", { value: 110 }, id); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(110); + expect(edited.json.data!.converted).toBe(false); + expect(edited.json.data!.sourceValue).toBeNull(); + expect(edited.json.data!.sourceUnit).toBeNull(); + }); + + it("keeps the conversion record when only the note is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.1, + unit: "mmol/L", + takenAt: "2026-07-02T08:00:00.000Z", + }); + const id = created.json.data!.id; + const storedValue = created.json.data!.value; + // The web edit dialog sends the form back whole, the unchanged value + // included; only a changed value clears the record. + const edited = await call( + "one", + "PUT", { - analyte: "My own marker", - value: 3, - unit: "mmol/L", - takenAt: "2026-06-02T08:00:00.000Z", + value: storedValue, + takenAt: "2026-07-02T09:00:00.000Z", + note: "fasting", }, - ], + id, + ); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(storedValue); + expect(edited.json.data!.converted).toBe(true); + expect(edited.json.data!.sourceValue).toBe(6.1); + expect(edited.json.data!.sourceUnit).toBe("mmol/L"); }); - expect(json.data!.inserted).toEqual([]); - expect(json.data!.skipped.map((s) => s.reason)).toEqual(["unit_mismatch"]); - }); -}); -describe("editing and reading back", () => { - it("clears the conversion record when the value is edited", async () => { - const created = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 6.0, - unit: "mmol/L", - takenAt: "2026-07-01T08:00:00.000Z", + it("returns the fields on the list and on the single reading", async () => { + const list = await call<{ results: Dto[] }>("labs", "GET"); + const converted = list.json.data!.results.filter((r) => r.converted); + expect(converted.length).toBeGreaterThan(0); + for (const r of converted) { + expect(r.sourceValue).not.toBeNull(); + expect(r.sourceUnit).not.toBeNull(); + } + const one = await call("one", "GET", undefined, converted[0].id); + expect(one.json.data!.converted).toBe(true); }); - const id = created.json.data!.id; - expect(created.json.data!.converted).toBe(true); - - const edited = await call("one", "PUT", { value: 110 }, id); - expect(edited.status).toBe(200); - expect(edited.json.data!.value).toBe(110); - expect(edited.json.data!.converted).toBe(false); - expect(edited.json.data!.sourceValue).toBeNull(); - expect(edited.json.data!.sourceUnit).toBeNull(); - }); - - it("keeps the conversion record when only the note is edited", async () => { - const created = await call("labs", "POST", { - biomarkerId: "bm-glucose", - value: 6.1, - unit: "mmol/L", - takenAt: "2026-07-02T08:00:00.000Z", - }); - const id = created.json.data!.id; - const storedValue = created.json.data!.value; - // The web edit dialog sends the form back whole, the unchanged value - // included; only a changed value clears the record. - const edited = await call( - "one", - "PUT", - { - value: storedValue, - takenAt: "2026-07-02T09:00:00.000Z", - note: "fasting", - }, - id, - ); - expect(edited.status).toBe(200); - expect(edited.json.data!.value).toBe(storedValue); - expect(edited.json.data!.converted).toBe(true); - expect(edited.json.data!.sourceValue).toBe(6.1); - expect(edited.json.data!.sourceUnit).toBe("mmol/L"); - }); - - it("returns the fields on the list and on the single reading", async () => { - const list = await call<{ results: Dto[] }>("labs", "GET"); - const converted = list.json.data!.results.filter((r) => r.converted); - expect(converted.length).toBeGreaterThan(0); - for (const r of converted) { - expect(r.sourceValue).not.toBeNull(); - expect(r.sourceUnit).not.toBeNull(); - } - const one = await call("one", "GET", undefined, converted[0].id); - expect(one.json.data!.converted).toBe(true); }); }); diff --git a/tests/integration/pulse-day-mean-method.test.ts b/tests/integration/pulse-day-mean-method.test.ts index 974d8c397..30bab5bfb 100644 --- a/tests/integration/pulse-day-mean-method.test.ts +++ b/tests/integration/pulse-day-mean-method.test.ts @@ -211,27 +211,32 @@ async function readersOfD1(method: PulseDayMeanMethod) { }; } -describe("every reader follows the active method", () => { - it("weighs every hour alike under hour_mean_v1", async () => { - const seen = await readersOfD1("hour_mean_v1"); - for (const [reader, value] of Object.entries(seen)) { - expect(value, reader).toBeCloseTo(V1, 6); - } +// One instance through the cases in order: the readers, the dry run, the +// rebuild after apply (which reads the dry run's record) and the large +// account last, whose rows every earlier count leaves out. The order holds +// under a shuffled run. +describe("the pulse day-mean method, one instance", { shuffle: false }, () => { + describe("every reader follows the active method", () => { + it("weighs every hour alike under hour_mean_v1", async () => { + const seen = await readersOfD1("hour_mean_v1"); + for (const [reader, value] of Object.entries(seen)) { + expect(value, reader).toBeCloseTo(V1, 6); + } + }); + + it("weighs the lone reading's hour a third under weighted_hour_mean_v2", async () => { + const seen = await readersOfD1("weighted_hour_mean_v2"); + for (const [reader, value] of Object.entries(seen)) { + expect(value, reader).toBeCloseTo(V2, 6); + } + expect(Number(V2.toFixed(1))).toBe(60.9); + setActivePulseDayMeanMethod("hour_mean_v1"); + }); }); - it("weighs the lone reading's hour a third under weighted_hour_mean_v2", async () => { - const seen = await readersOfD1("weighted_hour_mean_v2"); - for (const [reader, value] of Object.entries(seen)) { - expect(value, reader).toBeCloseTo(V2, 6); - } - expect(Number(V2.toFixed(1))).toBe(60.9); - setActivePulseDayMeanMethod("hour_mean_v1"); - }); -}); - -/** Every table except the settings row, as one digest per table. */ -async function snapshotTables(): Promise> { - const tables = await prisma.$queryRaw>` + /** Every table except the settings row, as one digest per table. */ + async function snapshotTables(): Promise> { + const tables = await prisma.$queryRaw>` SELECT table_name AS name FROM information_schema.tables WHERE table_schema = 'public' @@ -239,147 +244,147 @@ async function snapshotTables(): Promise> { AND table_name NOT IN ('app_settings', '_prisma_migrations') ORDER BY table_name `; - const out: Record = {}; - for (const { name } of tables) { - if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`); - const [r] = await prisma.$queryRawUnsafe>( - `SELECT count(*)::int AS n, + const out: Record = {}; + for (const { name } of tables) { + if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`); + const [r] = await prisma.$queryRawUnsafe>( + `SELECT count(*)::int AS n, coalesce(md5(string_agg(t::text, '|' ORDER BY t::text)), '') AS d FROM "${name}" t`, - ); - out[name] = `${r.n}:${r.d}`; + ); + out[name] = `${r.n}:${r.d}`; + } + return out; } - return out; -} -/** The settings row without the dry-run column. */ -async function settingsWithoutDryRun() { - const [r] = await prisma.$queryRaw }>>` + /** The settings row without the dry-run column. */ + async function settingsWithoutDryRun() { + const [r] = await prisma.$queryRaw }>>` SELECT to_jsonb(s) - 'pulse_day_mean_dry_run_json' - 'updated_at' AS j FROM app_settings s WHERE s.id = 'singleton' `; - return r.j; -} + return r.j; + } -describe("the dry run", () => { - it("writes its result and nothing else", async () => { - const tablesBefore = await snapshotTables(); - const settingsBefore = await settingsWithoutDryRun(); - expect(tablesBefore.measurement_rollups).not.toMatch(/^0:/); + describe("the dry run", () => { + it("writes its result and nothing else", async () => { + const tablesBefore = await snapshotTables(); + const settingsBefore = await settingsWithoutDryRun(); + expect(tablesBefore.measurement_rollups).not.toMatch(/^0:/); - const outcome = await handlePulseDayMean(job("dry_run")); - expect(outcome.ok).toBe(true); + const outcome = await handlePulseDayMean(job("dry_run")); + expect(outcome.ok).toBe(true); - expect(await snapshotTables()).toEqual(tablesBefore); - expect(await settingsWithoutDryRun()).toEqual(settingsBefore); + expect(await snapshotTables()).toEqual(tablesBefore); + expect(await settingsWithoutDryRun()).toEqual(settingsBefore); - const stored = await prisma.appSettings.findUniqueOrThrow({ - where: { id: "singleton" }, - }); - const run = pulseDayMeanStoredRunsSchema.parse( - stored.pulseDayMeanDryRunJson, - ); - expect(run.status).toBe("done"); - expect(run.finishedAt).not.toBeNull(); - expect(run.result).toEqual({ - accounts: 2, - daysCompared: 3, - daysChangedOver1Bpm: 2, - maxChangeBpm: 10, - distribution: [ - { bucket: "0-1", days: 1 }, - { bucket: "1-2", days: 1 }, - { bucket: "2-5", days: 0 }, - { bucket: "5+", days: 1 }, - ], - perAccount: [ - { account: "#1", days: 2, changedOver1Bpm: 1 }, - { account: "#2", days: 1, changedOver1Bpm: 1 }, - ], - }); - // No account id leaves the job. - expect(JSON.stringify(stored.pulseDayMeanDryRunJson)).not.toContain(A); - expect(stored.pulseDayMeanMethod).toBe("hour_mean_v1"); - }); -}); - -describe("the rebuild after apply", () => { - const dayMean = async (userId: string, day: number) => - ( - await readCanonicalRollupBuckets({ - userId, - type: "PULSE", - granularity: "DAY", - from: new Date(day), - to: new Date(day), - toInclusive: true, - }) - )[0]?.mean; - - it("rewrites the PULSE rollups under the stored method", async () => { - expect(await dayMean(A, D1)).toBeCloseTo(V1, 6); - expect(await dayMean(B, D1)).toBeCloseTo(80, 6); - - await prisma.appSettings.update({ - where: { id: "singleton" }, - data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, - }); - const outcome = await handlePulseDayMean(job("rebuild")); - expect(outcome.ok).toBe(true); - // The rebuild is on the record beside the dry run, with what it did. - const { readPulseDayMeanStatus } = - await import("@/lib/jobs/pulse-day-mean"); - const status = await readPulseDayMeanStatus(); - expect(status.lastRebuild).toMatchObject({ status: "done" }); - expect(status.lastRebuild?.accounts).toBeGreaterThan(0); - expect(status.lastDryRun?.status).toBe("done"); - - expect(await dayMean(A, D1)).toBeCloseTo(V2, 6); - expect(await dayMean(A, D2)).toBeCloseTo(70, 6); - expect(await dayMean(B, D1)).toBeCloseTo(70, 6); - - await prisma.appSettings.update({ - where: { id: "singleton" }, - data: { pulseDayMeanMethod: "hour_mean_v1" }, + const stored = await prisma.appSettings.findUniqueOrThrow({ + where: { id: "singleton" }, + }); + const run = pulseDayMeanStoredRunsSchema.parse( + stored.pulseDayMeanDryRunJson, + ); + expect(run.status).toBe("done"); + expect(run.finishedAt).not.toBeNull(); + expect(run.result).toEqual({ + accounts: 2, + daysCompared: 3, + daysChangedOver1Bpm: 2, + maxChangeBpm: 10, + distribution: [ + { bucket: "0-1", days: 1 }, + { bucket: "1-2", days: 1 }, + { bucket: "2-5", days: 0 }, + { bucket: "5+", days: 1 }, + ], + perAccount: [ + { account: "#1", days: 2, changedOver1Bpm: 1 }, + { account: "#2", days: 1, changedOver1Bpm: 1 }, + ], + }); + // No account id leaves the job. + expect(JSON.stringify(stored.pulseDayMeanDryRunJson)).not.toContain(A); + expect(stored.pulseDayMeanMethod).toBe("hour_mean_v1"); }); }); -}); -describe("a large account under the production heap", () => { - const BIG = "pulse-method-big"; - const YEARS = 2; - /** The production container's heap ceiling. */ - const PROD_HEAP_MB = 524; - /** - * The seed's transactions run past Prisma's 5 s default under a loaded - * host: a month took at most 0.55 s on a quiet machine and 9.9 s at a load - * average near 95, which expired the transaction. The bound is the - * fixture's, not the code's under test. - */ - const SEED_TRANSACTION = { maxWait: 30_000, timeout: 120_000 }; + describe("the rebuild after apply", () => { + const dayMean = async (userId: string, day: number) => + ( + await readCanonicalRollupBuckets({ + userId, + type: "PULSE", + granularity: "DAY", + from: new Date(day), + to: new Date(day), + toInclusive: true, + }) + )[0]?.mean; - beforeAll(async () => { - await prisma.user.create({ - data: { id: BIG, username: BIG, timezone: "UTC" }, + it("rewrites the PULSE rollups under the stored method", async () => { + expect(await dayMean(A, D1)).toBeCloseTo(V1, 6); + expect(await dayMean(B, D1)).toBeCloseTo(80, 6); + + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, + }); + const outcome = await handlePulseDayMean(job("rebuild")); + expect(outcome.ok).toBe(true); + // The rebuild is on the record beside the dry run, with what it did. + const { readPulseDayMeanStatus } = + await import("@/lib/jobs/pulse-day-mean"); + const status = await readPulseDayMeanStatus(); + expect(status.lastRebuild).toMatchObject({ status: "done" }); + expect(status.lastRebuild?.accounts).toBeGreaterThan(0); + expect(status.lastDryRun?.status).toBe("done"); + + expect(await dayMean(A, D1)).toBeCloseTo(V2, 6); + expect(await dayMean(A, D2)).toBeCloseTo(70, 6); + expect(await dayMean(B, D1)).toBeCloseTo(70, 6); + + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { pulseDayMeanMethod: "hour_mean_v1" }, + }); }); - // One reading a minute for two years, about a million rows, and every - // seventh hour holding a single reading so the two methods disagree. - // - // Written a month at a time, each month in its own transaction with the - // statement timeout lifted for that transaction only and a transaction - // timeout sized for a loaded host (`SEED_TRANSACTION`): as one statement - // the million-row insert outran the timeout on CI's shared Postgres, and - // both timeouts guard the code under test, not this fixture. - const end = new Date(); - end.setUTCHours(0, 0, 0, 0); - const start = new Date(end.getTime() - YEARS * 365 * DAY); - const CHUNK = 30 * DAY; - for (let from = start.getTime(); from < end.getTime(); from += CHUNK) { - const chunkFrom = new Date(from); - const chunkTo = new Date(Math.min(from + CHUNK, end.getTime())); - await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SET LOCAL statement_timeout = 0`; - await tx.$executeRaw` + }); + + describe("a large account under the production heap", () => { + const BIG = "pulse-method-big"; + const YEARS = 2; + /** The production container's heap ceiling. */ + const PROD_HEAP_MB = 524; + /** + * The seed's transactions run past Prisma's 5 s default under a loaded + * host: a month took at most 0.55 s on a quiet machine and 9.9 s at a load + * average near 95, which expired the transaction. The bound is the + * fixture's, not the code's under test. + */ + const SEED_TRANSACTION = { maxWait: 30_000, timeout: 120_000 }; + + beforeAll(async () => { + await prisma.user.create({ + data: { id: BIG, username: BIG, timezone: "UTC" }, + }); + // One reading a minute for two years, about a million rows, and every + // seventh hour holding a single reading so the two methods disagree. + // + // Written a month at a time, each month in its own transaction with the + // statement timeout lifted for that transaction only and a transaction + // timeout sized for a loaded host (`SEED_TRANSACTION`): as one statement + // the million-row insert outran the timeout on CI's shared Postgres, and + // both timeouts guard the code under test, not this fixture. + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const start = new Date(end.getTime() - YEARS * 365 * DAY); + const CHUNK = 30 * DAY; + for (let from = start.getTime(); from < end.getTime(); from += CHUNK) { + const chunkFrom = new Date(from); + const chunkTo = new Date(Math.min(from + CHUNK, end.getTime())); + await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SET LOCAL statement_timeout = 0`; + await tx.$executeRaw` INSERT INTO measurements (id, user_id, type, unit, source, value, measured_at, created_at, updated_at) SELECT 'big-' || (extract(epoch FROM ts)::bigint / 60)::text, ${BIG}, 'PULSE', 'bpm', 'APPLE_HEALTH', 60 + ((extract(epoch FROM ts)::bigint / 60) % 40), @@ -391,60 +396,63 @@ describe("a large account under the production heap", () => { ) AS s(ts) WHERE extract(hour FROM ts)::int % 7 <> 3 OR extract(minute FROM ts)::int = 0 `; + }, SEED_TRANSACTION); + } + await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SET LOCAL statement_timeout = 0`; + await tx.$executeRaw`ANALYZE measurements`; }, SEED_TRANSACTION); - } - await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SET LOCAL statement_timeout = 0`; - await tx.$executeRaw`ANALYZE measurements`; - }, SEED_TRANSACTION); - }, 600_000); + }, 600_000); - async function peakHeapMb(work: () => Promise) { - const base = process.memoryUsage().heapUsed; - let peak = base; - const timer = setInterval(() => { + async function peakHeapMb(work: () => Promise) { + const base = process.memoryUsage().heapUsed; + let peak = base; + const timer = setInterval(() => { + peak = Math.max(peak, process.memoryUsage().heapUsed); + }, 25); + try { + await work(); + } finally { + clearInterval(timer); + } peak = Math.max(peak, process.memoryUsage().heapUsed); - }, 25); - try { - await work(); - } finally { - clearInterval(timer); + return { + grewMb: (peak - base) / 1_048_576, + peakMb: peak / 1_048_576, + }; } - peak = Math.max(peak, process.memoryUsage().heapUsed); - return { - grewMb: (peak - base) / 1_048_576, - peakMb: peak / 1_048_576, - }; - } - it("stays flat through the dry run and the rebuild", async () => { - const rows = await prisma.measurement.count({ where: { userId: BIG } }); - expect(rows).toBeGreaterThan(900_000); + it("stays flat through the dry run and the rebuild", async () => { + const rows = await prisma.measurement.count({ where: { userId: BIG } }); + expect(rows).toBeGreaterThan(900_000); - const dry = await peakHeapMb(() => computePulseDayMeanDryRun()); - await prisma.appSettings.update({ - where: { id: "singleton" }, - data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, - }); - const rebuild = await peakHeapMb(() => handlePulseDayMean(job("rebuild"))); - process.stderr.write( - `[pulse-day-mean heap] rows=${rows} dryRun grew=${dry.grewMb.toFixed(1)} MB peak=${dry.peakMb.toFixed(1)} MB; rebuild grew=${rebuild.grewMb.toFixed(1)} MB peak=${rebuild.peakMb.toFixed(1)} MB\n`, - ); - // A window's aggregate rows, never the readings: a few MB at most. The - // bound leaves room for the test runner's own churn and is still a small - // slice of the production ceiling. - expect(dry.grewMb).toBeLessThan(64); - expect(rebuild.grewMb).toBeLessThan(64); - expect(dry.grewMb).toBeLessThan(PROD_HEAP_MB / 8); + const dry = await peakHeapMb(() => computePulseDayMeanDryRun()); + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { pulseDayMeanMethod: "weighted_hour_mean_v2" }, + }); + const rebuild = await peakHeapMb(() => + handlePulseDayMean(job("rebuild")), + ); + process.stderr.write( + `[pulse-day-mean heap] rows=${rows} dryRun grew=${dry.grewMb.toFixed(1)} MB peak=${dry.peakMb.toFixed(1)} MB; rebuild grew=${rebuild.grewMb.toFixed(1)} MB peak=${rebuild.peakMb.toFixed(1)} MB\n`, + ); + // A window's aggregate rows, never the readings: a few MB at most. The + // bound leaves room for the test runner's own churn and is still a small + // slice of the production ceiling. + expect(dry.grewMb).toBeLessThan(64); + expect(rebuild.grewMb).toBeLessThan(64); + expect(dry.grewMb).toBeLessThan(PROD_HEAP_MB / 8); - const days = await readCanonicalRollupBuckets({ - userId: BIG, - type: "PULSE", - granularity: "DAY", - from: new Date(Date.now() - 400 * DAY), - to: new Date(), - toInclusive: true, - }); - expect(days.length).toBeGreaterThan(300); - }, 900_000); + const days = await readCanonicalRollupBuckets({ + userId: BIG, + type: "PULSE", + granularity: "DAY", + from: new Date(Date.now() - 400 * DAY), + to: new Date(), + toInclusive: true, + }); + expect(days.length).toBeGreaterThan(300); + }, 900_000); + }); }); diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index de25d0251..74ed9dffb 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -93,7 +93,9 @@ beforeAll(async () => { }); }); -describe("lab CSV import", () => { +// One walk: a dry run, the import, the same file again. Each step reads what +// the one before it wrote, so the order holds under a shuffled run. +describe("lab CSV import", { shuffle: false }, () => { it("a dry run writes nothing and reports the plan", async () => { const result = await importLabsCsv({ userId: USER, @@ -192,6 +194,14 @@ describe("lab CSV import", () => { }); describe("lab CSV import in a zone of the file's own", () => { + // An account of its own, so its two draws are not in the other cases' counts. + const ZONE_USER = "csv-import-zone-user"; + beforeAll(async () => { + await prisma.user.create({ + data: { id: ZONE_USER, username: ZONE_USER, timezone: TZ }, + }); + }); + it("cuts the duplicate day in the zone the file is read in", async () => { // Read in New York, these are draws on two days (1 and 2 March). In the // account's Berlin they both fall on 2 March, which used to make the @@ -202,7 +212,7 @@ describe("lab CSV import in a zone of the file's own", () => { "Zone marker;7;mg/dL;2025-03-02T02:00", ].join("\n"); const result = await importLabsCsv({ - userId: USER, + userId: ZONE_USER, tz: TZ, stream: textToStream(csv), maxBytes: 1_000_000, @@ -384,6 +394,15 @@ describe("medication CSV import", () => { }); describe("medication CSV import, a crash between medication and category", () => { + // An account of its own: the medication import case counts every + // medication its account holds. + const CRASH_USER = "csv-import-crash-user"; + beforeAll(async () => { + await prisma.user.create({ + data: { id: CRASH_USER, username: CRASH_USER, timezone: TZ }, + }); + }); + const CSV = ["name,dose,times", "Atorvastatin,20 mg,20:00"].join("\n"); const query = medicationsCsvQuerySchema.parse({ name: "0", @@ -396,7 +415,7 @@ describe("medication CSV import, a crash between medication and category", () => categoryFault.on = true; await expect( importMedicationsCsv({ - userId: USER, + userId: CRASH_USER, tz: TZ, stream: textToStream(CSV), maxBytes: 100_000, @@ -406,12 +425,12 @@ describe("medication CSV import, a crash between medication and category", () => categoryFault.on = false; expect( await prisma.medication.count({ - where: { userId: USER, name: "Atorvastatin" }, + where: { userId: CRASH_USER, name: "Atorvastatin" }, }), ).toBe(0); const retry = await importMedicationsCsv({ - userId: USER, + userId: CRASH_USER, tz: TZ, stream: textToStream(CSV), maxBytes: 100_000, @@ -419,7 +438,7 @@ describe("medication CSV import, a crash between medication and category", () => }); expect(retry).toMatchObject({ inserted: 1, duplicates: 0 }); const med = await prisma.medication.findFirstOrThrow({ - where: { userId: USER, name: "Atorvastatin" }, + where: { userId: CRASH_USER, name: "Atorvastatin" }, select: { id: true }, }); expect( diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts index 0045ba1a8..b961fbad2 100644 --- a/tests/integration/settings-round-trip.test.ts +++ b/tests/integration/settings-round-trip.test.ts @@ -996,182 +996,194 @@ describe("every setting a person can choose", () => { expect(new Set(listed).size).toBe(listed.length); }); - describe("set, read back, backed up, restored, read back again", () => { - let owner: User; - let session: SessionFixture; - const before = new Map(); - const meBefore = new Map(); - let stored: Map; - let defaults: Map; + // One trip, in the order of its name: each leg reads what the leg before it + // wrote. The order holds under a shuffled run. + describe( + "set, read back, backed up, restored, read back again", + { shuffle: false }, + () => { + let owner: User; + let session: SessionFixture; + const before = new Map(); + const meBefore = new Map(); + let stored: Map; + let defaults: Map; - beforeAll(async () => { - await truncateAllTables(prisma); - owner = await makeUser({ onboardingCompletedAt: null }); - session = await makeSession(owner); - defaults = columnsOf(await makeUser({ onboardingCompletedAt: null })); - }); + beforeAll(async () => { + await truncateAllTables(prisma); + owner = await makeUser({ onboardingCompletedAt: null }); + session = await makeSession(owner); + defaults = columnsOf(await makeUser({ onboardingCompletedAt: null })); + }); - it("stores each setting through its write route and answers it on its read route", async () => { - const failures: string[] = []; - for (const [index, entry] of entries(owner).entries()) { - const label = entry.columns.join(", "); - let written: RouteResult; - let read: RouteResult; - try { - written = await call(entry.write, session); - if (written.status >= 300) { - failures.push( - `${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`, - ); + it("stores each setting through its write route and answers it on its read route", async () => { + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + const label = entry.columns.join(", "); + let written: RouteResult; + let read: RouteResult; + try { + written = await call(entry.write, session); + if (written.status >= 300) { + failures.push( + `${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`, + ); + continue; + } + read = await call(entry.read.call, session); + } catch (error) { + failures.push(`${label}: ${String(error).slice(0, 400)}`); continue; } - read = await call(entry.read.call, session); - } catch (error) { - failures.push(`${label}: ${String(error).slice(0, 400)}`); - continue; + if (read.status !== 200) { + failures.push(`${label}: read answered ${read.status}`); + continue; + } + const value = entry.read.pick(read); + before.set(index, value); + if ( + "expected" in entry && + !isDeepStrictEqual(value, entry.expected) + ) { + failures.push( + `${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`, + ); + } } - if (read.status !== 200) { - failures.push(`${label}: read answered ${read.status}`); - continue; + expect(failures).toEqual([]); + }); + + it("publishes the settings the account payload carries", async () => { + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: session, + }); + expect(me.status).toBe(200); + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + meBefore.set(index, value); + if ( + "expected" in entry.me && + !isDeepStrictEqual(value, entry.me.expected) + ) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`, + ); + } } - const value = entry.read.pick(read); - before.set(index, value); - if ("expected" in entry && !isDeepStrictEqual(value, entry.expected)) { - failures.push( - `${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`, - ); - } - } - expect(failures).toEqual([]); - }); - - it("publishes the settings the account payload carries", async () => { - const me = await callRoute(getMe, { - method: "GET", - route: "/api/auth/me", - auth: session, + expect(failures).toEqual([]); }); - expect(me.status).toBe(200); - const failures: string[] = []; - for (const [index, entry] of entries(owner).entries()) { - if (!entry.me) continue; - const value = entry.me.pick(me.data); - meBefore.set(index, value); - if ( - "expected" in entry.me && - !isDeepStrictEqual(value, entry.me.expected) - ) { - failures.push( - `${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`, - ); - } - } - expect(failures).toEqual([]); - }); - it("moves every setting column off its default", async () => { - stored = columnsOf( - await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }), - ); - const untouched = SETTING_COLUMNS.filter( - (column) => - comparable(stored.get(column)) === comparable(defaults.get(column)), - ); - expect( - untouched, - "these columns still hold what a fresh account holds", - ).toEqual([]); - }); - - it("comes back from a backup restored into a recreated account", async () => { - const exported = await callRoute(exportFullBackup, { - method: "GET", - route: "/api/export/full-backup", - auth: session, - }); - expect(exported.status).toBe(200); - - // The account is recreated under the same id, the way an operator - // brings an account back on a new host, and nothing else of it remains. - await prisma.user.delete({ where: { id: owner.id } }); - const fresh = await makeUser({ - id: owner.id, - username: owner.username, - email: owner.email, - onboardingCompletedAt: null, - }); - const admin = await makeAdmin(); - const operator = await makeSession(admin); - - const uploaded = await callRoute<{ id: string }>(uploadBackup, { - method: "POST", - route: "/api/admin/backups/upload", - body: exported.body, - auth: operator, - }); - expect( - uploaded.status, - JSON.stringify(uploaded.body).slice(0, 400), - ).toBeLessThan(300); - const backupId = uploaded.data.id; - - const restored = await callRoute(restoreBackup, { - method: "POST", - route: "/api/admin/backups/{id}/restore", - params: { id: backupId }, - body: { confirm: "RESTORE" }, - auth: operator, - contract: { - skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", - }, - }); - expect(restored.status, JSON.stringify(restored.body).slice(0, 400)).toBe( - 200, - ); - - const after = columnsOf( - await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }), - ); - const lostInStorage = SETTING_COLUMNS.filter( - (column) => - !SEALED.has(column) && - comparable(after.get(column)) !== comparable(stored.get(column)), - ); - expect( - lostInStorage.map( + it("moves every setting column off its default", async () => { + stored = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }), + ); + const untouched = SETTING_COLUMNS.filter( (column) => - `${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`, - ), - "these columns came back with a different stored value", - ).toEqual([]); - - const restoredSession = await makeSession(fresh); - const failures: string[] = []; - for (const [index, entry] of entries(fresh).entries()) { - const read = await call(entry.read.call, restoredSession); - const value = - read.status === 200 ? entry.read.pick(read) : `status ${read.status}`; - if (!isDeepStrictEqual(value, before.get(index))) { - failures.push( - `${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`, - ); - } - } - const me = await callRoute(getMe, { - method: "GET", - route: "/api/auth/me", - auth: restoredSession, + comparable(stored.get(column)) === comparable(defaults.get(column)), + ); + expect( + untouched, + "these columns still hold what a fresh account holds", + ).toEqual([]); }); - for (const [index, entry] of entries(fresh).entries()) { - if (!entry.me) continue; - const value = entry.me.pick(me.data); - if (!isDeepStrictEqual(value, meBefore.get(index))) { - failures.push( - `${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`, - ); + + it("comes back from a backup restored into a recreated account", async () => { + const exported = await callRoute(exportFullBackup, { + method: "GET", + route: "/api/export/full-backup", + auth: session, + }); + expect(exported.status).toBe(200); + + // The account is recreated under the same id, the way an operator + // brings an account back on a new host, and nothing else of it remains. + await prisma.user.delete({ where: { id: owner.id } }); + const fresh = await makeUser({ + id: owner.id, + username: owner.username, + email: owner.email, + onboardingCompletedAt: null, + }); + const admin = await makeAdmin(); + const operator = await makeSession(admin); + + const uploaded = await callRoute<{ id: string }>(uploadBackup, { + method: "POST", + route: "/api/admin/backups/upload", + body: exported.body, + auth: operator, + }); + expect( + uploaded.status, + JSON.stringify(uploaded.body).slice(0, 400), + ).toBeLessThan(300); + const backupId = uploaded.data.id; + + const restored = await callRoute(restoreBackup, { + method: "POST", + route: "/api/admin/backups/{id}/restore", + params: { id: backupId }, + body: { confirm: "RESTORE" }, + auth: operator, + contract: { + skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", + }, + }); + expect( + restored.status, + JSON.stringify(restored.body).slice(0, 400), + ).toBe(200); + + const after = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }), + ); + const lostInStorage = SETTING_COLUMNS.filter( + (column) => + !SEALED.has(column) && + comparable(after.get(column)) !== comparable(stored.get(column)), + ); + expect( + lostInStorage.map( + (column) => + `${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`, + ), + "these columns came back with a different stored value", + ).toEqual([]); + + const restoredSession = await makeSession(fresh); + const failures: string[] = []; + for (const [index, entry] of entries(fresh).entries()) { + const read = await call(entry.read.call, restoredSession); + const value = + read.status === 200 + ? entry.read.pick(read) + : `status ${read.status}`; + if (!isDeepStrictEqual(value, before.get(index))) { + failures.push( + `${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`, + ); + } } - } - expect(failures).toEqual([]); - }); - }); + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: restoredSession, + }); + for (const [index, entry] of entries(fresh).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + if (!isDeepStrictEqual(value, meBefore.get(index))) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`, + ); + } + } + expect(failures).toEqual([]); + }); + }, + ); }); From 2c33c2a0a037cd67bbf82d357a926aa8f385e54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:35:57 +0100 Subject: [PATCH 06/10] test(integration): stop cases reading what another case left behind - the AI-state cases take their baseline in a world of their own instead of from the S0 case; - the live-window case drops a rollup tier the rollup case built; - the job readers start each case on an empty queue, and the no-schema case starts pg-boss again after it; - the server move case reseeds the server it emptied; - the extremes cases rebuild the tier one of them breaks on purpose; - the first Google Health backfill seeds its own history; - the batch budget warms the process caches as the reads do; - the HOT-update count is read as a difference: since Postgres 15 the transaction view also shows an earlier transaction's unflushed counts; - the MANAGE matrix checks its effect set after all cases rather than in a case of its own that a shuffle could run first. --- .../ai-optional-data-routes.test.ts | 22 ++++++++++++---- .../all-time-extremes-tier-parity.test.ts | 8 +++++- .../google-health-backfill-multi-year.test.ts | 9 +++---- tests/integration/job-failures.test.ts | 16 +++++++++--- .../json-import-own-exports.test.ts | 11 ++++++-- .../measurement-hot-update.test.ts | 21 ++++++++++----- .../query-budget-hot-routes.test.ts | 26 ++++++++++++++++--- .../request-clock-windows.cases.ts | 3 +++ .../sharing-manage-handler-matrix.test.ts | 21 ++++++++++----- 9 files changed, 106 insertions(+), 31 deletions(-) diff --git a/tests/integration/ai-optional-data-routes.test.ts b/tests/integration/ai-optional-data-routes.test.ts index 465e775d0..c91e3657e 100644 --- a/tests/integration/ai-optional-data-routes.test.ts +++ b/tests/integration/ai-optional-data-routes.test.ts @@ -257,7 +257,20 @@ async function readAll( return out; } -let baseline: Record | null = null; +type Reads = Record; +let baseline: Reads | null = null; + +/** + * The all-on reads, taken once in a world of their own and then cleared, so + * a state case compares against them whichever case runs first. + */ +async function readBaseline(): Promise { + if (baseline === null) { + baseline = await readAll(await enterState("S0", seed), "S0"); + await resetWorld(); + } + return baseline; +} beforeEach(async () => { await resetWorld(); @@ -265,8 +278,7 @@ beforeEach(async () => { describe("data routes under every AI state", () => { it("S0 (all on) is the baseline, and every read answers", async () => { - const world = await enterState("S0", seed); - baseline = await readAll(world, "S0"); + const baseline = await readBaseline(); for (const [name, { status }] of Object.entries(baseline)) { expect(status, name).toBe(200); } @@ -288,12 +300,12 @@ describe("data routes under every AI state", () => { it.each(["S1", "S2", "S3", "S4", "S5", "S6", "S8"] as const)( "%s answers every read with the baseline's data", async (state) => { - expect(baseline, "the S0 baseline runs first").not.toBeNull(); + const base = await readBaseline(); const world = await enterState(state, seed); const reads = await readAll(world, state); for (const [name, { status, body }] of Object.entries(reads)) { expect(status, `${state} ${name}`).toBe(200); - expect(body, `${state} ${name}`).toEqual(baseline![name].body); + expect(body, `${state} ${name}`).toEqual(base[name].body); } }, ); diff --git a/tests/integration/all-time-extremes-tier-parity.test.ts b/tests/integration/all-time-extremes-tier-parity.test.ts index c73ad99b0..587e3a96c 100644 --- a/tests/integration/all-time-extremes-tier-parity.test.ts +++ b/tests/integration/all-time-extremes-tier-parity.test.ts @@ -18,7 +18,7 @@ * Means are compared to nine significant digits: the tier sums the same day * values in a different order. Extremes are compared exactly. */ -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { Prisma, type MeasurementType } from "@/generated/prisma/client"; @@ -246,6 +246,12 @@ beforeAll(async () => { }, 300_000); describe("readAllTimeExtremes over the DAY tier", () => { + // A clean tier over whatever rows are left for every case: some cases + // break a tier row on purpose, and the next case must not read it. + beforeEach(async () => { + await recomputeUserRollups(USER, { granularities: ["DAY"] }); + }, 120_000); + it("reads the tier for every type", async () => { const covered = await prisma.measurementRollup.groupBy({ by: ["type"], diff --git a/tests/integration/google-health-backfill-multi-year.test.ts b/tests/integration/google-health-backfill-multi-year.test.ts index b45003c83..4ec776bfa 100644 --- a/tests/integration/google-health-backfill-multi-year.test.ts +++ b/tests/integration/google-health-backfill-multi-year.test.ts @@ -58,7 +58,7 @@ import v8 from "node:v8"; import vm from "node:vm"; import type { Job } from "pg-boss"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import type { WideEvent } from "@/lib/logging/types"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -324,10 +324,6 @@ async function seed(points: number): Promise { fake.upperBoundRequests = 0; } -beforeAll(async () => { - await seed(POINTS); -}, 120_000); - afterAll(async () => { await truncateAllTables(getPrismaClient()); }); @@ -357,6 +353,9 @@ function nextSlice(): AdmissionPayload | null { describe(`Google Health backfill over ${DAYS} days of heart rate (#1220)`, () => { it("completes slice by slice, imports every reading once, and keeps the hourly sync running", async () => { + // Its own history, as the case below seeds its own: neither reads what + // the other left behind, whichever runs first. + await seed(POINTS); const { drainIntegrationBackfillAdmission } = await import("@/lib/jobs/integration-backfill-drain"); const { handleGoogleHealthSync } = diff --git a/tests/integration/job-failures.test.ts b/tests/integration/job-failures.test.ts index 981353dd0..f13180fd7 100644 --- a/tests/integration/job-failures.test.ts +++ b/tests/integration/job-failures.test.ts @@ -13,7 +13,7 @@ * are failed through its own API. */ import { PgBoss } from "pg-boss"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { readActiveJobsStartedBefore, @@ -47,7 +47,7 @@ async function failOneJob( return jobId; } -beforeAll(async () => { +async function startBoss(): Promise { boss = new PgBoss({ connectionString: process.env.DATABASE_URL, // No maintenance or monitoring timers — the test drives every transition. @@ -57,7 +57,15 @@ beforeAll(async () => { await boss.start(); await boss.createQueue(QUEUE); await boss.createQueue(OTHER_QUEUE); -}, 60_000); +} + +beforeAll(startBoss, 60_000); + +// Every case reads the queue it filled itself: no job a case before it sent, +// failed or left active is still there to be counted or fetched. +beforeEach(async () => { + await getPrismaClient().$executeRawUnsafe(`DELETE FROM pgboss.job`); +}); afterAll(async () => { await boss?.stop({ graceful: false, close: true }); @@ -244,6 +252,8 @@ describe("no queue schema at all", () => { await prisma.$executeRawUnsafe( `ALTER SCHEMA pgboss_gone RENAME TO pgboss`, ); + // Running again for whichever case comes next. + await startBoss(); } }); }); diff --git a/tests/integration/json-import-own-exports.test.ts b/tests/integration/json-import-own-exports.test.ts index 29540ce28..3c79a918c 100644 --- a/tests/integration/json-import-own-exports.test.ts +++ b/tests/integration/json-import-own-exports.test.ts @@ -138,11 +138,14 @@ function byInstant(at: string) { String(a.type ?? "").localeCompare(String(b.type ?? "")); } -beforeAll(async () => { +/** This server: the seeded catalogue and the owner whose files are read. */ +async function seedServer(): Promise { await truncateAllTables(prisma); await seedJsonImportCatalogue(prisma); ({ ownerId } = await seedJsonImportOwner(prisma)); -}); +} + +beforeAll(seedServer); afterAll(async () => { await truncateAllTables(prisma); @@ -350,6 +353,10 @@ describe("the JSON import takes the app's own files back", () => { }); describe("a full backup moved to another server", () => { + // The case empties the database to stand in for another server. Put this + // server back for the cases that run after it. + afterAll(seedServer); + it("comes back equal, the account's own tags, groups and rated factor included", async () => { const original = await fullBackupOf(ownerId); expect(original.customMoodTags.length).toBeGreaterThanOrEqual(2); diff --git a/tests/integration/measurement-hot-update.test.ts b/tests/integration/measurement-hot-update.test.ts index 7b5144c1c..a24efcb7e 100644 --- a/tests/integration/measurement-hot-update.test.ts +++ b/tests/integration/measurement-hot-update.test.ts @@ -55,18 +55,27 @@ describe("an update that changes nothing", () => { const row = await seedReading(); await new Promise((r) => setTimeout(r, 20)); + // Read as a difference within the transaction. Since Postgres 15 the + // `pg_stat_xact_*` views also carry counts an earlier transaction on the + // same connection has not flushed yet (stats flush at most once a + // second), so an update from the case before this one could show up. const hot = await prisma.$transaction(async (tx) => { + const read = async () => { + const [stats] = await tx.$queryRaw>` + SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd + FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`; + return { hot: Number(stats?.hot ?? 0), upd: Number(stats?.upd ?? 0) }; + }; + const before = await read(); await tx.measurement.update({ where: { id: row.id }, data: { value: 72.3, deletedAt: null, syncVersion: { increment: 1 } }, }); - const [stats] = await tx.$queryRaw>` - SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd - FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`; - return stats; + const after = await read(); + return { hot: after.hot - before.hot, upd: after.upd - before.upd }; }); - expect(Number(hot.upd)).toBe(1); - expect(Number(hot.hot)).toBe(1); + expect(hot.upd).toBe(1); + expect(hot.hot).toBe(1); const after = await prisma.measurement.findUniqueOrThrow({ where: { id: row.id }, diff --git a/tests/integration/query-budget-hot-routes.test.ts b/tests/integration/query-budget-hot-routes.test.ts index 0a50202d6..f16576db8 100644 --- a/tests/integration/query-budget-hot-routes.test.ts +++ b/tests/integration/query-budget-hot-routes.test.ts @@ -428,15 +428,35 @@ describe("statement budgets on the hot routes (real Postgres)", () => { externalId: `budget-hr-${i}`, }; }); - const m = await countStatements(async () => { - const res = await (POST as unknown as Handler)( + const post = (body: unknown) => + (POST as unknown as Handler)( new NextRequest("http://localhost/api/measurements/batch", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ entries }), + body: JSON.stringify(body), }), { params: Promise.resolve({}) }, ); + // One reading a month earlier first, as the reads warm up: it fills the + // process-level caches a running server holds, which this case would + // otherwise pay for whenever it runs before the reads do. + const warmAt = daysAgo(30).toISOString(); + const warm = await post({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value: 60, + unit: "count/min", + startDate: warmAt, + endDate: warmAt, + externalId: "budget-hr-warm", + }, + ], + }); + expect(warm.status, "batch warm-up").toBe(200); + await evictUserCaches(); + const m = await countStatements(async () => { + const res = await post({ entries }); return { status: res.status, body: (await res.json()) as { data: { inserted: number } }, diff --git a/tests/integration/request-clock-windows.cases.ts b/tests/integration/request-clock-windows.cases.ts index 828ecd18e..6e59232e5 100644 --- a/tests/integration/request-clock-windows.cases.ts +++ b/tests/integration/request-clock-windows.cases.ts @@ -157,6 +157,9 @@ export function defineRequestClockCases(expectedZone: string): void { }); it("cuts every window of the live aggregate on the request's clock", async () => { + // The live path is the one without a tier; drop whatever the rollup + // case built if it ran first. + await prisma.measurementRollup.deleteMany({ where: { userId } }); await expectWindows("live"); }); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index abc6f0f37..2954127f5 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -33,7 +33,7 @@ * the generation leg asserts both halves — nothing enqueued for the manager, * something enqueued for the owner doing the identical thing. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; @@ -276,6 +276,8 @@ const STRICT_DRIVER_KEYS = new Set(); * It is the executable answer to "did this route actually do its work?". */ const REAL_EFFECT_KEYS = new Set(); +/** The drivers whose effect case started in this run, filtered or not. */ +const EFFECT_CASES_STARTED = new Set(); const ROUTE_IMPORTS = import.meta.glob>( "/src/app/api/**/route.ts", ); @@ -309,6 +311,7 @@ function manageContract(name: string, c: ManageCase) { STRICT_DRIVER_KEYS.add(matrixKey(c.contract)); describe(name, () => { it("changes the owner's record and names the manager as actor", async () => { + EFFECT_CASES_STARTED.add(matrixKey(c.contract)); const owner = await makeUser("owner"); const manager = await makeUser("manager"); const ctx = await c.prepare(owner.id); @@ -385,6 +388,7 @@ function writeEffectContract(name: string, c: ManageCase) { }); it("writes for the owner and audits the WRITE actor", async () => { + EFFECT_CASES_STARTED.add(matrixKey(c.contract)); const owner = await makeUser("owner"); const delegate = await makeUser("writer"); const ctx = await c.prepare(owner.id); @@ -3023,10 +3027,15 @@ describe("the complete MANAGE handler matrix", () => { expect(STRICT_DRIVER_KEYS.size).toBe(93); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); +}); - it("executes every registered driver through its owned effect and actor audit", () => { - const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(93); - expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); - }); +// Every registered driver went through its owned effect and actor audit. +// After all cases, not as one of them: the set fills as the effect cases +// run, and a case placed last in the file was read first whenever the order +// was shuffled. A run filtered down to some cases has nothing to say here. +afterAll(() => { + if (EFFECT_CASES_STARTED.size !== STRICT_DRIVER_KEYS.size) return; + const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); + expect(REAL_EFFECT_KEYS.size).toBe(93); + expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); From fa439601c4df906ff8aae8ebdcf9a75949f77053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 22:13:32 +0100 Subject: [PATCH 07/10] test(labs): give the unitless-marker refusal its own marker The refusal case leaned on the marker the case before it mints, and under a shuffled order it ran first and minted it itself. --- tests/integration/lab-dimensionless-unit.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/integration/lab-dimensionless-unit.test.ts b/tests/integration/lab-dimensionless-unit.test.ts index a3b4e25d0..1b08f1e1a 100644 --- a/tests/integration/lab-dimensionless-unit.test.ts +++ b/tests/integration/lab-dimensionless-unit.test.ts @@ -77,11 +77,16 @@ describe("lab CSV import of a value without a unit", () => { }); it("refuses a real unit under the marker without one", async () => { + // A marker of its own, kept in "1", rather than the one the case above + // mints: the cases run in either order. + await prisma.biomarker.create({ + data: { userId: USER, name: "LDL/HDL-Quotient", unit: "1" }, + }); const result = await run( [ "Analyt;Wert;Einheit;Datum", - "Cholesterin/HDL-Quotient;3,4;mg/dL;02.03.2025", - "Cholesterin/HDL-Quotient;3,2;1;03.03.2025", + "LDL/HDL-Quotient;3,4;mg/dL;02.03.2025", + "LDL/HDL-Quotient;3,2;1;03.03.2025", ].join("\n"), ); expect(result.errors).toEqual([ From 9a3140a4d31c55c623f4ef5918d30b462dec6570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 22:15:05 +0100 Subject: [PATCH 08/10] test(backup): hold the record-shaped slices at their edges The health score day, the intraday curve, a medication with its composition and custom category, an intake row and the cycle's own symptoms had fields and defaults no slice test met, added since the floor was set; the backup-validation score had drifted to 40.2 % under its 41 % floor. It is 54.1 % with these cases, and the floor follows to 53 %. --- .../__tests__/backup-slices-records.test.ts | 410 ++++++++++++++++++ stryker.floors.json | 2 +- 2 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 src/lib/validations/__tests__/backup-slices-records.test.ts diff --git a/src/lib/validations/__tests__/backup-slices-records.test.ts b/src/lib/validations/__tests__/backup-slices-records.test.ts new file mode 100644 index 000000000..3f1ca9b2c --- /dev/null +++ b/src/lib/validations/__tests__/backup-slices-records.test.ts @@ -0,0 +1,410 @@ +/** + * The record-shaped backup slices, one rule at a time: the health score + * day, the intraday curve, a medication with its composition and custom + * category, an intake row, and the cycle's own symptoms. + * + * Like `backup-slices.test.ts`, each rule meets the value just inside and + * just outside it. The defaults are pinned as well: a file written before a + * field existed restores through them, so a default that drifted would + * change what an older file means without any parse failing. + */ +import { describe, expect, it } from "vitest"; +import type { z } from "zod/v4"; + +import { customCycleSymptomSchema } from "@/lib/cycle/backup-schema"; +import { healthScoreRecordBackupSchema } from "@/lib/export/health-score-backup-schema"; +import { intradayProfileBackupSchema } from "@/lib/export/intraday-profile-backup-schema"; +import { + customMedicationCategorySchema, + intakeEventSchema, + medicationIngredientSchema, + medicationSchema, + medicationSideEffectSchema, +} from "@/lib/export/medications-backup-schema"; + +/** The paths a value is refused on, or `[]` when it parses. */ +function refused(schema: z.ZodType, value: unknown): string[] { + const result = schema.safeParse(value); + return result.success + ? [] + : result.error.issues.map((issue) => issue.path.join(".")); +} + +function parsed(schema: z.ZodType, value: unknown): T { + return schema.parse(value); +} + +const AT = "2026-07-01T08:00:00.000Z"; +const BAD_DAY_KEYS = [ + "x2026-07-01", + "2026-07-01x", + "226-07-01", + "2026-7-01", + "2026-07-1", + "abcd-07-01", + "2026-ab-01", + "2026-07-ab", +]; + +describe("the health score day", () => { + const day = { + id: "hs-1", + dayKey: "2026-07-01", + timezone: "Europe/Berlin", + composite: 72, + band: "green", + scoreVersion: 3, + composition: ["sleep", "activity"], + pillarScores: { sleep: 80 }, + inputFingerprint: "a".repeat(64), + computedAt: AT, + }; + + it("takes a well-formed day and defaults the config stamp to null", () => { + expect(refused(healthScoreRecordBackupSchema, day)).toEqual([]); + const out = parsed(healthScoreRecordBackupSchema, day) as { + configVersion: unknown; + configChangedAt: unknown; + }; + expect(out.configVersion).toBeNull(); + expect(out.configChangedAt).toBeNull(); + }); + + it("wants an anchored YYYY-MM-DD day key", () => { + for (const dayKey of BAD_DAY_KEYS) { + expect( + refused(healthScoreRecordBackupSchema, { ...day, dayKey }), + dayKey, + ).toEqual(["dayKey"]); + } + }); + + it("refuses an empty id and an empty zone", () => { + expect(refused(healthScoreRecordBackupSchema, { ...day, id: "" })).toEqual([ + "id", + ]); + expect( + refused(healthScoreRecordBackupSchema, { ...day, timezone: "" }), + ).toEqual(["timezone"]); + }); + + it("holds the composite to 0 through 100", () => { + for (const composite of [0, 100]) { + expect( + refused(healthScoreRecordBackupSchema, { ...day, composite }), + ).toEqual([]); + } + for (const composite of [-1, 101]) { + expect( + refused(healthScoreRecordBackupSchema, { ...day, composite }), + ).toEqual(["composite"]); + } + }); + + it("takes every band the column allows and nothing else", () => { + for (const band of ["green", "yellow", "red"]) { + expect(refused(healthScoreRecordBackupSchema, { ...day, band })).toEqual( + [], + ); + } + expect( + refused(healthScoreRecordBackupSchema, { ...day, band: "" }), + ).toEqual(["band"]); + }); + + it("wants at least one named pillar in the composition", () => { + expect( + refused(healthScoreRecordBackupSchema, { ...day, composition: ["a"] }), + ).toEqual([]); + expect( + refused(healthScoreRecordBackupSchema, { ...day, composition: [] }), + ).toEqual(["composition"]); + expect( + refused(healthScoreRecordBackupSchema, { + ...day, + composition: ["sleep", ""], + }), + ).toEqual(["composition.1"]); + }); + + it("wants a fingerprint of exactly 64 lowercase hex digits", () => { + for (const inputFingerprint of [ + "a".repeat(63), + "a".repeat(65), + "g".repeat(64), + `x${"a".repeat(64)}`, + `${"a".repeat(64)}x`, + "a", + ]) { + expect( + refused(healthScoreRecordBackupSchema, { ...day, inputFingerprint }), + inputFingerprint, + ).toEqual(["inputFingerprint"]); + } + }); +}); + +describe("the intraday curve", () => { + const curve = { + id: "ip-1", + type: "ACTIVITY_STEPS", + dateKey: "2026-07-01", + hourlyCumulative: [0, 120], + dayTotal: 120, + sampleCount: 2, + timezone: "UTC", + }; + + it("reads a file from before the hours travelled as a fold of samples", () => { + const out = parsed(intradayProfileBackupSchema, curve) as { + hourlyIncrements: unknown; + basis: unknown; + bucketCount: unknown; + }; + expect(out.hourlyIncrements).toEqual([]); + expect(out.basis).toBe("PER_SAMPLE"); + expect(out.bucketCount).toBeNull(); + }); + + it("takes both bases and refuses another", () => { + for (const basis of ["PER_SAMPLE", "HOUR_BUCKETS"]) { + expect(refused(intradayProfileBackupSchema, { ...curve, basis })).toEqual( + [], + ); + } + expect( + refused(intradayProfileBackupSchema, { ...curve, basis: "" }), + ).toEqual(["basis"]); + }); + + it("wants an anchored day key, a zone and a non-empty id", () => { + for (const dateKey of BAD_DAY_KEYS) { + expect( + refused(intradayProfileBackupSchema, { ...curve, dateKey }), + dateKey, + ).toEqual(["dateKey"]); + } + expect( + refused(intradayProfileBackupSchema, { ...curve, timezone: "" }), + ).toEqual(["timezone"]); + expect(refused(intradayProfileBackupSchema, { ...curve, id: "" })).toEqual([ + "id", + ]); + }); +}); + +describe("a custom medication category", () => { + const KEY = "custom:0f8fad5b-d9cb-469f-a165-70867728950e"; + + it("takes a readable label and defaults its order and state", () => { + const out = parsed(customMedicationCategorySchema, { + key: KEY, + label: " Supplements ", + }) as { label: unknown; sortOrder: unknown; isActive: unknown }; + expect(out.label).toBe("Supplements"); + expect(out.sortOrder).toBe(0); + expect(out.isActive).toBe(true); + }); + + it("wants the custom: prefix and a 36-character id, anchored", () => { + for (const key of [ + KEY.slice(0, -1), + `${KEY}0`, + `x${KEY}`, + "custom:", + "custom:zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", + ]) { + expect( + refused(customMedicationCategorySchema, { key, label: "A" }), + key, + ).toContain("key"); + } + }); + + it("holds the label to 1 through 40 characters after trimming", () => { + expect( + refused(customMedicationCategorySchema, { + key: KEY, + label: "x".repeat(40), + }), + ).toEqual([]); + expect( + refused(customMedicationCategorySchema, { + key: KEY, + label: "x".repeat(41), + }), + ).toEqual(["label"]); + // Blank after trimming: the length rule and the label-or-ciphertext rule + // both refuse it. + expect( + refused(customMedicationCategorySchema, { key: KEY, label: " " }), + ).toEqual(["label", "label"]); + }); + + it("refuses a negative order", () => { + expect( + refused(customMedicationCategorySchema, { + key: KEY, + label: "A", + sortOrder: -1, + }), + ).toEqual(["sortOrder"]); + }); + + it("needs its label or its ciphertext, and names the label when both are missing", () => { + expect( + refused(customMedicationCategorySchema, { + key: KEY, + labelEncrypted: "QUJDRA==", + }), + ).toEqual([]); + expect( + refused(customMedicationCategorySchema, { + key: KEY, + label: null, + labelEncrypted: "QUJDRA==", + }), + ).toEqual([]); + const result = customMedicationCategorySchema.safeParse({ key: KEY }); + expect(result.success).toBe(false); + expect(result.error?.issues).toEqual([ + expect.objectContaining({ + path: ["label"], + message: "a custom category needs its label or its ciphertext", + }), + ]); + expect( + refused(customMedicationCategorySchema, { key: KEY, label: null }), + ).toEqual(["label"]); + }); +}); + +describe("a medication", () => { + it("restores a file from before its parts travelled with every part empty", () => { + const out = parsed(medicationSchema, { name: "Ramipril", dose: "5 mg" }); + expect(out).toMatchObject({ + schedules: [], + sideEffects: [], + pauseEras: [], + ingredients: [], + doseChanges: [], + inventoryItems: [], + inventoryEvents: [], + efficacyTargets: [], + scheduleRevisions: [], + }); + expect((out as { courses?: unknown }).courses).toBeUndefined(); + }); + + it("refuses an empty name, id or ref and a category past 64 characters", () => { + const base = { name: "Ramipril", dose: "5 mg" }; + expect(refused(medicationSchema, { ...base, name: "" })).toEqual(["name"]); + expect(refused(medicationSchema, { ...base, id: "" })).toEqual(["id"]); + expect(refused(medicationSchema, { ...base, ref: "" })).toEqual(["ref"]); + expect( + refused(medicationSchema, { ...base, category: "x".repeat(64) }), + ).toEqual([]); + expect( + refused(medicationSchema, { ...base, category: "x".repeat(65) }), + ).toEqual(["category"]); + }); + + it("names the side effect severity outside 1 through 5", () => { + const effect = { + occurredAt: AT, + category: medicationSideEffectSchema.shape.category.options[0], + entry: medicationSideEffectSchema.shape.entry.options[0], + }; + for (const severity of [1, 5]) { + expect( + refused(medicationSideEffectSchema, { ...effect, severity }), + ).toEqual([]); + } + for (const severity of [0, 6]) { + expect( + refused(medicationSideEffectSchema, { ...effect, severity }), + ).toEqual(["severity"]); + } + expect( + refused(medicationSideEffectSchema, { ...effect, severity: 3, id: "" }), + ).toEqual(["id"]); + }); +}); + +describe("a composition ingredient", () => { + const ingredient = { + nutrient: "vitamin_d", + amountPerUnit: 20, + unit: "µg", + }; + + it("defaults its source to a hand entry and takes the label reader", () => { + expect( + (parsed(medicationIngredientSchema, ingredient) as { source: unknown }) + .source, + ).toBe("MANUAL"); + expect( + refused(medicationIngredientSchema, { + ...ingredient, + source: "LABEL_AI", + }), + ).toEqual([]); + expect( + refused(medicationIngredientSchema, { ...ingredient, source: "" }), + ).toEqual(["source"]); + }); + + it("holds the nutrient to 1 through 40 and the units to 8 characters", () => { + const at = (patch: Record) => + refused(medicationIngredientSchema, { ...ingredient, ...patch }); + expect(at({ nutrient: "x".repeat(40) })).toEqual([]); + expect(at({ nutrient: "x".repeat(41) })).toEqual(["nutrient"]); + expect(at({ nutrient: "" })).toEqual(["nutrient"]); + expect(at({ unit: "x".repeat(8) })).toEqual([]); + expect(at({ unit: "x".repeat(9) })).toEqual(["unit"]); + expect(at({ unit: "" })).toEqual(["unit"]); + expect(at({ enteredUnit: "x".repeat(8) })).toEqual([]); + expect(at({ enteredUnit: "x".repeat(9) })).toEqual(["enteredUnit"]); + expect(at({ id: "" })).toEqual(["id"]); + }); +}); + +describe("an intake row", () => { + it("names its medication, and refuses an empty one or empty ids", () => { + const row = { medication: "Ramipril", scheduledFor: AT }; + expect(refused(intakeEventSchema, row)).toEqual([]); + expect(refused(intakeEventSchema, { ...row, medication: "" })).toEqual([ + "medication", + ]); + expect(refused(intakeEventSchema, { ...row, id: "" })).toEqual(["id"]); + expect(refused(intakeEventSchema, { ...row, medicationId: "" })).toEqual([ + "medicationId", + ]); + }); +}); + +describe("a custom cycle symptom", () => { + const symptom = { + key: "custom-cramps", + labelKey: "cycle.symptom.custom", + categoryId: "cat-1", + }; + + it("defaults to active at the top of its list", () => { + const out = parsed(customCycleSymptomSchema, symptom) as { + isActive: unknown; + sortOrder: unknown; + }; + expect(out.isActive).toBe(true); + expect(out.sortOrder).toBe(0); + }); + + it("refuses an empty key, label key, category or id", () => { + for (const field of ["key", "labelKey", "categoryId", "id"]) { + expect( + refused(customCycleSymptomSchema, { ...symptom, [field]: "" }), + field, + ).toEqual([field]); + } + }); +}); diff --git a/stryker.floors.json b/stryker.floors.json index 32b7f0ab1..4e39740e0 100644 --- a/stryker.floors.json +++ b/stryker.floors.json @@ -61,7 +61,7 @@ "src/lib/export/backup-schema-kit.ts", "src/lib/cycle/backup-schema.ts" ], - "floor": 41 + "floor": 53 } } } From 1ac69420d533700ba094f93f3c47b566fd4a1397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 22:22:32 +0100 Subject: [PATCH 09/10] test: reset to the factory answer instead of casting a new one A reset mock goes back to the implementation its factory gave it, which is the fresh session and the open capability these cases want; the cast replacements grew the test-realism count. --- .../export/__tests__/soft-delete-filter.test.ts | 14 ++++---------- .../chat/[id]/attachments/__tests__/route.test.ts | 13 ++++--------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 50363cc58..5b3478183 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -174,16 +174,10 @@ beforeEach(() => { vi.mocked(isModuleEnabled).mockResolvedValue(true); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - // A session that signed in just now. Reset, not only cleared: a case below - // swaps in a stale session, and `clearAllMocks` keeps the implementation - // it left behind for whichever case runs next. - vi.mocked(prisma.session.findUnique) - .mockReset() - .mockImplementation((async () => ({ - createdAt: new Date(), - mfaVerifiedAt: null, - reproofAt: null, - })) as never); + // A session that signed in just now, the factory's own answer. Reset, not + // only cleared: a case below swaps in a stale session, and `clearAllMocks` + // keeps the implementation it left behind for whichever case runs next. + vi.mocked(prisma.session.findUnique).mockReset(); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, limit: 60, diff --git a/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts index be0ab6d2e..f5636ee79 100644 --- a/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts +++ b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts @@ -87,15 +87,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(validateAttachmentCandidate).mockResolvedValue({ ok: true }); - // Open again for every case: the detach case closes every capability, and - // `clearAllMocks` would carry that refusal into whichever case runs next. - vi.mocked(requireAiCapability) - .mockReset() - .mockResolvedValue({ - available: true, - reason: null, - onDeviceAllowed: true, - } as never); + // Open again for every case (the factory's own answer): the detach case + // closes every capability, and `clearAllMocks` would carry that refusal + // into whichever case runs next. + vi.mocked(requireAiCapability).mockReset(); }); describe("POST attach", () => { From a598b000817ca2675fab5c12149e185990f3bae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 22:25:43 +0100 Subject: [PATCH 10/10] test(coach): start every snapshot case with no readings The query-shape case read the measurement mock without setting it and passed only after a case that left rows behind. --- src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 97f0e68be..1d4c27189 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -94,6 +94,9 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => // for 60 s; reset between tests so each fixture is read fresh. __resetCoachSnapshotCacheForTests(); resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); + // No readings unless a case seeds its own; the case that only inspects + // the query used to inherit whatever rows the case before it left. + prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); prismaMock.medication.findMany.mockResolvedValue([]);