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/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..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,6 +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, 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 00c7e8c2d..4ef25daff 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 @@ -84,6 +84,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(validateAttachmentCandidate).mockResolvedValue({ ok: true }); + // 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", () => { 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/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 () => { 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([]); 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/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, 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/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", 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 } } } diff --git a/tests/integration/ai-optional-data-routes.test.ts b/tests/integration/ai-optional-data-routes.test.ts index 303682912..6c21e2173 100644 --- a/tests/integration/ai-optional-data-routes.test.ts +++ b/tests/integration/ai-optional-data-routes.test.ts @@ -253,7 +253,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(); @@ -261,8 +274,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); } @@ -284,12 +296,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/backup-parity.test.ts b/tests/integration/backup-parity.test.ts index c8e2d98f1..b6af6afd6 100644 --- a/tests/integration/backup-parity.test.ts +++ b/tests/integration/backup-parity.test.ts @@ -1109,7 +1109,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/google-health-backfill-multi-year.test.ts b/tests/integration/google-health-backfill-multi-year.test.ts index 659042467..ec5dfcd3d 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"; @@ -320,10 +320,6 @@ async function seed(points: number): Promise { fake.upperBoundRequests = 0; } -beforeAll(async () => { - await seed(POINTS); -}, 120_000); - afterAll(async () => { await truncateAllTables(getPrismaClient()); }); @@ -353,6 +349,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/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 8e2742407..72a4fe247 100644 --- a/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts +++ b/tests/integration/hrv-rmssd-fitbit-google-migration.test.ts @@ -177,7 +177,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/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/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([ diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts index 6ff07e615..0e079601b 100644 --- a/tests/integration/labs-unit-conversion.test.ts +++ b/tests/integration/labs-unit-conversion.test.ts @@ -126,244 +126,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/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/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/query-budget-hot-routes.test.ts b/tests/integration/query-budget-hot-routes.test.ts index 6d8a6af4c..0b89a37d1 100644 --- a/tests/integration/query-budget-hot-routes.test.ts +++ b/tests/integration/query-budget-hot-routes.test.ts @@ -424,15 +424,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/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index e04587f97..00cf0ae69 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -90,7 +90,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, @@ -189,6 +191,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 @@ -199,7 +209,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, @@ -381,6 +391,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", @@ -393,7 +412,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, @@ -403,12 +422,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, @@ -416,7 +435,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/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/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 }); }); 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([]); + }); + }, + ); }); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 247513c75..12f5c2159 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"; @@ -272,6 +272,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", ); @@ -305,6 +307,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); @@ -381,6 +384,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); @@ -3019,10 +3023,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); });