From 05b5eb694228c596f7438357a39c3cc2a8ac014e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 23:00:16 +0100 Subject: [PATCH] test(medications): prove the dose history window against Postgres The cases for the bounded read asserted the query's where clause on a mocked client and cast their fixtures past the type checker. Against Postgres they now show what the read promises: a rolling medication last taken before the window shows the dose that fell due inside it, and a year of earlier takes answers the same ledger as a short history. --- .../[id]/dose-history/__tests__/route.test.ts | 71 ----------- tests/integration/dose-history-window.test.ts | 117 ++++++++++++++++++ 2 files changed, 117 insertions(+), 71 deletions(-) create mode 100644 tests/integration/dose-history-window.test.ts diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 8edbfb47d..303565453 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -567,74 +567,3 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102 }); } }); - -/** - * The history reads the window's rows, not every intake the medication ever - * had: for a medication taken for years each open of the tab read thousands - * of rows to show at most a year. Of the rows before the window only the - * latest take is read, because a rolling grid re-starts from it. - */ -describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => { - const FROM = new Date("2026-06-01T00:00:00Z"); - const TO = new Date("2026-06-15T00:00:00Z"); - const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`; - - it("asks only for rows anchored or taken in the window", async () => { - await GET(getReq(windowQuery), ROUTE_PARAMS); - - const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock - .calls[0][0]?.where; - expect(where).toMatchObject({ - OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }], - }); - const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock - .calls[0][0]; - expect(before?.where).toMatchObject({ - skipped: false, - scheduledFor: { lt: FROM }, - takenAt: { lt: FROM }, - }); - }); - - it("re-starts a rolling grid from the latest take before the window", async () => { - vi.mocked(prisma.medication.findUnique).mockResolvedValue({ - id: "med-1", - startsOn: null, - endsOn: null, - oneShot: false, - createdAt: new Date("2026-01-01T00:00:00Z"), - schedules: [ - { - id: "sched-1", - windowStart: "08:00", - windowEnd: "08:00", - daysOfWeek: null, - timesOfDay: ["08:00"], - reminderGraceMinutes: null, - rrule: null, - rollingIntervalDays: 7, - scheduleType: "SCHEDULED", - cyclicOnWeeks: null, - cyclicOffWeeks: null, - }, - ], - } as never); - // Taken four days before the window opens, nothing since: the next dose - // was due inside the window and reads as missed there. - vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ - takenAt: new Date("2026-05-28T06:00:00Z"), - } as never); - - const res = await GET(getReq(windowQuery), ROUTE_PARAMS); - expect(res.status).toBe(200); - const rows = (await res.json()).data.rows as Array<{ - at: string; - kind: string; - status: string; - }>; - - expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([ - ["2026-06-04", "slot", "missed"], - ]); - }); -}); diff --git a/tests/integration/dose-history-window.test.ts b/tests/integration/dose-history-window.test.ts new file mode 100644 index 000000000..5068770d3 --- /dev/null +++ b/tests/integration/dose-history-window.test.ts @@ -0,0 +1,117 @@ +/** + * `GET /api/medications/{id}/dose-history` reads the window it shows, against + * Postgres. + * + * Of the rows before the window only the latest take is read: a rolling grid + * re-starts from it, and earlier takes place no slot in the window. So a + * long history before the window answers the same ledger as a short one, and + * a rolling medication last taken before the window shows the dose that fell + * due inside it. All data invented. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as getDoseHistory } from "@/app/api/medications/[id]/dose-history/route"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { makeMedication, makeSession, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const DAY_MS = 86_400_000; +const FROM = new Date("2026-06-01T00:00:00Z"); +const TO = new Date("2026-06-15T00:00:00Z"); + +interface LedgerBody { + rows: Array<{ at: string; kind: string; status: string }>; +} + +async function ledger(auth: RouteAuth, id: string) { + const res = await callRoute(getDoseHistory, { + method: "GET", + route: "/api/medications/{id}/dose-history", + params: { id }, + query: { from: FROM.toISOString(), to: TO.toISOString() }, + auth, + }); + expect(res.status).toBe(200); + return res.data.rows.map((r) => [r.at.slice(0, 10), r.kind, r.status]); +} + +/** Doses taken on time at 08:00 Berlin (06:00 UTC in summer), one per day. */ +async function takeOn(userId: string, medicationId: string, days: string[]) { + await prisma.medicationIntakeEvent.createMany({ + data: days.map((day) => { + const at = new Date(`${day}T06:00:00Z`); + return { + userId, + medicationId, + scheduledFor: at, + takenAt: at, + skipped: false, + source: "WEB" as const, + }; + }), + }); +} + +const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("the dose history of a window", () => { + it("re-starts a rolling grid from the latest take before the window", async () => { + const user = await makeUser({ timezone: TZ }); + const auth = await makeSession(user); + const med = await makeMedication( + user, + { createdAt: new Date("2026-01-01T00:00:00Z") }, + [ + { + windowStart: "08:00", + windowEnd: "08:00", + timesOfDay: ["08:00"], + rollingIntervalDays: 7, + }, + ], + ); + // Last taken four days before the window opens and not after: the next + // dose was due inside the window and reads as missed there. + await takeOn(user.id, med.id, ["2026-05-14", "2026-05-21", "2026-05-28"]); + + expect(await ledger(auth, med.id)).toEqual([ + ["2026-06-04", "slot", "missed"], + ]); + }); + + it("answers the same ledger after a year of earlier takes", async () => { + const user = await makeUser({ timezone: TZ }); + const auth = await makeSession(user); + const daily = [ + { + windowStart: "08:00", + windowEnd: "08:00", + timesOfDay: ["08:00"], + }, + ]; + const created = { createdAt: new Date("2025-05-01T00:00:00Z") }; + const short = await makeMedication(user, created, daily); + const long = await makeMedication(user, created, daily); + const inWindow = ["2026-06-02", "2026-06-03", "2026-06-07"]; + await takeOn(user.id, short.id, ["2026-05-31", ...inWindow]); + const yearBefore: string[] = []; + for (let ms = Date.parse("2025-05-01"); ms <= Date.parse("2026-05-31");) { + yearBefore.push(isoDay(ms)); + ms += DAY_MS; + } + await takeOn(user.id, long.id, [...yearBefore, ...inWindow]); + + const shortLedger = await ledger(auth, short.id); + expect(shortLedger.filter((r) => r[2] === "taken_on_time")).toEqual( + inWindow.map((day) => [day, "slot", "taken_on_time"]), + ); + expect(await ledger(auth, long.id)).toEqual(shortLedger); + }); +});