Files
HealthLog/src/app/api/medications/__tests__/route.test.ts
T
Marc-André Bombeck aaecd141ee fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body
The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
2026-06-12 10:43:32 +02:00

414 lines
13 KiB
TypeScript

/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
medicationIntakeEvent: {
groupBy: vi.fn().mockResolvedValue([]),
findMany: vi.fn().mockResolvedValue([]),
},
medicationScheduleRevision: {
groupBy: vi.fn().mockResolvedValue([]),
},
medicationCategoryAssignment: {
findMany: vi.fn().mockResolvedValue([]),
},
medicationSchedule: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/cache/server-cache", () => ({
cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
f(),
),
cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
f(),
),
caches: { medicationsList: {} },
__resetAllCachesForTests: vi.fn(),
}));
vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn().mockResolvedValue({}),
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/tz/local-day", () => ({
getUserTodayBounds: vi
.fn()
.mockReturnValue({ start: new Date(), end: new Date() }),
}));
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, POST } from "../route";
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";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK 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.
vi.mocked(prisma.medication.create).mockImplementation((async (
args: { data: Record<string, unknown> },
) => ({
id: "med-1",
userId: "user-1",
...args.data,
// Reflect the nested `schedules.create` as a populated array so
// the include returns the same shape as Prisma.
schedules: Array.isArray(
(args.data as { schedules?: { create?: unknown[] } }).schedules?.create,
)
? (
args.data as {
schedules: { create: Record<string, unknown>[] };
}
).schedules.create.map((s, i) => ({
id: `sched-${i}`,
medicationId: "med-1",
...s,
}))
: [],
})) as never);
});
describe("POST /api/medications — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Missing `name` + bad `dose` (e.g. number).
const res = await POST(postReq({ dose: 42, dosesPerUnit: -1 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// name not a string + bad dose + bad dosesPerUnit.
const res = await POST(
postReq({ name: 123, dose: 42, dosesPerUnit: "bad" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("POST /api/medications — v1.5 scheduling primitives", () => {
/**
* Helper to extract the `data.schedules.create[i]` payload the route
* forwarded to Prisma.create — that's the single point of truth this
* test suite cares about.
*/
function lastCreateCall(): {
data: Record<string, unknown> & {
schedules: { create: Record<string, unknown>[] };
};
} {
const calls = vi.mocked(prisma.medication.create).mock.calls;
expect(calls.length).toBeGreaterThan(0);
return calls[calls.length - 1][0] as never;
}
it("(1) one-shot create writes oneShot=true + single timesOfDay schedule", async () => {
const res = await POST(
postReq({
name: "Flu shot",
dose: "1 shot",
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{ windowStart: "10:00", windowEnd: "10:30", timesOfDay: ["10:00"] },
],
}),
);
expect(res.status).toBe(201);
const call = lastCreateCall();
expect(call.data.oneShot).toBe(true);
// endsOn auto-normalised to startsOn for one-shot.
expect(call.data.endsOn).toBeInstanceOf(Date);
expect((call.data.endsOn as Date).toISOString()).toEqual(
(call.data.startsOn as Date).toISOString(),
);
const s = call.data.schedules.create[0];
expect(s.timesOfDay).toEqual(["10:00"]);
expect(s.rrule).toBeUndefined();
expect(s.rollingIntervalDays).toBeUndefined();
});
it("(2) RRULE schedule is persisted verbatim", async () => {
const res = await POST(
postReq({
name: "Monthly med",
dose: "1 tab",
schedules: [
{
windowStart: "08:00",
windowEnd: "08:30",
rrule: "FREQ=MONTHLY;BYMONTHDAY=1",
timesOfDay: ["08:00"],
},
],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
expect(s.rrule).toBe("FREQ=MONTHLY;BYMONTHDAY=1");
expect(s.timesOfDay).toEqual(["08:00"]);
});
it("(3) rolling schedule is persisted with rollingIntervalDays", async () => {
const res = await POST(
postReq({
name: "Mounjaro",
dose: "5mg",
schedules: [
{
windowStart: "09:00",
windowEnd: "09:30",
rollingIntervalDays: 7,
timesOfDay: ["09:00"],
},
],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
expect(s.rollingIntervalDays).toBe(7);
expect(s.rrule).toBeUndefined();
expect(s.timesOfDay).toEqual(["09:00"]);
});
it("(4) 422 when oneShot=true AND schedule carries recurrence", async () => {
const res = await POST(
postReq({
name: "Bad one-shot",
dose: "1",
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{
windowStart: "10:00",
windowEnd: "10:30",
rrule: "FREQ=DAILY",
},
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/one-shot.*recurrence/i);
});
it("(5) 422 when oneShot=true AND multiple schedules", async () => {
const res = await POST(
postReq({
name: "Bad one-shot",
dose: "1",
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{ windowStart: "08:00", windowEnd: "08:30" },
{ windowStart: "20:00", windowEnd: "20:30" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/one-shot.*one schedule/i);
});
it("(7) legacy POST without new fields back-fills timesOfDay from windowStart", async () => {
const res = await POST(
postReq({
name: "Daily vitamin",
dose: "1 tab",
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: [1, 2, 3, 4, 5],
},
],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
// timesOfDay populated server-side from windowStart.
expect(s.timesOfDay).toEqual(["08:00"]);
// Legacy daysOfWeek serialised; new rrule not stamped because
// legacy days were supplied.
expect(s.daysOfWeek).toBe("1,2,3,4,5");
expect(s.rrule).toBeUndefined();
});
it("(7b) legacy POST with no daysOfWeek, no rrule, no rolling → defaults rrule=FREQ=DAILY", async () => {
const res = await POST(
postReq({
name: "Plain daily",
dose: "1 tab",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
expect(s.rrule).toBe("FREQ=DAILY");
expect(s.timesOfDay).toEqual(["08:00"]);
});
});
describe("POST /api/medications — v1.5.4 clinical category extension", () => {
/**
* The modal wizard's Step 2 taxonomy adds Diabetes and Antibiotikum
* as first-class rows. The mapping table writes `category:
* "DIABETES"` and `category: "ANTIBIOTIC"` to the side-table for
* those rows instead of collapsing them into `"OTHER"`.
*
* The Zod schema accepts the new values; the route's
* `setMedicationCategory` helper normalises them through
* `MEDICATION_CATEGORY_VALUES`. The tests below pin that the route
* accepts both new values without 422-ing.
*/
it("accepts DIABETES as a valid clinical category", async () => {
const res = await POST(
postReq({
name: "Metformin",
dose: "500 mg",
category: "DIABETES",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(201);
});
it("accepts ANTIBIOTIC as a valid clinical category", async () => {
const res = await POST(
postReq({
name: "Amoxicillin",
dose: "500 mg",
category: "ANTIBIOTIC",
oneShot: true,
startsOn: "2026-06-01",
schedules: [{ windowStart: "08:00", windowEnd: "08:30" }],
}),
);
expect(res.status).toBe(201);
});
it("still 422s on an unknown category string", async () => {
const res = await POST(
postReq({
name: "Random",
dose: "1 tab",
category: "MYSTERY",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(422);
});
});
describe("GET /api/medications — todayEventCount counts only actioned rows (v1.16.9)", () => {
it("filters the today-count groupBy to taken-or-skipped rows", async () => {
// The dashboard projector mints a pending row per slot of the day, so
// an unfiltered count covered every passed dose after any dashboard
// visit and the cards' overdue pill went dark. The count read must
// exclude pending rows.
// (`vi.resetAllMocks()` in beforeEach clears the factory defaults, so
// re-wire the pass-through + empty result sets here.)
vi.mocked(cachedSwr).mockImplementation((async (
_c: unknown,
_k: string,
f: () => Promise<unknown>,
) => f()) as never);
vi.mocked(getUserTodayBounds).mockReturnValue({
start: new Date("2026-06-10T00:00:00Z"),
end: new Date("2026-06-10T23:59:59Z"),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] 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,
);
// The handler body takes no parameters; `apiHandler` still receives
// the request through the wrapper's rest args.
const res = await (
GET as unknown as (req: NextRequest) => Promise<Response>
)(new NextRequest("http://localhost/api/medications"));
expect(res.status).toBe(200);
const groupByCalls = vi.mocked(prisma.medicationIntakeEvent.groupBy).mock
.calls;
const countCall = groupByCalls.find(
(c) => (c[0] as { _count?: unknown })._count !== undefined,
);
expect(countCall).toBeDefined();
const where = (
countCall![0] as {
where: { OR?: unknown };
}
).where;
expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]);
});
});