From 713806fe9bd8114e7cdf2de3f870d65a74e945cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:35:56 +0100 Subject: [PATCH] test(parity): keep each route walk in order under a shuffled run A parity file is one account walked through a route's cases, each request reading what the earlier ones stored, and the recorded files pin that sequence. The nightly shuffle reordered the steps and every snapshot after the first moved. Keep each walk in order with shuffle: false, wrapping the files whose top-level blocks build on each other, and note the rule for order-bound files in the testing guide. --- docs/contributing/testing.md | 11 + .../routes/parity/analytics.parity.test.ts | 2 +- .../parity/dashboard-summary.parity.test.ts | 2 +- .../parity/dashboard-widgets.parity.test.ts | 2 +- .../parity/documents-inbound.parity.test.ts | 236 +++--- .../routes/parity/import-csv.parity.test.ts | 2 +- .../routes/parity/import-json.parity.test.ts | 2 +- .../insights-comprehensive.parity.test.ts | 2 +- .../parity/insights-generate.parity.test.ts | 732 +++++++++--------- .../parity/measurement-series.parity.test.ts | 2 +- .../parity/measurements-batch.parity.test.ts | 2 +- .../routes/parity/measurements.parity.test.ts | 349 +++++---- .../parity/medication-detail.parity.test.ts | 404 +++++----- .../medication-intake-bulk.parity.test.ts | 2 +- .../medication-intake-event.parity.test.ts | 232 +++--- .../medication-intake-status.parity.test.ts | 237 +++--- .../parity/medication-intake.parity.test.ts | 342 ++++---- .../routes/parity/medications.parity.test.ts | 357 ++++----- .../parity/mood-entries-bulk.parity.test.ts | 2 +- .../parity/oidc-callback.parity.test.ts | 2 +- .../routes/parity/sync-changes.parity.test.ts | 2 +- .../parity/tokens-mirror.parity.test.ts | 122 +-- .../parity/workout-detail.parity.test.ts | 108 +-- .../parity/workouts-batch.parity.test.ts | 2 +- 24 files changed, 1613 insertions(+), 1543 deletions(-) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 18e2a30b2..c7694cad1 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -284,3 +284,14 @@ earns its place; do not extend the date. Order dependence is looked for on purpose: the nightly workflow runs both suites with `--sequence.shuffle` and prints the seed. Replay a failure with `--sequence.shuffle --sequence.seed=`. + +A case resets or states what it reads: `vi.resetAllMocks()` (or +`mockReset()`) where a case swaps in its own implementation, since +`clearAllMocks` keeps it and a `mockResolvedValueOnce` the code never consumed +answers the next case; a module cache dropped through its own invalidator; a +row the case needs written by the case or its own `beforeAll`. A file that is +one walk by design (a parity sequence, a migration's before and after, a +dry run followed by the import and the same file again) says so in a comment +and passes `{ shuffle: false }` to its `describe`, which keeps that block in +order while the rest of the suite still shuffles. Wrap the whole file in one +such block when its top-level blocks build on each other. diff --git a/tests/integration/routes/parity/analytics.parity.test.ts b/tests/integration/routes/parity/analytics.parity.test.ts index d68a489d9..c251e592b 100644 --- a/tests/integration/routes/parity/analytics.parity.test.ts +++ b/tests/integration/routes/parity/analytics.parity.test.ts @@ -210,7 +210,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/analytics parity", () => { +describe("GET /api/analytics parity", { shuffle: false }, () => { it("01 a slice outside the closed set", async () => { await step("01-invalid-slice", { query: { slice: "summary" } }); }); diff --git a/tests/integration/routes/parity/dashboard-summary.parity.test.ts b/tests/integration/routes/parity/dashboard-summary.parity.test.ts index 7467d68fe..4840f50a8 100644 --- a/tests/integration/routes/parity/dashboard-summary.parity.test.ts +++ b/tests/integration/routes/parity/dashboard-summary.parity.test.ts @@ -159,7 +159,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/dashboard/summary parity", () => { +describe("GET /api/dashboard/summary parity", { shuffle: false }, () => { it("01 cold, then warm", async () => { await step("01-cold"); await step("02-warm"); diff --git a/tests/integration/routes/parity/dashboard-widgets.parity.test.ts b/tests/integration/routes/parity/dashboard-widgets.parity.test.ts index 0faca0745..fa2bdfa03 100644 --- a/tests/integration/routes/parity/dashboard-widgets.parity.test.ts +++ b/tests/integration/routes/parity/dashboard-widgets.parity.test.ts @@ -169,7 +169,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("/api/dashboard/widgets parity", () => { +describe("/api/dashboard/widgets parity", { shuffle: false }, () => { let lastToken = ""; it("01 the first read, filled into the cache", async () => { diff --git a/tests/integration/routes/parity/documents-inbound.parity.test.ts b/tests/integration/routes/parity/documents-inbound.parity.test.ts index 81ef63c24..173012620 100644 --- a/tests/integration/routes/parity/documents-inbound.parity.test.ts +++ b/tests/integration/routes/parity/documents-inbound.parity.test.ts @@ -215,129 +215,133 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/documents/inbound parity", () => { - it("a stored upload and the same bytes again", async () => { - const body = () => - form(PDF("discharge"), "discharge.pdf", { - title: "Discharge letter", - kind: "DISCHARGE_LETTER", - documentDate: "2026-10-02", - episodeIds: [episodeId], +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("documents-inbound parity walk", { shuffle: false }, () => { + describe("POST /api/documents/inbound parity", () => { + it("a stored upload and the same bytes again", async () => { + const body = () => + form(PDF("discharge"), "discharge.pdf", { + title: "Discharge letter", + kind: "DISCHARGE_LETTER", + documentDate: "2026-10-02", + episodeIds: [episodeId], + }); + await upload("post-01-stored", body()); + await upload("post-02-duplicate", body()); + await upload( + "post-03-second", + form(PDF("labs"), "labs.pdf", { + title: "Blood test", + kind: "LAB_RESULT", + documentDate: "2025-03-04", + }), + ); + }); + + it("the refusals", async () => { + await upload("post-04-no-file", form(null, "x", { title: "Nothing" })); + await upload( + "post-05-invalid-metadata", + form(PDF("bad-kind"), "bad.pdf", { kind: "SHOPPING_LIST" }), + ); + await upload("post-06-empty", form(Buffer.alloc(0), "empty.pdf")); + await upload( + "post-07-unsupported", + form( + Buffer.from("hi\n"), + "page.html", + ), + ); + await upload( + "post-08-unknown-episode", + form(PDF("orphan"), "orphan.pdf", { + episodeIds: ["cmissingepisode000000000"], + }), + ); + await upload("post-09-half-key", form(PDF("half"), "half.pdf"), { + query: { sourceSystem: "PAPERLESS" }, }); - await upload("post-01-stored", body()); - await upload("post-02-duplicate", body()); - await upload( - "post-03-second", - form(PDF("labs"), "labs.pdf", { - title: "Blood test", - kind: "LAB_RESULT", - documentDate: "2025-03-04", - }), - ); + await upload( + "post-10-keys-differ", + form(PDF("differ"), "differ.pdf", { + sourceSystem: "PAPERLESS", + sourceId: "77", + }), + { query: { sourceSystem: "PAPERLESS", sourceId: "78" } }, + ); + }); + + it("a narrow token storing under a source key", async () => { + const stored = await upload( + "post-11-token-stored", + form(PDF("imported"), "imported.pdf", { + sourceSystem: "PAPERLESS", + sourceId: "41", + aiRead: "defer", + }), + { auth: writer }, + ); + await upload("post-12-token-query-key", form(PDF("imported"), "x.pdf"), { + auth: writer, + query: { sourceSystem: "PAPERLESS", sourceId: "41" }, + }); + await prisma.inboundDocument.update({ + where: { id: stored.id! }, + data: { deletedAt: new Date("2026-10-14T09:00:00.000Z") }, + }); + await upload( + "post-13-token-deleted", + form(PDF("imported"), "imported.pdf", { + sourceSystem: "PAPERLESS", + sourceId: "41", + }), + { auth: writer }, + ); + }); + + it("the module switched off", async () => { + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { inboundDocuments: false } }, + }); + await upload("post-14-module-off", form(PDF("dark"), "dark.pdf")); + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { inboundDocuments: true } }, + }); + }); }); - it("the refusals", async () => { - await upload("post-04-no-file", form(null, "x", { title: "Nothing" })); - await upload( - "post-05-invalid-metadata", - form(PDF("bad-kind"), "bad.pdf", { kind: "SHOPPING_LIST" }), - ); - await upload("post-06-empty", form(Buffer.alloc(0), "empty.pdf")); - await upload( - "post-07-unsupported", - form( - Buffer.from("hi\n"), - "page.html", - ), - ); - await upload( - "post-08-unknown-episode", - form(PDF("orphan"), "orphan.pdf", { - episodeIds: ["cmissingepisode000000000"], - }), - ); - await upload("post-09-half-key", form(PDF("half"), "half.pdf"), { - query: { sourceSystem: "PAPERLESS" }, + describe("GET /api/documents/inbound parity", () => { + it("the default page, a search and the filters", async () => { + await list("get-01-default", {}); + await list("get-02-search", { q: "blood" }); + await list("get-03-kind", { kind: "DISCHARGE_LETTER,LAB_RESULT" }); + await list("get-04-episode", { episodeId }); + await list("get-05-year", { year: 2025 }); + await list("get-06-range", { from: "2026-10-01", to: "2026-10-02" }); }); - await upload( - "post-10-keys-differ", - form(PDF("differ"), "differ.pdf", { - sourceSystem: "PAPERLESS", - sourceId: "77", - }), - { query: { sourceSystem: "PAPERLESS", sourceId: "78" } }, - ); - }); - it("a narrow token storing under a source key", async () => { - const stored = await upload( - "post-11-token-stored", - form(PDF("imported"), "imported.pdf", { - sourceSystem: "PAPERLESS", - sourceId: "41", - aiRead: "defer", - }), - { auth: writer }, - ); - await upload("post-12-token-query-key", form(PDF("imported"), "x.pdf"), { - auth: writer, - query: { sourceSystem: "PAPERLESS", sourceId: "41" }, + it("a sorted page and the page after it", async () => { + const first = await list("get-07-sorted", { + sort: "title", + order: "asc", + limit: 1, + }); + await list("get-08-next-page", { + sort: "title", + order: "asc", + limit: 1, + cursor: first.nextCursor!, + }); }); - await prisma.inboundDocument.update({ - where: { id: stored.id! }, - data: { deletedAt: new Date("2026-10-14T09:00:00.000Z") }, - }); - await upload( - "post-13-token-deleted", - form(PDF("imported"), "imported.pdf", { - sourceSystem: "PAPERLESS", - sourceId: "41", - }), - { auth: writer }, - ); - }); - it("the module switched off", async () => { - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: { inboundDocuments: false } }, - }); - await upload("post-14-module-off", form(PDF("dark"), "dark.pdf")); - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: { inboundDocuments: true } }, + it("a refused query, and a delegate reading the record", async () => { + await list("get-09-invalid", { sort: "size" }); + await switchSessionTo(delegate.sessionId, userId); + await list("get-10-delegated", {}, delegate); + await switchSessionTo(delegate.sessionId, null); }); }); }); - -describe("GET /api/documents/inbound parity", () => { - it("the default page, a search and the filters", async () => { - await list("get-01-default", {}); - await list("get-02-search", { q: "blood" }); - await list("get-03-kind", { kind: "DISCHARGE_LETTER,LAB_RESULT" }); - await list("get-04-episode", { episodeId }); - await list("get-05-year", { year: 2025 }); - await list("get-06-range", { from: "2026-10-01", to: "2026-10-02" }); - }); - - it("a sorted page and the page after it", async () => { - const first = await list("get-07-sorted", { - sort: "title", - order: "asc", - limit: 1, - }); - await list("get-08-next-page", { - sort: "title", - order: "asc", - limit: 1, - cursor: first.nextCursor!, - }); - }); - - it("a refused query, and a delegate reading the record", async () => { - await list("get-09-invalid", { sort: "size" }); - await switchSessionTo(delegate.sessionId, userId); - await list("get-10-delegated", {}, delegate); - await switchSessionTo(delegate.sessionId, null); - }); -}); diff --git a/tests/integration/routes/parity/import-csv.parity.test.ts b/tests/integration/routes/parity/import-csv.parity.test.ts index 60ee5ba50..2a84aac5b 100644 --- a/tests/integration/routes/parity/import-csv.parity.test.ts +++ b/tests/integration/routes/parity/import-csv.parity.test.ts @@ -199,7 +199,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/import/csv parity", () => { +describe("POST /api/import/csv parity", { shuffle: false }, () => { it("01 a dry run", async () => { await step("01-dry-run", FILE, { dryRun: "1" }); }); diff --git a/tests/integration/routes/parity/import-json.parity.test.ts b/tests/integration/routes/parity/import-json.parity.test.ts index 265de2378..a63e05b6f 100644 --- a/tests/integration/routes/parity/import-json.parity.test.ts +++ b/tests/integration/routes/parity/import-json.parity.test.ts @@ -263,7 +263,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/import parity", () => { +describe("POST /api/import parity", { shuffle: false }, () => { it("01 a first file", async () => { await step("01-first-file", FILE); }); diff --git a/tests/integration/routes/parity/insights-comprehensive.parity.test.ts b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts index 72c1ae5b9..78adab744 100644 --- a/tests/integration/routes/parity/insights-comprehensive.parity.test.ts +++ b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts @@ -408,7 +408,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/insights/comprehensive parity", () => { +describe("GET /api/insights/comprehensive parity", { shuffle: false }, () => { it("01 an anonymous call", async () => { await step("01-anonymous", { auth: anonymous }); }); diff --git a/tests/integration/routes/parity/insights-generate.parity.test.ts b/tests/integration/routes/parity/insights-generate.parity.test.ts index 0cdb9d831..b451b8b07 100644 --- a/tests/integration/routes/parity/insights-generate.parity.test.ts +++ b/tests/integration/routes/parity/insights-generate.parity.test.ts @@ -635,380 +635,394 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/insights/generate parity", () => { - it("G01 an anonymous call", async () => { - await get("G01-anonymous", { auth: anonymous }); - }); - - it("G02 the AI analysis opt-out", async () => { - await get("G02-opted-out", { auth: optedOut, userId: OPTED_OUT_ID }); - }); - - it("G03 no cached briefing", async () => { - await resetBriefing(OWNER_ID); - await get("G03-empty"); - }); - - it("G04 a briefing written today", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "en", +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("insights-generate parity walk", { shuffle: false }, () => { + describe("GET /api/insights/generate parity", () => { + it("G01 an anonymous call", async () => { + await get("G01-anonymous", { auth: anonymous }); }); - await get("G04-fresh"); - }); - it("G05 a briefing written yesterday", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), - cachedAt: new Date(NOW.getTime() - 2 * HOUR), - cachedLocale: "en", + it("G02 the AI analysis opt-out", async () => { + await get("G02-opted-out", { auth: optedOut, userId: OPTED_OUT_ID }); }); - await get("G05-stale"); - }); - it("G06 a briefing in another language", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "de", - }); - await get("G06-other-language"); - }); - - it("G07 an unparseable cache row", async () => { - await resetBriefing(OWNER_ID, { - text: "{not json", - cachedAt: new Date(NOW.getTime() - HOUR), - }); - await get("G07-unparseable"); - }); - - it("G08 a legacy payload, untagged", async () => { - await resetBriefing(OWNER_ID, { - text: { summary: "Legacy", recommendations: ["Drink water."] }, - cachedAt: new Date(NOW.getTime() - HOUR), - }); - await get("G08-legacy"); - }); - - it("G09 a failure newer than the last good briefing", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 3 * HOUR)), - cachedAt: new Date(NOW.getTime() - 3 * HOUR), - cachedLocale: "en", - markers: [ - { - reason: "all-providers-failed", - at: new Date(NOW.getTime() - HOUR), - httpStatus: 429, - }, - ], - }); - await get("G09-held-after-failure"); - }); - - it("G10 a failure with no good briefing, and an older superseded one", async () => { - await resetBriefing(OWNER_ID, { - markers: [ - { reason: "provider-error", at: new Date(NOW.getTime() - 5 * HOUR) }, - { reason: "invalid-json", at: new Date(NOW.getTime() - HOUR) }, - ], - }); - await get("G10-failed-empty"); - }); - - it("G11 a briefing the grounding gate withheld", async () => { - await resetBriefing(OWNER_ID, { - text: cached(null, new Date(NOW.getTime() - 2 * HOUR)), - cachedAt: new Date(NOW.getTime() - 2 * HOUR), - cachedLocale: "en", - markers: [ - { reason: "briefing-ungrounded", at: new Date(NOW.getTime() - HOUR) }, - ], - }); - await get("G11-withheld"); - }); -}); - -describe("POST /api/insights/generate parity", () => { - it("P01 an anonymous call", async () => { - await post("P01-anonymous", { force: true }, { auth: anonymous }); - }); - - it("P02 the AI analysis opt-out", async () => { - await post( - "P02-opted-out", - { force: true }, - { auth: optedOut, userId: OPTED_OUT_ID }, - ); - }); - - it("P03 a body over the cap", async () => { - await resetBriefing(OWNER_ID); - await post("P03-body-too-large", { pad: "x".repeat(17 * 1024) }); - }); - - it("P04 a cached briefing written today", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "en", - }); - await post("P04-cache-hit", {}); - }); - - it("P05 a cached briefing from yesterday, regenerated", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), - cachedAt: new Date(NOW.getTime() - 2 * HOUR), - cachedLocale: "en", - }); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P05-yesterday-regenerated", {}); - }); - - it("P06 a cache in another language, regenerated", async () => { - await resetBriefing(OWNER_ID, { - text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "de", - }); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P06-other-language-regenerated", {}); - }); - - it("P07 a briefingless cache when the hour's quota is spent", async () => { - await resetBriefing(OWNER_ID, { - text: cached(null, new Date(NOW.getTime() - HOUR)), - cachedAt: new Date(NOW.getTime() - HOUR), - cachedLocale: "en", - quotaUsed: 10, - }); - await post("P07-quota-cached-fallback", {}); - }); - - it("P08 a forced generation when the hour's quota is spent", async () => { - await resetBriefing(OWNER_ID, { quotaUsed: 10 }); - await post("P08-quota-refused"); - }); - - it("P09 a key no resolver can open", async () => { - await resetBriefing(UNREADABLE_KEY_ID); - await post( - "P09-unreadable-key", - { force: true }, - { auth: unreadableKey, userId: UNREADABLE_KEY_ID }, - ); - }); - - it("P10 a fresh generation", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ content: reply(GROUNDED_BRIEFING), tokensUsed: 2100 }); - await post("P10-generated"); - }); - - it("P11 a reply the strict schema accepts", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING, { disclaimer: "Consult your doctor." }), - }); - await post("P11-strict-reply"); - }); - - it("P12 a generation in German with the comparison, about-me and short form", async () => { - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { - locale: "de", - aiUsageLevel: "FRUGAL", - dashboardWidgetsJson: { comparisonBaseline: "lastMonth" }, - }, - }); - await prisma.allergy.create({ - data: { userId: OWNER_ID, substance: "Penicillin" }, - }); - await resetBriefing(OWNER_ID); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P12-generated-de-short"); - await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { locale: "en", aiUsageLevel: null, dashboardWidgetsJson: {} }, - }); - }); - - it("P13 an oversized raw payload, downgraded once", async () => { - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { insightsPrivacyMode: "raw" }, - }); - await resetBriefing(OWNER_ID); - seams.oversize.push(6_100_000); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P13-downgraded"); - }); - - it("P14 an oversized aggregate too, downgraded twice", async () => { - await resetBriefing(OWNER_ID); - seams.oversize.push(6_100_000, 5_400_000); - seams.script.push({ content: reply(GROUNDED_BRIEFING) }); - await post("P14-hard-downgraded"); - }); - - it("P15 oversized on every rung", async () => { - await resetBriefing(OWNER_ID); - seams.oversize.push(6_100_000, 5_400_000, 5_200_000); - await post("P15-too-large"); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { insightsPrivacyMode: "aggregated" }, - }); - }); - - it("P16 the day's token ceiling", async () => { - await resetBriefing(OWNER_ID, { spentToday: 5_000_000 }); - await post("P16-budget-exceeded"); - }); - - it("P17 every provider refused the credential", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(401), hop(403, 2)]), - }); - await post("P17-chain-auth"); - }); - - it("P18 every provider rate-limited", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ error: () => new AllProvidersFailedError([hop(429)]) }); - await post("P18-chain-rate-limited"); - }); - - it("P19 a provider in a 5xx", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(401), hop(502, 2)]), - }); - await post("P19-chain-5xx"); - }); - - it("P20 every provider unreachable", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(null), hop(null, 2)]), - }); - await post("P20-chain-transport"); - }); - - it("P21 a mixed chain failure", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - error: () => new AllProvidersFailedError([hop(404), hop(429, 2)]), - }); - await post("P21-chain-mixed"); - }); - - it("P22 one provider error per status class", async () => { - for (const status of [401, 429, 500, 400]) { + it("G03 no cached briefing", async () => { await resetBriefing(OWNER_ID); - seams.script.push({ error: providerError(status) }); - await post(`P22-provider-${status}`); - } - }); - - it("P23 a reply that is not JSON", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ content: "Sure! Here is your briefing." }); - await post("P23-invalid-json"); - }); - - it("P24 a reply cut off at the token ceiling", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ content: '{"summary": "Your', finishReason: "length" }); - await post("P24-truncated"); - }); - - it("P25 an ungrounded briefing the retry fixes", async () => { - await resetBriefing(OWNER_ID); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: reply(GROUNDED_BRIEFING), tokensUsed: 900 }, - ); - await post("P25-grounding-retry-kept"); - }); - - it("P26 still ungrounded, the previous briefing stands in", async () => { - await resetBriefing(OWNER_ID, { - text: cached(PREVIOUS_BRIEFING, new Date(NOW.getTime() - 26 * HOUR)), - cachedAt: new Date(NOW.getTime() - 26 * HOUR), - cachedLocale: "en", + await get("G03-empty"); }); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: reply(UNGROUNDED_BRIEFING) }, - ); - await post("P26-grounding-previous"); - }); - it("P27 still ungrounded, nothing to stand in", async () => { - await resetBriefing(OWNER_ID); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: reply(UNGROUNDED_BRIEFING) }, - ); - await post("P27-grounding-stripped"); - }); + it("G04 a briefing written today", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + }); + await get("G04-fresh"); + }); - it("P28 an ungrounded briefing whose retry is not JSON", async () => { - await resetBriefing(OWNER_ID); - seams.script.push( - { content: reply(UNGROUNDED_BRIEFING) }, - { content: "not json" }, - ); - await post("P28-grounding-retry-unparseable"); - }); + it("G05 a briefing written yesterday", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + }); + await get("G05-stale"); + }); - it("P29 a reply the outbound screen blocks", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING, { - summary: - "Your 10-year cardiovascular risk is elevated — consider stepping your dose up.", - }), + it("G06 a briefing in another language", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "de", + }); + await get("G06-other-language"); + }); + + it("G07 an unparseable cache row", async () => { + await resetBriefing(OWNER_ID, { + text: "{not json", + cachedAt: new Date(NOW.getTime() - HOUR), + }); + await get("G07-unparseable"); + }); + + it("G08 a legacy payload, untagged", async () => { + await resetBriefing(OWNER_ID, { + text: { summary: "Legacy", recommendations: ["Drink water."] }, + cachedAt: new Date(NOW.getTime() - HOUR), + }); + await get("G08-legacy"); + }); + + it("G09 a failure newer than the last good briefing", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 3 * HOUR)), + cachedAt: new Date(NOW.getTime() - 3 * HOUR), + cachedLocale: "en", + markers: [ + { + reason: "all-providers-failed", + at: new Date(NOW.getTime() - HOUR), + httpStatus: 429, + }, + ], + }); + await get("G09-held-after-failure"); + }); + + it("G10 a failure with no good briefing, and an older superseded one", async () => { + await resetBriefing(OWNER_ID, { + markers: [ + { reason: "provider-error", at: new Date(NOW.getTime() - 5 * HOUR) }, + { reason: "invalid-json", at: new Date(NOW.getTime() - HOUR) }, + ], + }); + await get("G10-failed-empty"); + }); + + it("G11 a briefing the grounding gate withheld", async () => { + await resetBriefing(OWNER_ID, { + text: cached(null, new Date(NOW.getTime() - 2 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + markers: [ + { reason: "briefing-ungrounded", at: new Date(NOW.getTime() - HOUR) }, + ], + }); + await get("G11-withheld"); }); - await post("P29-outbound-screened", { force: true }); }); - it("P30 the about-me text changing during the generation", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING), - during: async () => { - await prisma.allergy.create({ - data: { userId: OWNER_ID, substance: "Latex" }, - }); - }, + describe("POST /api/insights/generate parity", () => { + it("P01 an anonymous call", async () => { + await post("P01-anonymous", { force: true }, { auth: anonymous }); }); - await post("P30-profile-scope-changed"); - await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); - }); - it("P31 the profile changing during the generation", async () => { - await resetBriefing(OWNER_ID); - seams.script.push({ - content: reply(GROUNDED_BRIEFING), - during: async () => { - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { displayName: "Renamed Person" }, - }); - }, + it("P02 the AI analysis opt-out", async () => { + await post( + "P02-opted-out", + { force: true }, + { auth: optedOut, userId: OPTED_OUT_ID }, + ); }); - await post("P31-scope-changed"); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { displayName: "Parity Person" }, + + it("P03 a body over the cap", async () => { + await resetBriefing(OWNER_ID); + await post("P03-body-too-large", { pad: "x".repeat(17 * 1024) }); + }); + + it("P04 a cached briefing written today", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + }); + await post("P04-cache-hit", {}); + }); + + it("P05 a cached briefing from yesterday, regenerated", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + }); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P05-yesterday-regenerated", {}); + }); + + it("P06 a cache in another language, regenerated", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "de", + }); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P06-other-language-regenerated", {}); + }); + + it("P07 a briefingless cache when the hour's quota is spent", async () => { + await resetBriefing(OWNER_ID, { + text: cached(null, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + quotaUsed: 10, + }); + await post("P07-quota-cached-fallback", {}); + }); + + it("P08 a forced generation when the hour's quota is spent", async () => { + await resetBriefing(OWNER_ID, { quotaUsed: 10 }); + await post("P08-quota-refused"); + }); + + it("P09 a key no resolver can open", async () => { + await resetBriefing(UNREADABLE_KEY_ID); + await post( + "P09-unreadable-key", + { force: true }, + { auth: unreadableKey, userId: UNREADABLE_KEY_ID }, + ); + }); + + it("P10 a fresh generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + tokensUsed: 2100, + }); + await post("P10-generated"); + }); + + it("P11 a reply the strict schema accepts", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING, { + disclaimer: "Consult your doctor.", + }), + }); + await post("P11-strict-reply"); + }); + + it("P12 a generation in German with the comparison, about-me and short form", async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { + locale: "de", + aiUsageLevel: "FRUGAL", + dashboardWidgetsJson: { comparisonBaseline: "lastMonth" }, + }, + }); + await prisma.allergy.create({ + data: { userId: OWNER_ID, substance: "Penicillin" }, + }); + await resetBriefing(OWNER_ID); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P12-generated-de-short"); + await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { locale: "en", aiUsageLevel: null, dashboardWidgetsJson: {} }, + }); + }); + + it("P13 an oversized raw payload, downgraded once", async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { insightsPrivacyMode: "raw" }, + }); + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P13-downgraded"); + }); + + it("P14 an oversized aggregate too, downgraded twice", async () => { + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000, 5_400_000); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P14-hard-downgraded"); + }); + + it("P15 oversized on every rung", async () => { + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000, 5_400_000, 5_200_000); + await post("P15-too-large"); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { insightsPrivacyMode: "aggregated" }, + }); + }); + + it("P16 the day's token ceiling", async () => { + await resetBriefing(OWNER_ID, { spentToday: 5_000_000 }); + await post("P16-budget-exceeded"); + }); + + it("P17 every provider refused the credential", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(401), hop(403, 2)]), + }); + await post("P17-chain-auth"); + }); + + it("P18 every provider rate-limited", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(429)]), + }); + await post("P18-chain-rate-limited"); + }); + + it("P19 a provider in a 5xx", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(401), hop(502, 2)]), + }); + await post("P19-chain-5xx"); + }); + + it("P20 every provider unreachable", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(null), hop(null, 2)]), + }); + await post("P20-chain-transport"); + }); + + it("P21 a mixed chain failure", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(404), hop(429, 2)]), + }); + await post("P21-chain-mixed"); + }); + + it("P22 one provider error per status class", async () => { + for (const status of [401, 429, 500, 400]) { + await resetBriefing(OWNER_ID); + seams.script.push({ error: providerError(status) }); + await post(`P22-provider-${status}`); + } + }); + + it("P23 a reply that is not JSON", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ content: "Sure! Here is your briefing." }); + await post("P23-invalid-json"); + }); + + it("P24 a reply cut off at the token ceiling", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: '{"summary": "Your', + finishReason: "length", + }); + await post("P24-truncated"); + }); + + it("P25 an ungrounded briefing the retry fixes", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(GROUNDED_BRIEFING), tokensUsed: 900 }, + ); + await post("P25-grounding-retry-kept"); + }); + + it("P26 still ungrounded, the previous briefing stands in", async () => { + await resetBriefing(OWNER_ID, { + text: cached(PREVIOUS_BRIEFING, new Date(NOW.getTime() - 26 * HOUR)), + cachedAt: new Date(NOW.getTime() - 26 * HOUR), + cachedLocale: "en", + }); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(UNGROUNDED_BRIEFING) }, + ); + await post("P26-grounding-previous"); + }); + + it("P27 still ungrounded, nothing to stand in", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(UNGROUNDED_BRIEFING) }, + ); + await post("P27-grounding-stripped"); + }); + + it("P28 an ungrounded briefing whose retry is not JSON", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: "not json" }, + ); + await post("P28-grounding-retry-unparseable"); + }); + + it("P29 a reply the outbound screen blocks", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING, { + summary: + "Your 10-year cardiovascular risk is elevated — consider stepping your dose up.", + }), + }); + await post("P29-outbound-screened", { force: true }); + }); + + it("P30 the about-me text changing during the generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + during: async () => { + await prisma.allergy.create({ + data: { userId: OWNER_ID, substance: "Latex" }, + }); + }, + }); + await post("P30-profile-scope-changed"); + await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); + }); + + it("P31 the profile changing during the generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + during: async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { displayName: "Renamed Person" }, + }); + }, + }); + await post("P31-scope-changed"); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { displayName: "Parity Person" }, + }); }); }); }); diff --git a/tests/integration/routes/parity/measurement-series.parity.test.ts b/tests/integration/routes/parity/measurement-series.parity.test.ts index 3c2e82bc6..9b9f9f2c5 100644 --- a/tests/integration/routes/parity/measurement-series.parity.test.ts +++ b/tests/integration/routes/parity/measurement-series.parity.test.ts @@ -239,7 +239,7 @@ beforeAll(async () => { delegate = { auth: session }; }); -describe("GET /api/measurements/series parity", () => { +describe("GET /api/measurements/series parity", { shuffle: false }, () => { it("the plain readers", async () => { await step("01-weight", { kind: "weight" }); await step("02-steps", { kind: "steps", days: 14 }); diff --git a/tests/integration/routes/parity/measurements-batch.parity.test.ts b/tests/integration/routes/parity/measurements-batch.parity.test.ts index 2cb309931..9315aa8ab 100644 --- a/tests/integration/routes/parity/measurements-batch.parity.test.ts +++ b/tests/integration/routes/parity/measurements-batch.parity.test.ts @@ -327,7 +327,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/measurements/batch parity", () => { +describe("POST /api/measurements/batch parity", { shuffle: false }, () => { it("01 a first sync of mixed entries", async () => { await step("01-first-sync", { entries: FIRST_SYNC, diff --git a/tests/integration/routes/parity/measurements.parity.test.ts b/tests/integration/routes/parity/measurements.parity.test.ts index 5055c39e0..8401da77f 100644 --- a/tests/integration/routes/parity/measurements.parity.test.ts +++ b/tests/integration/routes/parity/measurements.parity.test.ts @@ -220,185 +220,192 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/measurements parity", () => { - it("a single reading, its duplicate, a refused one", async () => { - const weight = { - type: "WEIGHT", - value: 80.4, - measuredAt: "2026-10-14T06:30:00.000Z", - notes: "after the run", - deviceType: "scale", - }; - await post("post-01-single", weight); - await post("post-02-single-duplicate", weight); - await post("post-03-single-invalid", { - type: "WEIGHT", - value: "heavy", - measuredAt: "2026-10-14T06:30:00.000Z", - }); - await post("post-04-single-glucose", { - type: "BLOOD_GLUCOSE", - value: 104, - measuredAt: "2026-10-14T07:00:00.000Z", - glucoseContext: "FASTING", - symptomsPresent: false, - }); - }); - - it("a combined two-value reading, its duplicate, a refused one", async () => { - const reading = [ - { - type: "BLOOD_PRESSURE_SYS", - value: 128, - measuredAt: "2026-10-14T08:00:00.000Z", - }, - { - type: "BLOOD_PRESSURE_DIA", - value: 82, - measuredAt: "2026-10-14T08:00:00.000Z", - }, - { - type: "PULSE", - value: 66, - measuredAt: "2026-10-14T08:00:00.000Z", - notes: "seated", - symptomsPresent: true, - }, - ]; - await post("post-05-batch", reading); - await post("post-06-batch-duplicate", reading); - await post("post-07-batch-invalid", [{ type: "PULSE", value: -1 }]); - }); - - it("a named sleep-stage segment, its re-post and its mirror", async () => { - const segment = { - type: "SLEEP_DURATION", - sleepStage: "DEEP", - startDate: "2026-10-14T01:00:00.000Z", - endDate: "2026-10-14T02:30:00.000Z", - externalId: "bridge-sleep-1", - }; - await post("post-08-sleep-stage", segment); - await post("post-09-sleep-stage-again", segment); - await post("post-10-sleep-stage-mirror", { - ...segment, - externalId: "hk-sleep-1", - source: "APPLE_HEALTH", - }); - await post("post-11-sleep-stage-invalid", { - type: "SLEEP_DURATION", - sleepStage: "DEEP", - }); - }); - - it("a narrow bridge token with and without a source", async () => { - const bridge = await makeToken( - { id: ownerId }, - { permissions: [MEASUREMENTS_WRITE_SCOPE] }, - ); - await post( - "post-12-bridge-with-source", - { +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("measurements parity walk", { shuffle: false }, () => { + describe("POST /api/measurements parity", () => { + it("a single reading, its duplicate, a refused one", async () => { + const weight = { type: "WEIGHT", - value: 80.1, - measuredAt: "2026-10-15T06:30:00.000Z", - source: "MANUAL", - }, - bridge, - ); - await post( - "post-13-bridge", - { + value: 80.4, + measuredAt: "2026-10-14T06:30:00.000Z", + notes: "after the run", + deviceType: "scale", + }; + await post("post-01-single", weight); + await post("post-02-single-duplicate", weight); + await post("post-03-single-invalid", { type: "WEIGHT", - value: 80.1, - measuredAt: "2026-10-15T06:30:00.000Z", - }, - bridge, - ); - }); - - it("a delegate writing into a shared record", async () => { - const delegateUser = await makeUser({ timezone: "UTC" }); - await makeGrant({ id: ownerId }, delegateUser, { access: "WRITE" }); - const delegate = await makeSession(delegateUser); - await switchSessionTo(delegate.sessionId, ownerId); - await post( - "post-14-delegated", - { - type: "PULSE", - value: 71, - measuredAt: "2026-10-15T07:00:00.000Z", - }, - delegate, - ); - await switchSessionTo(delegate.sessionId, null); - }); -}); - -describe("GET /api/measurements parity", () => { - beforeAll(async () => { - // Day totals for the cumulative list modes, from the phone. - for (const [day, value] of [ - ["2026-10-12", 7_100], - ["2026-10-13", 8_400], - ["2026-10-14", 5_100], - ] as const) { - await prisma.measurement.create({ - data: { - userId: ownerId, - type: "ACTIVITY_STEPS", - value, - unit: "steps", - source: "APPLE_HEALTH", - measuredAt: new Date(`${day}T12:00:00.000Z`), - externalId: `stats:${STEPS}:${day}`, - aggregationProvenance: "HEALTHKIT_STATISTICS", - }, + value: "heavy", + measuredAt: "2026-10-14T06:30:00.000Z", + }); + await post("post-04-single-glucose", { + type: "BLOOD_GLUCOSE", + value: 104, + measuredAt: "2026-10-14T07:00:00.000Z", + glucoseContext: "FASTING", + symptomsPresent: false, }); - } - }); - - it("the plain page and its filters", async () => { - await get("get-01-page", { limit: 50 }); - await get("get-02-type-sorted", { - type: "PULSE", - sortBy: "value", - sortDir: "asc", }); - await get("get-03-filtered", { - sourceEq: "MANUAL", - valueMin: 70, - valueMax: 130, - from: "2026-10-14T00:00:00.000Z", - to: "2026-10-15T23:59:59.000Z", - limit: 2, - offset: 1, + + it("a combined two-value reading, its duplicate, a refused one", async () => { + const reading = [ + { + type: "BLOOD_PRESSURE_SYS", + value: 128, + measuredAt: "2026-10-14T08:00:00.000Z", + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 82, + measuredAt: "2026-10-14T08:00:00.000Z", + }, + { + type: "PULSE", + value: 66, + measuredAt: "2026-10-14T08:00:00.000Z", + notes: "seated", + symptomsPresent: true, + }, + ]; + await post("post-05-batch", reading); + await post("post-06-batch-duplicate", reading); + await post("post-07-batch-invalid", [{ type: "PULSE", value: -1 }]); + }); + + it("a named sleep-stage segment, its re-post and its mirror", async () => { + const segment = { + type: "SLEEP_DURATION", + sleepStage: "DEEP", + startDate: "2026-10-14T01:00:00.000Z", + endDate: "2026-10-14T02:30:00.000Z", + externalId: "bridge-sleep-1", + }; + await post("post-08-sleep-stage", segment); + await post("post-09-sleep-stage-again", segment); + await post("post-10-sleep-stage-mirror", { + ...segment, + externalId: "hk-sleep-1", + source: "APPLE_HEALTH", + }); + await post("post-11-sleep-stage-invalid", { + type: "SLEEP_DURATION", + sleepStage: "DEEP", + }); + }); + + it("a narrow bridge token with and without a source", async () => { + const bridge = await makeToken( + { id: ownerId }, + { permissions: [MEASUREMENTS_WRITE_SCOPE] }, + ); + await post( + "post-12-bridge-with-source", + { + type: "WEIGHT", + value: 80.1, + measuredAt: "2026-10-15T06:30:00.000Z", + source: "MANUAL", + }, + bridge, + ); + await post( + "post-13-bridge", + { + type: "WEIGHT", + value: 80.1, + measuredAt: "2026-10-15T06:30:00.000Z", + }, + bridge, + ); + }); + + it("a delegate writing into a shared record", async () => { + const delegateUser = await makeUser({ timezone: "UTC" }); + await makeGrant({ id: ownerId }, delegateUser, { access: "WRITE" }); + const delegate = await makeSession(delegateUser); + await switchSessionTo(delegate.sessionId, ownerId); + await post( + "post-14-delegated", + { + type: "PULSE", + value: 71, + measuredAt: "2026-10-15T07:00:00.000Z", + }, + delegate, + ); + await switchSessionTo(delegate.sessionId, null); }); }); - it("the list modes", async () => { - await get("get-04-sleep", { type: "SLEEP_DURATION" }); - await get("get-05-drill-down", { - type: "ACTIVITY_STEPS", - dayKey: "2026-10-13", + describe("GET /api/measurements parity", () => { + beforeAll(async () => { + // Day totals for the cumulative list modes, from the phone. + for (const [day, value] of [ + ["2026-10-12", 7_100], + ["2026-10-13", 8_400], + ["2026-10-14", 5_100], + ] as const) { + await prisma.measurement.create({ + data: { + userId: ownerId, + type: "ACTIVITY_STEPS", + value, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date(`${day}T12:00:00.000Z`), + externalId: `stats:${STEPS}:${day}`, + aggregationProvenance: "HEALTHKIT_STATISTICS", + }, + }); + } }); - await get("get-06-day-grouped", { type: "ACTIVITY_STEPS", groupBy: "day" }); - await get("get-07-rollup-series", { - type: "ACTIVITY_STEPS", - source: "rollup", - aggregate: "daily", - from: "2026-10-10T00:00:00.000Z", - to: "2026-10-15T23:59:59.000Z", - }); - await get("get-08-live-aggregate", { - type: "PULSE", - aggregate: "weekly", - from: "2026-09-01T00:00:00.000Z", - to: "2026-10-15T23:59:59.000Z", - }); - }); - it("a refused query", async () => { - await get("get-09-invalid", { limit: 0, sortDir: "sideways" }); + it("the plain page and its filters", async () => { + await get("get-01-page", { limit: 50 }); + await get("get-02-type-sorted", { + type: "PULSE", + sortBy: "value", + sortDir: "asc", + }); + await get("get-03-filtered", { + sourceEq: "MANUAL", + valueMin: 70, + valueMax: 130, + from: "2026-10-14T00:00:00.000Z", + to: "2026-10-15T23:59:59.000Z", + limit: 2, + offset: 1, + }); + }); + + it("the list modes", async () => { + await get("get-04-sleep", { type: "SLEEP_DURATION" }); + await get("get-05-drill-down", { + type: "ACTIVITY_STEPS", + dayKey: "2026-10-13", + }); + await get("get-06-day-grouped", { + type: "ACTIVITY_STEPS", + groupBy: "day", + }); + await get("get-07-rollup-series", { + type: "ACTIVITY_STEPS", + source: "rollup", + aggregate: "daily", + from: "2026-10-10T00:00:00.000Z", + to: "2026-10-15T23:59:59.000Z", + }); + await get("get-08-live-aggregate", { + type: "PULSE", + aggregate: "weekly", + from: "2026-09-01T00:00:00.000Z", + to: "2026-10-15T23:59:59.000Z", + }); + }); + + it("a refused query", async () => { + await get("get-09-invalid", { limit: 0, sortDir: "sideways" }); + }); }); }); diff --git a/tests/integration/routes/parity/medication-detail.parity.test.ts b/tests/integration/routes/parity/medication-detail.parity.test.ts index 8ca3932e7..d914179d9 100644 --- a/tests/integration/routes/parity/medication-detail.parity.test.ts +++ b/tests/integration/routes/parity/medication-detail.parity.test.ts @@ -358,212 +358,216 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/medications/{id} parity", () => { - it("01 a rolling injection with stock, a composition and a custom category", async () => { - await get("01-rolling", med.rolling); +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-detail parity walk", { shuffle: false }, () => { + describe("GET /api/medications/{id} parity", () => { + it("01 a rolling injection with stock, a composition and a custom category", async () => { + await get("01-rolling", med.rolling); + }); + + it("02 a daily tablet without stock", async () => { + await get("02-daily", med.tablet); + }); + + it("03 a record-only medication", async () => { + await get("03-record-only", med.recordOnly); + }); + + it("04 another account's medication", async () => { + await get("04-not-found", med.foreign); + }); + + it("05 a reading delegate, and nobody", async () => { + await get("05a-reader", med.rolling, reader); + await get("05b-anonymous", med.rolling, { auth: anonymous }); + }); }); - it("02 a daily tablet without stock", async () => { - await get("02-daily", med.tablet); + describe("PUT /api/medications/{id} parity", () => { + it("11 plain fields", async () => { + await put("11-plain-fields", med.tablet, { + name: "Daily tablet renamed", + dose: "20mg", + treatmentClass: "GENERIC", + dosesPerUnit: 30, + reorderLeadDays: 5, + deliveryForm: "ORAL", + notificationsEnabled: false, + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "C09AA05", + rxNormCode: null, + }); + }); + + it("12 a schedule replace whose dose time leaves the stale window", async () => { + await put("12-schedule-replace", med.tablet, { + schedules: [ + { + windowStart: "08:00", + windowEnd: "10:00", + timesOfDay: ["11:30"], + label: "Late morning", + dose: "10mg", + unitsPerDose: 2, + reminderGraceMinutes: 30, + }, + { + windowStart: "20:00", + windowEnd: "22:00", + daysOfWeek: [1, 3, 5], + intervalWeeks: 2, + doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], + }, + ], + }); + }); + + it("13 the same schedule echoed back", async () => { + await put("13-schedule-echo", med.tablet, { + schedules: [ + { + windowStart: "11:30", + windowEnd: "11:30", + timesOfDay: ["11:30"], + label: "Late morning", + dose: "10mg", + unitsPerDose: 2, + reminderGraceMinutes: 30, + rrule: "FREQ=DAILY", + }, + { + windowStart: "20:00", + windowEnd: "22:00", + daysOfWeek: [1, 3, 5], + intervalWeeks: 2, + doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], + }, + ], + }); + }); + + it("14 the top-level reminder window", async () => { + await put("14-grace-bridge", med.tablet, { reminderGraceMinutes: 45 }); + }); + + it("15 a pause and a resume", async () => { + await put("15a-pause", med.tablet, { active: false }); + await put("15b-resume", med.tablet, { active: true }); + }); + + it("16 automatic recording switched on", async () => { + await put("16-auto-mark-taken", med.tablet, { autoMarkTaken: true }); + }); + + it("17 the category writes", async () => { + await put("17a-custom-category", med.tablet, { category: CUSTOM_KEY }); + await put( + "17b-native-other-kept", + med.tablet, + { category: "OTHER" }, + { auth: ownerBearer }, + ); + await put("17c-web-other", med.tablet, { category: "OTHER" }); + await put("17d-unknown-category", med.tablet, { + category: "custom:0a6c2d4e-1111-4222-8333-944455556666", + }); + }); + + it("18 a record-only medication and both tracking transitions", async () => { + await put("18a-record-schedule-kept", med.recordOnly, { + name: "Record only renamed", + schedules: [daily("06:00", "07:00")], + oneShot: false, + }); + await put("18b-tracking-on", med.recordOnly, { trackIntake: true }); + await put("18c-tracking-off", med.recordOnly, { trackIntake: false }); + }); + + it("19 the as-needed flips and their refusals", async () => { + await put("19a-scheduled-without-schedules", med.asNeeded, { + asNeeded: false, + }); + await put("19b-scheduled", med.asNeeded, { + asNeeded: false, + schedules: [daily("08:00", "09:00")], + }); + await put("19c-as-needed-keeping-schedules", med.asNeeded, { + asNeeded: true, + }); + await put("19d-as-needed", med.asNeeded, { + asNeeded: true, + schedules: [], + }); + }); + + it("20 the one-shot refusals and a conversion", async () => { + await put("20a-one-shot-two-schedules", med.booster, { + oneShot: true, + startsOn: "2026-10-20", + schedules: [daily("09:00", "10:00"), daily("18:00", "19:00")], + }); + await put("20b-one-shot-recurrence", med.booster, { + oneShot: true, + startsOn: "2026-10-20", + schedules: [ + { windowStart: "09:00", windowEnd: "10:00", rrule: "FREQ=DAILY" }, + ], + }); + await put("20c-one-shot", med.booster, { + oneShot: true, + startsOn: "2026-10-20", + schedules: [{ windowStart: "09:00", windowEnd: "10:00" }], + }); + }); + + it("21 the course refusals and a course edit", async () => { + await put("21a-course-overlap", med.course, { startsOn: "2026-08-20" }); + await put("21b-course-window-required", med.course, { + startsOn: null, + endsOn: null, + }); + await put("21c-course-end", med.course, { endsOn: "2026-12-31" }); + }); + + it("22 a units change on a medication with a composition", async () => { + await put("22-composition-units", med.rolling, { unitsPerDose: 2 }); + }); + + it("23 a body the schema refuses, and another account's medication", async () => { + await put("23a-invalid", med.tablet, { + name: "", + unitsPerDose: -1, + schedules: "daily", + }); + await put("23b-not-found", med.foreign, { name: "Taken over" }); + }); + + it("24 a delegate who may write, one who may manage, and nobody", async () => { + await put("24a-writer", med.tablet, { name: "By a writer" }, writer); + await put("24b-manager", med.tablet, { name: "By a manager" }, manager); + await put( + "24c-anonymous", + med.tablet, + { name: "By nobody" }, + { auth: anonymous }, + ); + }); }); - it("03 a record-only medication", async () => { - await get("03-record-only", med.recordOnly); - }); + describe("DELETE /api/medications/{id} parity", () => { + it("31 the owner deletes a medication with a composition and an ingest token", async () => { + await del("31-delete", med.doomed); + }); - it("04 another account's medication", async () => { - await get("04-not-found", med.foreign); - }); + it("32 the same medication again", async () => { + await del("32-not-found", med.doomed); + }); - it("05 a reading delegate, and nobody", async () => { - await get("05a-reader", med.rolling, reader); - await get("05b-anonymous", med.rolling, { auth: anonymous }); - }); -}); - -describe("PUT /api/medications/{id} parity", () => { - it("11 plain fields", async () => { - await put("11-plain-fields", med.tablet, { - name: "Daily tablet renamed", - dose: "20mg", - treatmentClass: "GENERIC", - dosesPerUnit: 30, - reorderLeadDays: 5, - deliveryForm: "ORAL", - notificationsEnabled: false, - liveActivityEnabled: true, - criticalAlarmEnabled: true, - atcCode: "C09AA05", - rxNormCode: null, + it("33 a managing delegate, and nobody", async () => { + await del("33a-manager", med.booster, manager); + await del("33b-anonymous", med.booster, { auth: anonymous }); }); - }); - - it("12 a schedule replace whose dose time leaves the stale window", async () => { - await put("12-schedule-replace", med.tablet, { - schedules: [ - { - windowStart: "08:00", - windowEnd: "10:00", - timesOfDay: ["11:30"], - label: "Late morning", - dose: "10mg", - unitsPerDose: 2, - reminderGraceMinutes: 30, - }, - { - windowStart: "20:00", - windowEnd: "22:00", - daysOfWeek: [1, 3, 5], - intervalWeeks: 2, - doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], - }, - ], - }); - }); - - it("13 the same schedule echoed back", async () => { - await put("13-schedule-echo", med.tablet, { - schedules: [ - { - windowStart: "11:30", - windowEnd: "11:30", - timesOfDay: ["11:30"], - label: "Late morning", - dose: "10mg", - unitsPerDose: 2, - reminderGraceMinutes: 30, - rrule: "FREQ=DAILY", - }, - { - windowStart: "20:00", - windowEnd: "22:00", - daysOfWeek: [1, 3, 5], - intervalWeeks: 2, - doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }], - }, - ], - }); - }); - - it("14 the top-level reminder window", async () => { - await put("14-grace-bridge", med.tablet, { reminderGraceMinutes: 45 }); - }); - - it("15 a pause and a resume", async () => { - await put("15a-pause", med.tablet, { active: false }); - await put("15b-resume", med.tablet, { active: true }); - }); - - it("16 automatic recording switched on", async () => { - await put("16-auto-mark-taken", med.tablet, { autoMarkTaken: true }); - }); - - it("17 the category writes", async () => { - await put("17a-custom-category", med.tablet, { category: CUSTOM_KEY }); - await put( - "17b-native-other-kept", - med.tablet, - { category: "OTHER" }, - { auth: ownerBearer }, - ); - await put("17c-web-other", med.tablet, { category: "OTHER" }); - await put("17d-unknown-category", med.tablet, { - category: "custom:0a6c2d4e-1111-4222-8333-944455556666", - }); - }); - - it("18 a record-only medication and both tracking transitions", async () => { - await put("18a-record-schedule-kept", med.recordOnly, { - name: "Record only renamed", - schedules: [daily("06:00", "07:00")], - oneShot: false, - }); - await put("18b-tracking-on", med.recordOnly, { trackIntake: true }); - await put("18c-tracking-off", med.recordOnly, { trackIntake: false }); - }); - - it("19 the as-needed flips and their refusals", async () => { - await put("19a-scheduled-without-schedules", med.asNeeded, { - asNeeded: false, - }); - await put("19b-scheduled", med.asNeeded, { - asNeeded: false, - schedules: [daily("08:00", "09:00")], - }); - await put("19c-as-needed-keeping-schedules", med.asNeeded, { - asNeeded: true, - }); - await put("19d-as-needed", med.asNeeded, { - asNeeded: true, - schedules: [], - }); - }); - - it("20 the one-shot refusals and a conversion", async () => { - await put("20a-one-shot-two-schedules", med.booster, { - oneShot: true, - startsOn: "2026-10-20", - schedules: [daily("09:00", "10:00"), daily("18:00", "19:00")], - }); - await put("20b-one-shot-recurrence", med.booster, { - oneShot: true, - startsOn: "2026-10-20", - schedules: [ - { windowStart: "09:00", windowEnd: "10:00", rrule: "FREQ=DAILY" }, - ], - }); - await put("20c-one-shot", med.booster, { - oneShot: true, - startsOn: "2026-10-20", - schedules: [{ windowStart: "09:00", windowEnd: "10:00" }], - }); - }); - - it("21 the course refusals and a course edit", async () => { - await put("21a-course-overlap", med.course, { startsOn: "2026-08-20" }); - await put("21b-course-window-required", med.course, { - startsOn: null, - endsOn: null, - }); - await put("21c-course-end", med.course, { endsOn: "2026-12-31" }); - }); - - it("22 a units change on a medication with a composition", async () => { - await put("22-composition-units", med.rolling, { unitsPerDose: 2 }); - }); - - it("23 a body the schema refuses, and another account's medication", async () => { - await put("23a-invalid", med.tablet, { - name: "", - unitsPerDose: -1, - schedules: "daily", - }); - await put("23b-not-found", med.foreign, { name: "Taken over" }); - }); - - it("24 a delegate who may write, one who may manage, and nobody", async () => { - await put("24a-writer", med.tablet, { name: "By a writer" }, writer); - await put("24b-manager", med.tablet, { name: "By a manager" }, manager); - await put( - "24c-anonymous", - med.tablet, - { name: "By nobody" }, - { auth: anonymous }, - ); - }); -}); - -describe("DELETE /api/medications/{id} parity", () => { - it("31 the owner deletes a medication with a composition and an ingest token", async () => { - await del("31-delete", med.doomed); - }); - - it("32 the same medication again", async () => { - await del("32-not-found", med.doomed); - }); - - it("33 a managing delegate, and nobody", async () => { - await del("33a-manager", med.booster, manager); - await del("33b-anonymous", med.booster, { auth: anonymous }); }); }); diff --git a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts index f51313028..4175929d7 100644 --- a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts @@ -375,7 +375,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/medications/intake/bulk parity", () => { +describe("POST /api/medications/intake/bulk parity", { shuffle: false }, () => { it("01 a mixed first batch", async () => { await post("01-mixed", mixedBatch()); }); diff --git a/tests/integration/routes/parity/medication-intake-event.parity.test.ts b/tests/integration/routes/parity/medication-intake-event.parity.test.ts index acfbe8d4b..775a38dba 100644 --- a/tests/integration/routes/parity/medication-intake-event.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-event.parity.test.ts @@ -229,131 +229,135 @@ afterAll(() => { vi.useRealTimers(); }); -describe("PUT /api/medications/{id}/intake/{eventId} parity", () => { - it("01 a pending dose taken inside its own window", async () => { - await put("01-take-in-place", events.pending, { - takenAt: "2026-10-13T08:40:00.000Z", +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-intake-event parity walk", { shuffle: false }, () => { + describe("PUT /api/medications/{id}/intake/{eventId} parity", () => { + it("01 a pending dose taken inside its own window", async () => { + await put("01-take-in-place", events.pending, { + takenAt: "2026-10-13T08:40:00.000Z", + }); + }); + + it("02 the same dose flipped to a skip", async () => { + await put("02-skip-flip", events.pending, { skipped: true }); + }); + + it("03 a take corrected into the other window, onto a skipped slot", async () => { + const res = await put("03-move-by-band", events.morningTaken, { + takenAt: "2026-10-14T20:30:00.000Z", + }); + events.moved = (res.data as { id: string }).id; + }); + + it("04 a legacy take pinned onto a free slot", async () => { + const res = await put("04-pin-free-slot", events.legacyTaken, { + forceSlotInstant: "2026-10-12T20:00:00.000Z", + }); + events.pinned = (res.data as { id: string }).id; + }); + + it("05 a pin onto an instant that is no slot", async () => { + await put("05-pin-not-a-slot", events.moved, { + forceSlotInstant: "2026-10-12T15:00:00.000Z", + }); + }); + + it("06 a pin onto a slot another dose holds", async () => { + await put("06-pin-occupied", events.moved, { + forceSlotInstant: "2026-10-12T20:00:00.000Z", + }); + }); + + it("07 an unpin", async () => { + const res = await put("07-unpin", events.pinned, { + forceSlotInstant: null, + }); + events.pinned = (res.data as { id: string }).id; + }); + + it("08 an explicit slot in the body", async () => { + const res = await put("08-explicit-slot", events.moved, { + scheduledFor: "2026-10-14T08:00:00.000Z", + takenAt: "2026-10-14T08:45:00.000Z", + }); + events.moved = (res.data as { id: string }).id; + }); + + it("09 a take before the start day", async () => { + await put("09-before-start", events.pinned, { + takenAt: "2026-09-20T08:00:00.000Z", + }); + }); + + it("10 a body the schema refuses", async () => { + await put("10-invalid", events.pinned, { + takenAt: "yesterday", + skipped: "no", + }); + }); + + it("11 a tombstoned dose, and a dose under another medication", async () => { + await put("11a-tombstoned", events.tombstoned, { skipped: true }); + await put( + "11b-other-medication", + events.pinned, + { skipped: true }, + { medicationId: oneShotId }, + ); + }); + + it("12 a delegate who may write, and one who may manage", async () => { + await put( + "12a-writer", + events.pinned, + { takenAt: "2026-10-13T20:50:00.000Z" }, + writer, + ); + await put( + "12b-manager", + events.pinned, + { takenAt: "2026-10-13T20:50:00.000Z" }, + manager, + ); + }); + + it("13 no credentials", async () => { + await put( + "13-anonymous", + events.pinned, + { skipped: true }, + { + auth: anonymous, + }, + ); }); }); - it("02 the same dose flipped to a skip", async () => { - await put("02-skip-flip", events.pending, { skipped: true }); - }); - - it("03 a take corrected into the other window, onto a skipped slot", async () => { - const res = await put("03-move-by-band", events.morningTaken, { - takenAt: "2026-10-14T20:30:00.000Z", + describe("DELETE /api/medications/{id}/intake/{eventId} parity", () => { + it("21 the owner undoes a dose", async () => { + await del("21-delete", events.forDelete); }); - events.moved = (res.data as { id: string }).id; - }); - it("04 a legacy take pinned onto a free slot", async () => { - const res = await put("04-pin-free-slot", events.legacyTaken, { - forceSlotInstant: "2026-10-12T20:00:00.000Z", + it("22 undoing the only dose of a one-shot medication", async () => { + await del("22-delete-one-shot", events.oneShotDose, { + medicationId: oneShotId, + }); }); - events.pinned = (res.data as { id: string }).id; - }); - it("05 a pin onto an instant that is no slot", async () => { - await put("05-pin-not-a-slot", events.moved, { - forceSlotInstant: "2026-10-12T15:00:00.000Z", + it("23 a dose under another medication", async () => { + await del("23-other-medication", events.forManagerDelete, { + medicationId: oneShotId, + }); }); - }); - it("06 a pin onto a slot another dose holds", async () => { - await put("06-pin-occupied", events.moved, { - forceSlotInstant: "2026-10-12T20:00:00.000Z", + it("24 a delegate who may write, and one who may manage", async () => { + await del("24a-writer", events.forManagerDelete, writer); + await del("24b-manager", events.forManagerDelete, manager); }); - }); - it("07 an unpin", async () => { - const res = await put("07-unpin", events.pinned, { - forceSlotInstant: null, + it("25 no credentials", async () => { + await del("25-anonymous", events.forDelete, { auth: anonymous }); }); - events.pinned = (res.data as { id: string }).id; - }); - - it("08 an explicit slot in the body", async () => { - const res = await put("08-explicit-slot", events.moved, { - scheduledFor: "2026-10-14T08:00:00.000Z", - takenAt: "2026-10-14T08:45:00.000Z", - }); - events.moved = (res.data as { id: string }).id; - }); - - it("09 a take before the start day", async () => { - await put("09-before-start", events.pinned, { - takenAt: "2026-09-20T08:00:00.000Z", - }); - }); - - it("10 a body the schema refuses", async () => { - await put("10-invalid", events.pinned, { - takenAt: "yesterday", - skipped: "no", - }); - }); - - it("11 a tombstoned dose, and a dose under another medication", async () => { - await put("11a-tombstoned", events.tombstoned, { skipped: true }); - await put( - "11b-other-medication", - events.pinned, - { skipped: true }, - { medicationId: oneShotId }, - ); - }); - - it("12 a delegate who may write, and one who may manage", async () => { - await put( - "12a-writer", - events.pinned, - { takenAt: "2026-10-13T20:50:00.000Z" }, - writer, - ); - await put( - "12b-manager", - events.pinned, - { takenAt: "2026-10-13T20:50:00.000Z" }, - manager, - ); - }); - - it("13 no credentials", async () => { - await put( - "13-anonymous", - events.pinned, - { skipped: true }, - { - auth: anonymous, - }, - ); - }); -}); - -describe("DELETE /api/medications/{id}/intake/{eventId} parity", () => { - it("21 the owner undoes a dose", async () => { - await del("21-delete", events.forDelete); - }); - - it("22 undoing the only dose of a one-shot medication", async () => { - await del("22-delete-one-shot", events.oneShotDose, { - medicationId: oneShotId, - }); - }); - - it("23 a dose under another medication", async () => { - await del("23-other-medication", events.forManagerDelete, { - medicationId: oneShotId, - }); - }); - - it("24 a delegate who may write, and one who may manage", async () => { - await del("24a-writer", events.forManagerDelete, writer); - await del("24b-manager", events.forManagerDelete, manager); - }); - - it("25 no credentials", async () => { - await del("25-anonymous", events.forDelete, { auth: anonymous }); }); }); diff --git a/tests/integration/routes/parity/medication-intake-status.parity.test.ts b/tests/integration/routes/parity/medication-intake-status.parity.test.ts index 92f6c25bb..1c4859108 100644 --- a/tests/integration/routes/parity/medication-intake-status.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-status.parity.test.ts @@ -317,138 +317,145 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/medications/intake parity", () => { - it("01 today's list", async () => { - await get("01-today", { scope: "today" }); +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-intake-status parity walk", { shuffle: false }, () => { + describe("GET /api/medications/intake parity", () => { + it("01 today's list", async () => { + await get("01-today", { scope: "today" }); + }); + + it("02 the compliance buckets, cold and from the cache", async () => { + await get("02a-compliance-cold", { scope: "compliance", days: "7" }); + await get("02b-compliance-warm", { scope: "compliance", days: "7" }); + }); + + it("03 a query the schema refuses", async () => { + await get("03-invalid", { scope: "yesterday", days: "0" }); + }); + + it("04 a reading delegate, and nobody", async () => { + await get("04a-reader", { scope: "today" }, reader); + await get("04b-anonymous", { scope: "today" }, { auth: anonymous }); + }); }); - it("02 the compliance buckets, cold and from the cache", async () => { - await get("02a-compliance-cold", { scope: "compliance", days: "7" }); - await get("02b-compliance-warm", { scope: "compliance", days: "7" }); - }); + describe("POST /api/medications/intake parity", () => { + it("11 a take in place", async () => { + await post("11-take-in-place", { + intakeId: ev.todayMorning, + status: "taken", + takenAt: "2026-10-15T08:30:00.000Z", + }); + }); - it("03 a query the schema refuses", async () => { - await get("03-invalid", { scope: "yesterday", days: "0" }); - }); + it("12 a take that re-binds to another slot", async () => { + await post("12-take-slot-move", { + intakeId: ev.moveMe, + status: "taken", + takenAt: "2026-10-14T20:30:00.000Z", + }); + }); - it("04 a reading delegate, and nobody", async () => { - await get("04a-reader", { scope: "today" }, reader); - await get("04b-anonymous", { scope: "today" }, { auth: anonymous }); - }); -}); - -describe("POST /api/medications/intake parity", () => { - it("11 a take in place", async () => { - await post("11-take-in-place", { - intakeId: ev.todayMorning, - status: "taken", - takenAt: "2026-10-15T08:30:00.000Z", + it("13 a pinned take, and a pin that is no slot", async () => { + await post("13a-pin", { + intakeId: ev.pinMe, + status: "taken", + takenAt: "2026-10-13T11:00:00.000Z", + forceSlotInstant: "2026-10-13T08:00:00.000Z", + }); + await post("13b-pin-not-a-slot", { + intakeId: ev.snoozeMe, + status: "taken", + forceSlotInstant: "2026-10-11T15:00:00.000Z", + }); }); - }); - it("12 a take that re-binds to another slot", async () => { - await post("12-take-slot-move", { - intakeId: ev.moveMe, - status: "taken", - takenAt: "2026-10-14T20:30:00.000Z", + it("14 a skip over a recorded dose", async () => { + await post("14-skip", { intakeId: ev.skipMe, status: "skipped" }); }); - }); - it("13 a pinned take, and a pin that is no slot", async () => { - await post("13a-pin", { - intakeId: ev.pinMe, - status: "taken", - takenAt: "2026-10-13T11:00:00.000Z", - forceSlotInstant: "2026-10-13T08:00:00.000Z", - }); - await post("13b-pin-not-a-slot", { - intakeId: ev.snoozeMe, - status: "taken", - forceSlotInstant: "2026-10-11T15:00:00.000Z", + it("15 a snooze with its end, and one without", async () => { + await post("15a-snooze-until", { + intakeId: ev.snoozeMe, + status: "snoozed", + snoozedUntil: "2026-10-15T13:00:00.000Z", + }); + await post("15b-snooze-default", { + intakeId: ev.snoozeMe, + status: "snoozed", + }); }); - }); - it("14 a skip over a recorded dose", async () => { - await post("14-skip", { intakeId: ev.skipMe, status: "skipped" }); - }); - - it("15 a snooze with its end, and one without", async () => { - await post("15a-snooze-until", { - intakeId: ev.snoozeMe, - status: "snoozed", - snoozedUntil: "2026-10-15T13:00:00.000Z", - }); - await post("15b-snooze-default", { - intakeId: ev.snoozeMe, - status: "snoozed", + it("16 the not-found answers", async () => { + await post("16a-unknown", { + intakeId: "cmv0000000000000000000000", + status: "taken", + }); + await post("16b-foreign", { intakeId: ev.foreign, status: "taken" }); + await post("16c-tombstoned", { + intakeId: ev.tombstoned, + status: "taken", + }); }); - }); - it("16 the not-found answers", async () => { - await post("16a-unknown", { - intakeId: "cmv0000000000000000000000", - status: "taken", + it("17 the record-only rule both ways", async () => { + await post("17a-record-only-open", { + intakeId: ev.recordOpen, + status: "taken", + }); + await post("17b-record-only-correction", { + intakeId: ev.recordTaken, + status: "taken", + takenAt: "2026-10-13T07:20:00.000Z", + }); }); - await post("16b-foreign", { intakeId: ev.foreign, status: "taken" }); - await post("16c-tombstoned", { intakeId: ev.tombstoned, status: "taken" }); - }); - it("17 the record-only rule both ways", async () => { - await post("17a-record-only-open", { - intakeId: ev.recordOpen, - status: "taken", + it("18 the injection-site refusal, and an allowed site", async () => { + await post("18a-site-not-allowed", { + intakeId: ev.penToday, + status: "taken", + takenAt: "2026-10-15T09:15:00.000Z", + injectionSite: "ABDOMEN_RIGHT", + }); + await post("18b-site", { + intakeId: ev.penToday, + status: "taken", + takenAt: "2026-10-15T09:15:00.000Z", + injectionSite: "ABDOMEN_LEFT", + }); }); - await post("17b-record-only-correction", { - intakeId: ev.recordTaken, - status: "taken", - takenAt: "2026-10-13T07:20:00.000Z", - }); - }); - it("18 the injection-site refusal, and an allowed site", async () => { - await post("18a-site-not-allowed", { - intakeId: ev.penToday, - status: "taken", - takenAt: "2026-10-15T09:15:00.000Z", - injectionSite: "ABDOMEN_RIGHT", - }); - await post("18b-site", { - intakeId: ev.penToday, - status: "taken", - takenAt: "2026-10-15T09:15:00.000Z", - injectionSite: "ABDOMEN_LEFT", + it("19 a body the schema refuses", async () => { + await post("19-invalid", { intakeId: "", status: "forgotten" }); }); - }); - it("19 a body the schema refuses", async () => { - await post("19-invalid", { intakeId: "", status: "forgotten" }); - }); - - it("20 a delegate who marks an open dose, a snooze, a correction, a reader and nobody", async () => { - await post( - "20a-writer-take", - { intakeId: ev.todayEvening, status: "taken" }, - { ...writer }, - ); - await post( - "20b-writer-snooze-refused", - { intakeId: ev.snoozeMe, status: "snoozed" }, - writer, - ); - await post( - "20c-writer-correction-refused", - { intakeId: ev.resolved, status: "skipped" }, - writer, - ); - await post( - "20d-reader", - { intakeId: ev.snoozeMe, status: "taken" }, - reader, - ); - await post( - "20e-anonymous", - { intakeId: ev.snoozeMe, status: "taken" }, - { auth: anonymous }, - ); + it("20 a delegate who marks an open dose, a snooze, a correction, a reader and nobody", async () => { + await post( + "20a-writer-take", + { intakeId: ev.todayEvening, status: "taken" }, + { ...writer }, + ); + await post( + "20b-writer-snooze-refused", + { intakeId: ev.snoozeMe, status: "snoozed" }, + writer, + ); + await post( + "20c-writer-correction-refused", + { intakeId: ev.resolved, status: "skipped" }, + writer, + ); + await post( + "20d-reader", + { intakeId: ev.snoozeMe, status: "taken" }, + reader, + ); + await post( + "20e-anonymous", + { intakeId: ev.snoozeMe, status: "taken" }, + { auth: anonymous }, + ); + }); }); }); diff --git a/tests/integration/routes/parity/medication-intake.parity.test.ts b/tests/integration/routes/parity/medication-intake.parity.test.ts index e87cf639b..e5ac70486 100644 --- a/tests/integration/routes/parity/medication-intake.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake.parity.test.ts @@ -343,189 +343,193 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/medications/{id}/intake parity", () => { - it("01 a take inside its band, onto the pending reminder row", async () => { - await post("01-take-in-band", { takenAt: "2026-10-15T08:30:00.000Z" }); - }); - - it("02 an as-needed take recorded standalone, and its repeat inside the dedup window", async () => { - await post( - "02a-take-standalone", - { takenAt: "2026-10-15T11:00:00.000Z" }, - { medicationId: med.asNeeded }, - ); - await post( - "02b-take-standalone-repeat", - { takenAt: "2026-10-15T11:00:00.000Z" }, - { medicationId: med.asNeeded }, - ); - }); - - it("03 a skip on a named slot, a slot-less skip, and a skip onto a standalone dose", async () => { - await post("03a-skip-named-slot", { - skipped: true, - scheduledFor: "2026-10-15T20:00:00.000Z", +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medication-intake parity walk", { shuffle: false }, () => { + describe("POST /api/medications/{id}/intake parity", () => { + it("01 a take inside its band, onto the pending reminder row", async () => { + await post("01-take-in-band", { takenAt: "2026-10-15T08:30:00.000Z" }); }); - await post("03b-skip-slotless", { skipped: true }); - await post( - "03c-delegate-skip-standalone-refused", - { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, - { ...writer, medicationId: med.asNeeded, settle: () => refusalRows(1) }, - ); - await post( - "03d-owner-skip-standalone", - { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, - { medicationId: med.asNeeded }, - ); - }); - it("04 a pinned take and the two pin refusals", async () => { - await post("04a-pin", { - takenAt: "2026-10-14T11:00:00.000Z", - forceSlotInstant: "2026-10-14T08:00:00.000Z", + it("02 an as-needed take recorded standalone, and its repeat inside the dedup window", async () => { + await post( + "02a-take-standalone", + { takenAt: "2026-10-15T11:00:00.000Z" }, + { medicationId: med.asNeeded }, + ); + await post( + "02b-take-standalone-repeat", + { takenAt: "2026-10-15T11:00:00.000Z" }, + { medicationId: med.asNeeded }, + ); }); - await post("04b-pin-not-a-slot", { - takenAt: "2026-10-14T16:00:00.000Z", - forceSlotInstant: "2026-10-14T15:00:00.000Z", + + it("03 a skip on a named slot, a slot-less skip, and a skip onto a standalone dose", async () => { + await post("03a-skip-named-slot", { + skipped: true, + scheduledFor: "2026-10-15T20:00:00.000Z", + }); + await post("03b-skip-slotless", { skipped: true }); + await post( + "03c-delegate-skip-standalone-refused", + { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, + { ...writer, medicationId: med.asNeeded, settle: () => refusalRows(1) }, + ); + await post( + "03d-owner-skip-standalone", + { skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" }, + { medicationId: med.asNeeded }, + ); }); - await post("04c-pin-occupied", { - takenAt: "2026-10-14T23:00:00.000Z", - forceSlotInstant: "2026-10-14T20:00:00.000Z", + + it("04 a pinned take and the two pin refusals", async () => { + await post("04a-pin", { + takenAt: "2026-10-14T11:00:00.000Z", + forceSlotInstant: "2026-10-14T08:00:00.000Z", + }); + await post("04b-pin-not-a-slot", { + takenAt: "2026-10-14T16:00:00.000Z", + forceSlotInstant: "2026-10-14T15:00:00.000Z", + }); + await post("04c-pin-occupied", { + takenAt: "2026-10-14T23:00:00.000Z", + forceSlotInstant: "2026-10-14T20:00:00.000Z", + }); + }); + + it("05 a backfill onto a named earlier slot", async () => { + await post("05-named-backfill", { + takenAt: "2026-10-15T11:30:00.000Z", + scheduledFor: "2026-10-13T08:00:00.000Z", + }); + }); + + it("06 a body idempotency key and its replay", async () => { + const body = { + takenAt: "2026-10-13T20:30:00.000Z", + idempotencyKey: "dose-key-0001", + }; + await post("06a-body-key", body); + await post("06b-body-key-replay", body); + }); + + it("07 an Idempotency-Key header and its replay", async () => { + const headers = { + "idempotency-key": "4b7e1c9a-2d3f-4a5b-8c6d-7e8f9a0b1c2d", + }; + const body = { takenAt: "2026-10-12T08:15:00.000Z" }; + await post("07a-header-key", body, { headers }); + await post("07b-header-key-replay", body, { headers }); + }); + + it("08 a native take", async () => { + await post( + "08-native", + { takenAt: "2026-10-11T20:10:00.000Z" }, + { auth: ownerBearer }, + ); + }); + + it("09 a skip over a recorded dose, by the owner and by a delegate", async () => { + await post( + "09a-delegate-skip-refused", + { skipped: true, scheduledFor: "2026-10-14T20:00:00.000Z" }, + { ...writer, settle: () => refusalRows(2) }, + ); + await post("09b-owner-skip-flip", { + skipped: true, + scheduledFor: "2026-10-14T20:00:00.000Z", + }); + }); + + it("10 a delegate's take on an open slot, a reader, and nobody", async () => { + await post( + "10a-writer-take", + { takenAt: "2026-10-11T08:20:00.000Z" }, + writer, + ); + await post("10b-reader", { takenAt: "2026-10-10T08:20:00.000Z" }, reader); + await post( + "10c-anonymous", + { takenAt: "2026-10-10T08:20:00.000Z" }, + { auth: anonymous }, + ); + }); + + it("11 the injection-site refusal, and a site with a dose override", async () => { + await post( + "11a-site-not-allowed", + { takenAt: "2026-10-15T09:10:00.000Z", injectionSite: "ABDOMEN_RIGHT" }, + { medicationId: med.pen }, + ); + await post( + "11b-site-and-dose", + { + takenAt: "2026-10-15T09:10:00.000Z", + injectionSite: "ABDOMEN_LEFT", + doseTaken: "0.5 pen", + }, + { medicationId: med.pen }, + ); + }); + + it("12 a one-shot dose", async () => { + await post( + "12-one-shot", + { takenAt: "2026-10-15T10:00:00.000Z" }, + { medicationId: med.oneShot }, + ); + }); + + it("13 a body the schema refuses", async () => { + await post("13-invalid", { + takenAt: "yesterday", + idempotencyKey: "", + }); + }); + + it("14 another account's medication, and a record-only one", async () => { + await post( + "14a-not-found", + { takenAt: "2026-10-15T08:30:00.000Z" }, + { medicationId: med.foreign }, + ); + await post( + "14b-record-only", + { takenAt: "2026-10-15T07:10:00.000Z" }, + { medicationId: med.recordOnly }, + ); }); }); - it("05 a backfill onto a named earlier slot", async () => { - await post("05-named-backfill", { - takenAt: "2026-10-15T11:30:00.000Z", - scheduledFor: "2026-10-13T08:00:00.000Z", + describe("GET /api/medications/{id}/intake parity", () => { + it("21 the default list", async () => { + await list("21-default"); }); - }); - it("06 a body idempotency key and its replay", async () => { - const body = { - takenAt: "2026-10-13T20:30:00.000Z", - idempotencyKey: "dose-key-0001", - }; - await post("06a-body-key", body); - await post("06b-body-key-replay", body); - }); - - it("07 an Idempotency-Key header and its replay", async () => { - const headers = { - "idempotency-key": "4b7e1c9a-2d3f-4a5b-8c6d-7e8f9a0b1c2d", - }; - const body = { takenAt: "2026-10-12T08:15:00.000Z" }; - await post("07a-header-key", body, { headers }); - await post("07b-header-key-replay", body, { headers }); - }); - - it("08 a native take", async () => { - await post( - "08-native", - { takenAt: "2026-10-11T20:10:00.000Z" }, - { auth: ownerBearer }, - ); - }); - - it("09 a skip over a recorded dose, by the owner and by a delegate", async () => { - await post( - "09a-delegate-skip-refused", - { skipped: true, scheduledFor: "2026-10-14T20:00:00.000Z" }, - { ...writer, settle: () => refusalRows(2) }, - ); - await post("09b-owner-skip-flip", { - skipped: true, - scheduledFor: "2026-10-14T20:00:00.000Z", + it("22 a completed page sorted by take time", async () => { + await list("22-completed-page", { + status: "completed", + sortBy: "takenAt", + sortDir: "asc", + limit: "3", + offset: "1", + }); }); - }); - it("10 a delegate's take on an open slot, a reader, and nobody", async () => { - await post( - "10a-writer-take", - { takenAt: "2026-10-11T08:20:00.000Z" }, - writer, - ); - await post("10b-reader", { takenAt: "2026-10-10T08:20:00.000Z" }, reader); - await post( - "10c-anonymous", - { takenAt: "2026-10-10T08:20:00.000Z" }, - { auth: anonymous }, - ); - }); - - it("11 the injection-site refusal, and a site with a dose override", async () => { - await post( - "11a-site-not-allowed", - { takenAt: "2026-10-15T09:10:00.000Z", injectionSite: "ABDOMEN_RIGHT" }, - { medicationId: med.pen }, - ); - await post( - "11b-site-and-dose", - { - takenAt: "2026-10-15T09:10:00.000Z", - injectionSite: "ABDOMEN_LEFT", - doseTaken: "0.5 pen", - }, - { medicationId: med.pen }, - ); - }); - - it("12 a one-shot dose", async () => { - await post( - "12-one-shot", - { takenAt: "2026-10-15T10:00:00.000Z" }, - { medicationId: med.oneShot }, - ); - }); - - it("13 a body the schema refuses", async () => { - await post("13-invalid", { - takenAt: "yesterday", - idempotencyKey: "", + it("23 the skips, and the takes", async () => { + await list("23a-skipped", { status: "skipped" }); + await list("23b-taken", { status: "taken", sortDir: "asc" }); }); - }); - it("14 another account's medication, and a record-only one", async () => { - await post( - "14a-not-found", - { takenAt: "2026-10-15T08:30:00.000Z" }, - { medicationId: med.foreign }, - ); - await post( - "14b-record-only", - { takenAt: "2026-10-15T07:10:00.000Z" }, - { medicationId: med.recordOnly }, - ); - }); -}); - -describe("GET /api/medications/{id}/intake parity", () => { - it("21 the default list", async () => { - await list("21-default"); - }); - - it("22 a completed page sorted by take time", async () => { - await list("22-completed-page", { - status: "completed", - sortBy: "takenAt", - sortDir: "asc", - limit: "3", - offset: "1", + it("24 a query the schema refuses", async () => { + await list("24-invalid", { limit: "lots", status: "maybe" }); }); - }); - - it("23 the skips, and the takes", async () => { - await list("23a-skipped", { status: "skipped" }); - await list("23b-taken", { status: "taken", sortDir: "asc" }); - }); - it("24 a query the schema refuses", async () => { - await list("24-invalid", { limit: "lots", status: "maybe" }); - }); - - it("25 another account's medication, a reading delegate, and nobody", async () => { - await list("25a-not-found", {}, { medicationId: med.foreign }); - await list("25b-reader", {}, reader); - await list("25c-anonymous", {}, { auth: anonymous }); + it("25 another account's medication, a reading delegate, and nobody", async () => { + await list("25a-not-found", {}, { medicationId: med.foreign }); + await list("25b-reader", {}, reader); + await list("25c-anonymous", {}, { auth: anonymous }); + }); }); }); diff --git a/tests/integration/routes/parity/medications.parity.test.ts b/tests/integration/routes/parity/medications.parity.test.ts index 0e3b15478..84b671309 100644 --- a/tests/integration/routes/parity/medications.parity.test.ts +++ b/tests/integration/routes/parity/medications.parity.test.ts @@ -138,197 +138,204 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/medications parity", () => { - it("01 the owner's list", async () => { - await list("01-list"); - }); +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("medications parity walk", { shuffle: false }, () => { + describe("GET /api/medications parity", () => { + it("01 the owner's list", async () => { + await list("01-list"); + }); - it("02 a reading delegate's view of it, and nobody's", async () => { - await list("02a-list-reader", reader); - await list("02b-list-anonymous", { auth: anonymous }); - }); -}); - -describe("POST /api/medications parity", () => { - it("11 a plain create", async () => { - await create("11-plain", { - name: "Plain tablet", - dose: "5mg", - schedules: [schedule("07:00", "09:00")], + it("02 a reading delegate's view of it, and nobody's", async () => { + await list("02a-list-reader", reader); + await list("02b-list-anonymous", { auth: anonymous }); }); }); - it("12 a full create with a custom category, a course and auto-take", async () => { - await create("12-full", { - name: "Full injection", - dose: "1 pen", - category: CUSTOM_KEY, - treatmentClass: "GLP1", - dosesPerUnit: 4, - unitsPerDose: 0.5, - reorderLeadDays: 7, - deliveryForm: "INJECTION", - trackInjectionSites: true, - allowedInjectionSites: ["ABDOMEN_LEFT", "THIGH_RIGHT"], - notificationsEnabled: false, - liveActivityEnabled: true, - criticalAlarmEnabled: true, - atcCode: "A10BJ06", - rxNormCode: "1991302", - startsOn: "2026-10-10", - endsOn: "2026-12-31", - autoMarkTaken: true, - schedules: [ - schedule("18:00", "20:00", { - label: "Evening", - dose: "0.5 pen", - unitsPerDose: 1, - timesOfDay: ["18:30"], - reminderGraceMinutes: 45, - rrule: "FREQ=WEEKLY;BYDAY=MO", - doseWindows: [{ timeOfDay: "18:30", start: "18:00", end: "20:00" }], - }), - ], + describe("POST /api/medications parity", () => { + it("11 a plain create", async () => { + await create("11-plain", { + name: "Plain tablet", + dose: "5mg", + schedules: [schedule("07:00", "09:00")], + }); }); - }); - it("13 an as-needed create, a one-shot one and a PRN schedule", async () => { - await create("13a-as-needed", { - name: "As needed", - dose: "400mg", - category: "PAIN_RELIEF", - asNeeded: true, + it("12 a full create with a custom category, a course and auto-take", async () => { + await create("12-full", { + name: "Full injection", + dose: "1 pen", + category: CUSTOM_KEY, + treatmentClass: "GLP1", + dosesPerUnit: 4, + unitsPerDose: 0.5, + reorderLeadDays: 7, + deliveryForm: "INJECTION", + trackInjectionSites: true, + allowedInjectionSites: ["ABDOMEN_LEFT", "THIGH_RIGHT"], + notificationsEnabled: false, + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "A10BJ06", + rxNormCode: "1991302", + startsOn: "2026-10-10", + endsOn: "2026-12-31", + autoMarkTaken: true, + schedules: [ + schedule("18:00", "20:00", { + label: "Evening", + dose: "0.5 pen", + unitsPerDose: 1, + timesOfDay: ["18:30"], + reminderGraceMinutes: 45, + rrule: "FREQ=WEEKLY;BYDAY=MO", + doseWindows: [{ timeOfDay: "18:30", start: "18:00", end: "20:00" }], + }), + ], + }); }); - await create("13b-one-shot", { - name: "One shot", - dose: "1 dose", - oneShot: true, - startsOn: "2026-10-20", - endsOn: "2026-11-20", - schedules: [schedule("09:00", "11:00")], - }); - await create("13c-prn-and-legacy-days", { - name: "Mixed schedules", - dose: "2mg", - trackIntake: false, - schedules: [ - schedule("06:00", "07:00", { scheduleType: "PRN" }), - schedule("21:00", "22:00", { daysOfWeek: [1, 3, 5], intervalWeeks: 2 }), - schedule("12:00", "13:00", { rollingIntervalDays: 3 }), - ], - }); - await create("13d-ended-window", { - name: "Ended window", - dose: "3mg", - endsOn: "2026-10-05", - schedules: [schedule("08:00", "09:00")], - }); - }); - it("14 the refusals", async () => { - await create("14a-unknown-category", { - name: "Unknown category", - dose: "1mg", - category: "custom:0a6c2d4e-1111-4222-8333-944455556666", - schedules: [schedule("08:00", "09:00")], + it("13 an as-needed create, a one-shot one and a PRN schedule", async () => { + await create("13a-as-needed", { + name: "As needed", + dose: "400mg", + category: "PAIN_RELIEF", + asNeeded: true, + }); + await create("13b-one-shot", { + name: "One shot", + dose: "1 dose", + oneShot: true, + startsOn: "2026-10-20", + endsOn: "2026-11-20", + schedules: [schedule("09:00", "11:00")], + }); + await create("13c-prn-and-legacy-days", { + name: "Mixed schedules", + dose: "2mg", + trackIntake: false, + schedules: [ + schedule("06:00", "07:00", { scheduleType: "PRN" }), + schedule("21:00", "22:00", { + daysOfWeek: [1, 3, 5], + intervalWeeks: 2, + }), + schedule("12:00", "13:00", { rollingIntervalDays: 3 }), + ], + }); + await create("13d-ended-window", { + name: "Ended window", + dose: "3mg", + endsOn: "2026-10-05", + schedules: [schedule("08:00", "09:00")], + }); }); - await create("14b-course-ends-before-start", { - name: "Backwards course", - dose: "1mg", - startsOn: "2026-10-20", - endsOn: "2026-10-10", - schedules: [schedule("08:00", "09:00")], - }); - await create("14c-one-shot-two-schedules", { - name: "One shot twice", - dose: "1mg", - oneShot: true, - startsOn: "2026-10-20", - schedules: [schedule("08:00", "09:00"), schedule("20:00", "21:00")], - }); - await create("14d-one-shot-recurrence", { - name: "One shot recurring", - dose: "1mg", - oneShot: true, - startsOn: "2026-10-20", - schedules: [schedule("08:00", "09:00", { rrule: "FREQ=DAILY" })], - }); - await create("14e-unstable-concept-id", { - name: "Phantom", - dose: "1mg", - externalSource: "APPLE_HEALTH", - externalId: "", - schedules: [schedule("08:00", "09:00")], - }); - await create("14f-invalid", { name: "", dose: 5, schedules: "daily" }); - }); - it("15 a mirrored create and its replay", async () => { - const mirrored = { - name: "Mirrored", - dose: "50mg", - externalSource: "APPLE_HEALTH", - externalId: "hk-concept-0001", - schedules: [schedule("08:00", "09:00")], - }; - await create("15a-mirror", mirrored); - await create("15b-mirror-replay", { ...mirrored, name: "Renamed" }); - }); - - it("16 the mirror cap", async () => { - const capped = await makeUser({ timezone: "UTC" }); - await prisma.medication.createMany({ - data: Array.from({ length: 30 }, (_, i) => ({ - userId: capped.id, - name: `Mirror ${String(i).padStart(2, "0")}`, + it("14 the refusals", async () => { + await create("14a-unknown-category", { + name: "Unknown category", dose: "1mg", - externalSource: "APPLE_HEALTH" as const, - externalId: `hk-cap-${String(i).padStart(2, "0")}`, - })), - }); - await create( - "16-mirror-cap", - { - name: "One too many", + category: "custom:0a6c2d4e-1111-4222-8333-944455556666", + schedules: [schedule("08:00", "09:00")], + }); + await create("14b-course-ends-before-start", { + name: "Backwards course", + dose: "1mg", + startsOn: "2026-10-20", + endsOn: "2026-10-10", + schedules: [schedule("08:00", "09:00")], + }); + await create("14c-one-shot-two-schedules", { + name: "One shot twice", + dose: "1mg", + oneShot: true, + startsOn: "2026-10-20", + schedules: [schedule("08:00", "09:00"), schedule("20:00", "21:00")], + }); + await create("14d-one-shot-recurrence", { + name: "One shot recurring", + dose: "1mg", + oneShot: true, + startsOn: "2026-10-20", + schedules: [schedule("08:00", "09:00", { rrule: "FREQ=DAILY" })], + }); + await create("14e-unstable-concept-id", { + name: "Phantom", dose: "1mg", externalSource: "APPLE_HEALTH", - externalId: "hk-cap-30", + externalId: "", schedules: [schedule("08:00", "09:00")], - }, - { auth: await makeSession(capped), userIds: [capped.id] }, - ); - }); + }); + await create("14f-invalid", { name: "", dose: 5, schedules: "daily" }); + }); - it("17 a retried create under one idempotency key", async () => { - const body = { - name: "Retried", - dose: "10mg", - schedules: [schedule("08:00", "09:00")], - }; - const headers = { - "idempotency-key": "6f9d2c4a-8b1e-4f3a-9d7c-5e2b1a0c9d8e", - }; - await create("17a-idempotent-first", body, { headers }); - await create("17b-idempotent-replay", body, { headers }); - }); + it("15 a mirrored create and its replay", async () => { + const mirrored = { + name: "Mirrored", + dose: "50mg", + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-0001", + schedules: [schedule("08:00", "09:00")], + }; + await create("15a-mirror", mirrored); + await create("15b-mirror-replay", { ...mirrored, name: "Renamed" }); + }); - it("18 a delegate who may write, and one who may only read", async () => { - const body = { - name: "Brought by a delegate", - dose: "20mg", - schedules: [schedule("08:00", "09:00")], - }; - await create("18a-writer", body, writer); - await create("18b-reader", body, reader); - }); + it("16 the mirror cap", async () => { + const capped = await makeUser({ timezone: "UTC" }); + await prisma.medication.createMany({ + data: Array.from({ length: 30 }, (_, i) => ({ + userId: capped.id, + name: `Mirror ${String(i).padStart(2, "0")}`, + dose: "1mg", + externalSource: "APPLE_HEALTH" as const, + externalId: `hk-cap-${String(i).padStart(2, "0")}`, + })), + }); + await create( + "16-mirror-cap", + { + name: "One too many", + dose: "1mg", + externalSource: "APPLE_HEALTH", + externalId: "hk-cap-30", + schedules: [schedule("08:00", "09:00")], + }, + { auth: await makeSession(capped), userIds: [capped.id] }, + ); + }); - it("19 no credentials", async () => { - await create( - "19-anonymous", - { name: "Nobody", dose: "1mg" }, - { - auth: anonymous, - }, - ); + it("17 a retried create under one idempotency key", async () => { + const body = { + name: "Retried", + dose: "10mg", + schedules: [schedule("08:00", "09:00")], + }; + const headers = { + "idempotency-key": "6f9d2c4a-8b1e-4f3a-9d7c-5e2b1a0c9d8e", + }; + await create("17a-idempotent-first", body, { headers }); + await create("17b-idempotent-replay", body, { headers }); + }); + + it("18 a delegate who may write, and one who may only read", async () => { + const body = { + name: "Brought by a delegate", + dose: "20mg", + schedules: [schedule("08:00", "09:00")], + }; + await create("18a-writer", body, writer); + await create("18b-reader", body, reader); + }); + + it("19 no credentials", async () => { + await create( + "19-anonymous", + { name: "Nobody", dose: "1mg" }, + { + auth: anonymous, + }, + ); + }); }); }); diff --git a/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts b/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts index 6c4a79519..6f58473a1 100644 --- a/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts +++ b/tests/integration/routes/parity/mood-entries-bulk.parity.test.ts @@ -214,7 +214,7 @@ afterAll(async () => { await prisma.moodTag.delete({ where: { id: SHORT_FACTOR_ID } }); }); -describe("POST /api/mood-entries/bulk parity", () => { +describe("POST /api/mood-entries/bulk parity", { shuffle: false }, () => { it("01 a first drain", async () => { await step("01-first-drain", { entries: firstDrain }); }); diff --git a/tests/integration/routes/parity/oidc-callback.parity.test.ts b/tests/integration/routes/parity/oidc-callback.parity.test.ts index 661d78770..c49bc1a85 100644 --- a/tests/integration/routes/parity/oidc-callback.parity.test.ts +++ b/tests/integration/routes/parity/oidc-callback.parity.test.ts @@ -270,7 +270,7 @@ afterAll(() => { } }); -describe("GET /api/auth/oidc/callback parity", () => { +describe("GET /api/auth/oidc/callback parity", { shuffle: false }, () => { it("01 SSO switched off", async () => { await step("01-disabled", { state: {} }); process.env.OIDC_ISSUER_URL = ISSUER; diff --git a/tests/integration/routes/parity/sync-changes.parity.test.ts b/tests/integration/routes/parity/sync-changes.parity.test.ts index d4d092ff9..c1c39b39e 100644 --- a/tests/integration/routes/parity/sync-changes.parity.test.ts +++ b/tests/integration/routes/parity/sync-changes.parity.test.ts @@ -298,7 +298,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/sync/changes parity", () => { +describe("GET /api/sync/changes parity", { shuffle: false }, () => { let firstCursor: string; it("drains the feed in small pages to the end", async () => { diff --git a/tests/integration/routes/parity/tokens-mirror.parity.test.ts b/tests/integration/routes/parity/tokens-mirror.parity.test.ts index 141c811ec..5d69444d7 100644 --- a/tests/integration/routes/parity/tokens-mirror.parity.test.ts +++ b/tests/integration/routes/parity/tokens-mirror.parity.test.ts @@ -176,63 +176,67 @@ beforeAll(async () => { }); }); -describe("the Apple Health write-back of a measurement token", () => { - it("walks the cases in order", async () => { - await step("01-summary", { kind: "summary" }); - await step("02-on", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: true }, +describe( + "the Apple Health write-back of a measurement token", + { shuffle: false }, + () => { + it("walks the cases in order", async () => { + await step("01-summary", { kind: "summary" }); + await step("02-on", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("03-on-again", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("04-list", { kind: "list" }); + const revoked = await callRoute(revoke, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: scale.tokenId }, + auth: owner, + }); + expect(revoked.status).toBe(200); + await step("05-off-revoked", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: false }, + }); + await step("06-wildcard", { + kind: "patch", + id: wildcard.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("07-connector", { + kind: "patch", + id: connector.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("08-another-account", { + kind: "patch", + id: theirs.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("09-unknown", { + kind: "patch", + id: "missing-token", + body: { mirrorToAppleHealth: true }, + }); + await step("10-invalid-body", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: "yes", name: "Renamed" }, + }); + await step("11-bearer", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + auth: wildcard, + }); + await step("12-summary-bearer", { kind: "summary", auth: wildcard }); }); - await step("03-on-again", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("04-list", { kind: "list" }); - const revoked = await callRoute(revoke, { - method: "DELETE", - route: "/api/tokens/{id}", - params: { id: scale.tokenId }, - auth: owner, - }); - expect(revoked.status).toBe(200); - await step("05-off-revoked", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: false }, - }); - await step("06-wildcard", { - kind: "patch", - id: wildcard.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("07-connector", { - kind: "patch", - id: connector.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("08-another-account", { - kind: "patch", - id: theirs.tokenId, - body: { mirrorToAppleHealth: true }, - }); - await step("09-unknown", { - kind: "patch", - id: "missing-token", - body: { mirrorToAppleHealth: true }, - }); - await step("10-invalid-body", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: "yes", name: "Renamed" }, - }); - await step("11-bearer", { - kind: "patch", - id: scale.tokenId, - body: { mirrorToAppleHealth: true }, - auth: wildcard, - }); - await step("12-summary-bearer", { kind: "summary", auth: wildcard }); - }); -}); + }, +); diff --git a/tests/integration/routes/parity/workout-detail.parity.test.ts b/tests/integration/routes/parity/workout-detail.parity.test.ts index 3b5e8debf..bb4ee814b 100644 --- a/tests/integration/routes/parity/workout-detail.parity.test.ts +++ b/tests/integration/routes/parity/workout-detail.parity.test.ts @@ -248,64 +248,68 @@ afterAll(() => { vi.useRealTimers(); }); -describe("GET /api/workouts/{id} parity", () => { - it("the detail reads", async () => { - await step("get-01-canonical", { method: "GET", id: ids.run }); - await step("get-02-compact", { - method: "GET", - id: ids.run, - query: { compact: "1" }, +// One walk: each request sees what the earlier ones stored, so the cases +// keep their order even when a run shuffles (`shuffle: false`). +describe("workout-detail parity walk", { shuffle: false }, () => { + describe("GET /api/workouts/{id} parity", () => { + it("the detail reads", async () => { + await step("get-01-canonical", { method: "GET", id: ids.run }); + await step("get-02-compact", { + method: "GET", + id: ids.run, + query: { compact: "1" }, + }); + await step("get-03-hidden-twin", { method: "GET", id: ids.twin }); + await step("get-04-ride", { method: "GET", id: ids.ride }); + await step("get-05-other-account", { + method: "GET", + id: ids.run, + auth: stranger, + }); + await step("get-06-missing", { + method: "GET", + id: "cmissingworkout000000000", + }); }); - await step("get-03-hidden-twin", { method: "GET", id: ids.twin }); - await step("get-04-ride", { method: "GET", id: ids.ride }); - await step("get-05-other-account", { - method: "GET", - id: ids.run, - auth: stranger, - }); - await step("get-06-missing", { - method: "GET", - id: "cmissingworkout000000000", + + it("the module switched off", async () => { + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { workouts: false } }, + }); + await step("get-07-module-off", { method: "GET", id: ids.run }); + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: {} }, + }); }); }); - it("the module switched off", async () => { - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: { workouts: false } }, + describe("DELETE /api/workouts/{id} parity", () => { + it("a synced workout, a missing one, another account's", async () => { + await step("delete-01-synced", { + method: "DELETE", + id: ids.ride, + readBack: true, + }); + await step("delete-02-missing", { + method: "DELETE", + id: "cmissingworkout000000000", + }); + await step("delete-03-other-account", { + method: "DELETE", + id: ids.walk, + auth: stranger, + readBack: true, + }); }); - await step("get-07-module-off", { method: "GET", id: ids.run }); - await prisma.user.update({ - where: { id: userId }, - data: { modulePreferencesJson: {} }, - }); - }); -}); -describe("DELETE /api/workouts/{id} parity", () => { - it("a synced workout, a missing one, another account's", async () => { - await step("delete-01-synced", { - method: "DELETE", - id: ids.ride, - readBack: true, - }); - await step("delete-02-missing", { - method: "DELETE", - id: "cmissingworkout000000000", - }); - await step("delete-03-other-account", { - method: "DELETE", - id: ids.walk, - auth: stranger, - readBack: true, - }); - }); - - it("a hand-entered workout and the record it set", async () => { - await step("delete-04-manual", { - method: "DELETE", - id: ids.walk, - readBack: true, + it("a hand-entered workout and the record it set", async () => { + await step("delete-04-manual", { + method: "DELETE", + id: ids.walk, + readBack: true, + }); }); }); }); diff --git a/tests/integration/routes/parity/workouts-batch.parity.test.ts b/tests/integration/routes/parity/workouts-batch.parity.test.ts index 66b2ff8b6..ae4e77588 100644 --- a/tests/integration/routes/parity/workouts-batch.parity.test.ts +++ b/tests/integration/routes/parity/workouts-batch.parity.test.ts @@ -238,7 +238,7 @@ afterAll(() => { vi.useRealTimers(); }); -describe("POST /api/workouts/batch parity", () => { +describe("POST /api/workouts/batch parity", { shuffle: false }, () => { it("01 a first batch", async () => { await step("01-first-batch", { workouts: FIRST_BATCH }); });