diff --git a/src/app/api/medications/[id]/cadence/__tests__/route.test.ts b/src/app/api/medications/[id]/cadence/__tests__/route.test.ts new file mode 100644 index 000000000..eac875a93 --- /dev/null +++ b/src/app/api/medications/[id]/cadence/__tests__/route.test.ts @@ -0,0 +1,168 @@ +/** + * v1.4.25 W19e — cadence + compliance API route tests. + * + * Mirrors the W19d side-effect test fixture pattern: external + * dependencies are mocked at module boundaries (Prisma, session, + * logging transport), and each test pins one contract case. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findUnique: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/medications/[id]/cadence", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/cadence"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(401); + }); + + it("returns 404 when the medication is not owned by the caller", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "OTHER", + createdAt: new Date(), + schedules: [], + } as never); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/cadence"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns 422 for an out-of-range days parameter", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + createdAt: new Date(), + schedules: [], + } as never); + + const res = await GET( + new NextRequest( + "http://localhost/api/medications/med-1/cadence?days=9999", + ), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns the timeline + chips for an owned medication", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + schedules: [ + { + id: "sch-1", + medicationId: "med-1", + windowStart: "08:00", + windowEnd: "09:00", + label: null, + dose: null, + daysOfWeek: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]); + + const res = await GET( + new NextRequest( + "http://localhost/api/medications/med-1/cadence?days=14", + ), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.windowDays).toBe(14); + expect(Array.isArray(json.data.timeline)).toBe(true); + expect(json.data.chips).toMatchObject({ + adherenceRate: expect.anything(), + currentStreak: expect.any(Number), + longestStreak: expect.any(Number), + missedLast30: expect.any(Number), + }); + }); + + it("defaults to a 30-day window when no `days` query param is supplied", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + schedules: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/cadence"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.windowDays).toBe(30); + }); + + it("returns a `null` next-dose when no schedule expands in the lookahead", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + schedules: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/cadence"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.next).toBeNull(); + }); +}); diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts new file mode 100644 index 000000000..51e830c5b --- /dev/null +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -0,0 +1,128 @@ +/** + * v1.4.25 W19e — GLP-1 cadence + compliance read endpoint. + * + * GET /api/medications/[id]/cadence?days=30 + * - returns the schedule's 30-day (configurable) expected-vs-actual + * dose timeline plus the four compliance chip values that drive + * the detail-page section. + * + * Pure computation — no writes. The route delegates math to the + * `src/lib/medications/scheduling/{cadence,compliance}` pure modules + * so the chart, the chips, and the server agree dose-for-dose. + * + * Auth: cookie-session via requireAuth(); the medication is verified + * to belong to the caller before any read. + */ + +import { NextRequest } from "next/server"; +import { z } from "zod"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { + buildCadenceTimeline, + computeNextDose, +} from "@/lib/medications/scheduling/cadence"; +import { complianceChips } from "@/lib/medications/scheduling/compliance"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const querySchema = z.object({ + days: z.coerce.number().int().min(1).max(180).optional(), +}); + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const med = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + if (!med || med.userId !== user.id) { + return apiError("Medication not found", 404); + } + + const url = new URL(request.url); + const parsed = querySchema.safeParse({ + days: url.searchParams.get("days") ?? undefined, + }); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + const windowDays = parsed.data.days ?? 30; + const asOf = new Date(); + + // Pull intake events covering the window + a small buffer so a dose + // logged at the edge can still pair with its slot. + const bufferMs = 24 * 60 * 60 * 1000; + const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs); + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + medicationId: id, + userId: user.id, + scheduledFor: { gte: from }, + }, + select: { scheduledFor: true, takenAt: true, skipped: true }, + orderBy: { scheduledFor: "asc" }, + }); + + // The cadence-anchor is the medication's createdAt for stable + // intervalWeeks=N phasing. Falling back to `from` would re-anchor + // every window-size change, which would make a 30-day vs 90-day + // chart disagree on the bi-weekly grid. + const anchor = med.createdAt; + + const timeline = buildCadenceTimeline( + med.schedules, + events, + asOf, + windowDays, + anchor, + ); + const chips = complianceChips( + med.schedules, + events, + asOf, + windowDays, + anchor, + ); + const next = computeNextDose(med.schedules, asOf, 14, anchor); + + annotate({ + action: { + name: "medication.cadence", + entity_type: "medication", + entity_id: id, + }, + meta: { + window_days: windowDays, + slots: timeline.length, + adherence: chips.adherenceRate, + }, + }); + + return apiSuccess({ + windowDays, + anchorIso: anchor.toISOString(), + next: next + ? { + windowStart: next.windowStart.toISOString(), + windowEnd: next.windowEnd.toISOString(), + scheduleIndex: next.scheduleIndex, + } + : null, + chips, + timeline: timeline.map((p) => ({ + day: p.day.toISOString(), + windowStart: p.windowStart.toISOString(), + windowEnd: p.windowEnd.toISOString(), + scheduleIndex: p.scheduleIndex, + status: p.status, + })), + }); + }, +);