From 1007ca3d1c0da21423ddf84406bcc0fe6456f69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 23:18:26 +0200 Subject: [PATCH] test(medications): cover the mirror growth guard and provenance echo A 31st new mirror create at the cap returns 422 with the limit_exceeded errorCode; an idempotent re-post of an existing mirror at the cap still returns 200; a native create at the cap is unaffected and never runs the count. The list read exposes externalSource as APPLE_HEALTH for a mirror and null for a native row. --- .../api/medications/__tests__/route.test.ts | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 17a683f29..c61b568dd 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -10,6 +10,8 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn().mockResolvedValue([]), // v1.28 — the Apple-mirror idempotency pre-query. findFirst: vi.fn().mockResolvedValue(null), + // v1.32.25 — the mirror growth-guard count. + count: vi.fn().mockResolvedValue(0), create: vi.fn(), }, medicationIntakeEvent: { @@ -76,6 +78,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { cachedSwr } from "@/lib/cache/server-cache"; import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { getMedicationCategories } from "@/lib/medication-category"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -93,6 +96,9 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // v1.32.25 — default the mirror growth-guard count below the cap so the + // pre-existing mirror-create tests hit the create path, not the guard. + vi.mocked(prisma.medication.count).mockResolvedValue(0 as never); // Default happy-path stub: return the data shape the caller passed in // so test assertions can inspect what the route actually forwarded // to Prisma without re-implementing the create-returning DB. @@ -630,3 +636,166 @@ describe("POST /api/medications — Apple Health mirror (v1.28)", () => { expect("externalId" in data).toBe(false); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.32.25 — mirror growth guard + provenance echo +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications — mirror growth guard (v1.32.25)", () => { + const MIRROR_BODY = { + name: "Blutdruckmittel", + dose: "1", + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-new", + asNeeded: true, + }; + + it("refuses a NEW mirror create (422) once the user already holds the cap", async () => { + // Idempotency miss (genuinely new row) + user already at 30 mirrors. + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medication.count).mockResolvedValue(30 as never); + + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(422); + const body = (await res.json()) as { + error: string; + meta: { errorCode: string }; + }; + expect(body.meta.errorCode).toBe("medications.mirror.limit_exceeded"); + // Nothing minted — the guard blocks the write. + expect(prisma.medication.create).not.toHaveBeenCalled(); + // The count was scoped to the user's Apple-Health mirrors. + expect(prisma.medication.count).toHaveBeenCalledWith({ + where: { userId: "user-1", externalSource: "APPLE_HEALTH" }, + }); + }); + + it("still returns 200 for an idempotent re-post of an EXISTING mirror while at the cap — the guard never blocks a replay", async () => { + // Even at/over the cap, a re-post of a mirror the user already holds + // must resolve to the existing row (200), because the idempotency + // lookup HITS before the growth guard runs. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "med-existing", + userId: "user-1", + name: "Blutdruckmittel", + dose: "1", + unitsPerDose: 1, + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-new", + schedules: [], + } as never); + vi.mocked(prisma.medication.count).mockResolvedValue(50 as never); + + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string } }; + expect(body.data.id).toBe("med-existing"); + // Replay: neither the guard count nor a write ran. + expect(prisma.medication.count).not.toHaveBeenCalled(); + expect(prisma.medication.create).not.toHaveBeenCalled(); + }); + + it("does not gate a native create (no externalSource) even while at the cap", async () => { + // A native medication has no external provenance, so the guard branch + // is never entered — the count query must not even run. + vi.mocked(prisma.medication.count).mockResolvedValue(999 as never); + + const res = await POST( + postReq({ + name: "Ramipril", + dose: "5 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(res.status).toBe(201); + expect(prisma.medication.count).not.toHaveBeenCalled(); + expect(prisma.medication.create).toHaveBeenCalled(); + }); + + it("admits a NEW mirror create below the cap (201)", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medication.count).mockResolvedValue(29 as never); + + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(201); + expect(prisma.medication.create).toHaveBeenCalled(); + }); +}); + +describe("GET /api/medications — provenance echo (v1.32.25)", () => { + function rewireListRead(medications: unknown[]) { + vi.mocked(cachedSwr).mockImplementation((async ( + _c: unknown, + _k: string, + f: () => Promise, + ) => f()) as never); + vi.mocked(getUserTodayBounds).mockReturnValue({ + start: new Date("2026-07-24T00:00:00Z"), + end: new Date("2026-07-24T23:59:59Z"), + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue( + medications as never, + ); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationInventoryItem.groupBy).mockResolvedValue( + [] as never, + ); + // `vi.resetAllMocks()` clears the factory default; the list read reads + // `categoryMap[m.id]` outside its try/catch, so a non-empty row set + // needs the category map to resolve to an object. + vi.mocked(getMedicationCategories).mockResolvedValue({}); + } + + it("exposes externalSource on each row: APPLE_HEALTH for a mirror, null for a native med", async () => { + rewireListRead([ + { + id: "med-mirror", + userId: "user-1", + name: "Blutdruckmittel", + dose: "1", + unitsPerDose: 1, + externalSource: "APPLE_HEALTH", + externalId: "hk-1", + createdAt: new Date("2026-07-20T00:00:00Z"), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }, + { + id: "med-native", + userId: "user-1", + name: "Ramipril", + dose: "5 mg", + unitsPerDose: 1, + externalSource: null, + externalId: null, + createdAt: new Date("2026-07-19T00:00:00Z"), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [], + }, + ]); + + const res = await ( + GET as unknown as (req: NextRequest) => Promise + )(new NextRequest("http://localhost/api/medications")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ id: string; externalSource: string | null }>; + }; + const mirror = body.data.find((m) => m.id === "med-mirror"); + const native = body.data.find((m) => m.id === "med-native"); + expect(mirror?.externalSource).toBe("APPLE_HEALTH"); + expect(native?.externalSource).toBeNull(); + }); +});