mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(medications): cadence + compliance read endpoint
This commit is contained in:
1 parent
4af09170bb
commit
3ef49679ed
2 files changed
+296
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user