Files
HealthLog/tests/integration/medication-courses.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

483 lines
16 KiB
TypeScript

/**
* Several courses per medication (#1024) against a real Postgres, through the
* shipped route exports and the shipped migration backfill.
*
* What the file pins:
* - the migration's backfill turns every medication window into one course
* (an end-only window starting on the creation day), leaves a medication
* without a window course-less, and is a no-op on a re-run;
* - after every course write (create, edit, delete) the medication row
* carries the latest course as its window, so reminders and the dose
* actions read the right one;
* - overlapping courses, a course queued behind a running one and a second
* course on a one-time medication are refused, and a refused write
* changes nothing;
* - the medication's own PUT edits the latest course;
* - the list and detail reads publish the resolved course fields.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const OWNER = "user-med-courses";
const D = (key: string) => new Date(`${key}T00:00:00.000Z`);
const key = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : null);
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
await prisma.user.create({
data: {
id: OWNER,
username: OWNER,
email: `${OWNER}@example.test`,
timezone: "UTC",
},
});
const session = await prisma.session.create({
data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
});
function req(method: string, path: string, body?: unknown) {
return new NextRequest(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
async function makeMedication(window: {
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
}) {
const { POST } = await import("@/app/api/medications/route");
const res = await POST(
req("POST", "/api/medications", {
name: "Tamiflu",
dose: "75 mg",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
...window,
}),
);
const body = await res.json();
expect(res.status, JSON.stringify(body)).toBe(201);
return body.data as { id: string };
}
async function postCourse(id: string, body: unknown) {
const { POST } = await import("@/app/api/medications/[id]/courses/route");
return POST(req("POST", `/api/medications/${id}/courses`, body), {
params: Promise.resolve({ id }),
});
}
async function window(id: string) {
const row = await getPrismaClient().medication.findUniqueOrThrow({
where: { id },
select: { startsOn: true, endsOn: true },
});
return { startsOn: key(row.startsOn), endsOn: key(row.endsOn) };
}
async function courses(id: string) {
const rows = await getPrismaClient().medicationCourse.findMany({
where: { medicationId: id },
orderBy: { startsOn: "asc" },
});
return rows.map((r) => [key(r.startsOn), key(r.endsOn)]);
}
describe("migration 0368 backfill", () => {
const SQL = readFileSync(
join(
process.cwd(),
"prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql",
),
"utf8",
);
const backfill = SQL.slice(SQL.indexOf('INSERT INTO "medication_courses"'));
it("makes one course of every window and none of no window, idempotently", async () => {
const prisma = getPrismaClient();
const created = new Date("2026-02-10T09:00:00.000Z");
const base = { userId: OWNER, dose: "1", createdAt: created };
const both = await prisma.medication.create({
data: {
...base,
name: "both",
startsOn: D("2026-03-01"),
endsOn: D("2026-03-07"),
},
});
const startOnly = await prisma.medication.create({
data: { ...base, name: "start", startsOn: D("2026-03-01") },
});
const endOnly = await prisma.medication.create({
data: { ...base, name: "end", endsOn: D("2026-04-01") },
});
const backdated = await prisma.medication.create({
data: { ...base, name: "backdated", endsOn: D("2026-01-15") },
});
const chronic = await prisma.medication.create({
data: { ...base, name: "chronic" },
});
const eligible = await prisma.medication.count({
where: { OR: [{ startsOn: { not: null } }, { endsOn: { not: null } }] },
});
const inserted = await prisma.$executeRawUnsafe(backfill);
expect(inserted).toBe(eligible);
expect(await prisma.$executeRawUnsafe(backfill)).toBe(0);
expect(await courses(both.id)).toEqual([["2026-03-01", "2026-03-07"]]);
expect(await courses(startOnly.id)).toEqual([["2026-03-01", null]]);
expect(await courses(endOnly.id)).toEqual([["2026-02-10", "2026-04-01"]]);
expect(await courses(backdated.id)).toEqual([["2026-01-15", "2026-01-15"]]);
expect(await courses(chronic.id)).toEqual([]);
// Start after end (an older row): a one-day course on the end day.
// Created in a far-east zone: the creation day is the account's day,
// not the UTC one. An unknown stored zone falls back to the default.
const prismaKiri = getPrismaClient();
for (const [id, tz] of [
["kiri-user", "Pacific/Kiritimati"],
["mars-user", "Mars/Olympus_Mons"],
] as const) {
await prismaKiri.user.create({
data: { id, username: id, email: `${id}@example.test`, timezone: tz },
});
}
const late = new Date("2026-02-10T12:00:00.000Z"); // Feb 11 at UTC+14
const kiri = await prismaKiri.medication.create({
data: {
userId: "kiri-user",
name: "k",
dose: "1",
createdAt: late,
endsOn: D("2026-04-01"),
},
});
const mars = await prismaKiri.medication.create({
data: {
userId: "mars-user",
name: "m",
dose: "1",
createdAt: new Date("2026-02-10T23:30:00.000Z"),
endsOn: D("2026-04-01"),
},
});
const inverted = await prismaKiri.medication.create({
data: {
...base,
name: "inverted",
startsOn: D("2026-05-10"),
endsOn: D("2026-05-01"),
},
});
await prismaKiri.medicationCourse.deleteMany({});
await prismaKiri.$executeRawUnsafe(backfill);
expect(await courses(kiri.id)).toEqual([["2026-02-11", "2026-04-01"]]);
// Europe/Berlin: 23:30 UTC on Feb 10 is Feb 11.
expect(await courses(mars.id)).toEqual([["2026-02-11", "2026-04-01"]]);
expect(await courses(inverted.id)).toEqual([["2026-05-01", "2026-05-01"]]);
expect(await prismaKiri.$executeRawUnsafe(backfill)).toBe(0);
// Data-preserving: the medication columns are untouched.
expect(await window(endOnly.id)).toEqual({
startsOn: null,
endsOn: "2026-04-01",
});
});
});
describe("course writes keep the projection", () => {
it("projects the latest course after create, edit and delete", async () => {
const med = await makeMedication({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
});
expect(await courses(med.id)).toEqual([["2026-03-01", "2026-03-07"]]);
const created = await postCourse(med.id, {
startsOn: "2026-06-10",
endsOn: "2026-06-16",
note: "flu again",
});
const createdBody = await created.json();
expect(created.status, JSON.stringify(createdBody)).toBe(201);
expect(await window(med.id)).toEqual({
startsOn: "2026-06-10",
endsOn: "2026-06-16",
});
const { PATCH, DELETE } =
await import("@/app/api/medications/[id]/courses/[courseId]/route");
const ctx = {
params: Promise.resolve({ id: med.id, courseId: createdBody.data.id }),
};
const patched = await PATCH(
req("PATCH", "/x", { endsOn: "2026-06-20" }),
ctx,
);
expect(patched.status).toBe(200);
expect(await window(med.id)).toEqual({
startsOn: "2026-06-10",
endsOn: "2026-06-20",
});
const deleted = await DELETE(req("DELETE", "/x"), {
params: Promise.resolve({ id: med.id, courseId: createdBody.data.id }),
});
expect(deleted.status).toBe(200);
expect(await window(med.id)).toEqual({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
});
// Deleting the only course makes the medication continuous again.
const [only] = await getPrismaClient().medicationCourse.findMany({
where: { medicationId: med.id },
});
await DELETE(req("DELETE", "/x"), {
params: Promise.resolve({ id: med.id, courseId: only.id }),
});
expect(await window(med.id)).toEqual({ startsOn: null, endsOn: null });
});
it("refuses an overlap, a queued course and a second one-time course, changing nothing", async () => {
const med = await makeMedication({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
});
const overlap = await postCourse(med.id, {
startsOn: "2026-03-05",
endsOn: "2026-03-09",
});
expect(overlap.status).toBe(422);
expect((await overlap.json()).meta.errorCode).toBe(
"medications.course.overlap",
);
// A course that has not ended yet cannot have another one queued after
// it, even without sharing a day.
const shift = (days: number) =>
new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
const running = await makeMedication({
startsOn: shift(-2),
endsOn: shift(5),
});
const queued = await postCourse(running.id, {
startsOn: shift(10),
endsOn: shift(12),
});
expect(queued.status).toBe(422);
expect((await queued.json()).meta.errorCode).toBe(
"medications.course.currentOpen",
);
const once = await makeMedication({
startsOn: "2026-03-01",
oneShot: true,
});
const second = await postCourse(once.id, { startsOn: "2026-04-01" });
expect(second.status).toBe(422);
expect((await second.json()).meta.errorCode).toBe(
"medications.course.oneShot",
);
expect(await courses(med.id)).toEqual([["2026-03-01", "2026-03-07"]]);
expect(await window(med.id)).toEqual({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
});
});
it("never lets a first course cut off a chronic medication's history", async () => {
// Taken continuously since creation: no window, no course row. A course
// added beside it would project the medication into ending.
const chronic = await makeMedication({});
const refused = await postCourse(chronic.id, {
startsOn: "2026-03-01",
endsOn: "2026-03-31",
});
expect(refused.status).toBe(422);
expect((await refused.json()).meta.errorCode).toBe(
"medications.course.currentOpen",
);
expect(await courses(chronic.id)).toEqual([]);
expect(await window(chronic.id)).toEqual({ startsOn: null, endsOn: null });
// A window the rows never recorded (written past the course writer, as
// an older restore could) becomes the first course before the new one.
const legacy = await makeMedication({});
await getPrismaClient().medication.update({
where: { id: legacy.id },
data: { startsOn: D("2026-01-10"), endsOn: D("2026-01-20") },
});
const added = await postCourse(legacy.id, {
startsOn: "2026-03-01",
endsOn: "2026-03-05",
});
expect(added.status).toBe(201);
expect(await courses(legacy.id)).toEqual([
["2026-01-10", "2026-01-20"],
["2026-03-01", "2026-03-05"],
]);
});
it("lists every course's slots in the dose history, none in the gap", async () => {
// Daily 08:00 UTC (the account is on UTC). Course A five days, the third
// missed; a gap; course B five days, all taken. Both ended before today.
const shift = (days: number) =>
new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
const med = await makeMedication({
startsOn: shift(-20),
endsOn: shift(-16),
});
await postCourse(med.id, { startsOn: shift(-10), endsOn: shift(-6) });
const taken = [-20, -19, -17, -16, -10, -9, -8, -7, -6].map(shift);
await getPrismaClient().medicationIntakeEvent.createMany({
data: taken.map((day) => ({
userId: OWNER,
medicationId: med.id,
scheduledFor: new Date(`${day}T08:00:00.000Z`),
takenAt: new Date(`${day}T08:02:00.000Z`),
})),
});
// The medication existed long before course A.
await getPrismaClient().medication.update({
where: { id: med.id },
data: { createdAt: new Date(Date.now() - 40 * 86_400_000) },
});
const { GET } =
await import("@/app/api/medications/[id]/dose-history/route");
const body = await (
await GET(req("GET", `/api/medications/${med.id}/dose-history`), {
params: Promise.resolve({ id: med.id }),
})
).json();
const slots = (
body.data.rows as Array<{ kind: string; at: string; status: string }>
).filter((r) => r.kind === "slot");
const days = slots.map((r) => r.at.slice(0, 10)).sort();
const courseDays = [-20, -19, -18, -17, -16, -10, -9, -8, -7, -6].map(
shift,
);
expect(days).toEqual(courseDays.sort());
expect(
slots.filter((r) => r.status === "missed").map((r) => r.at.slice(0, 10)),
).toEqual([shift(-18)]);
});
it("lets the medication's own PUT edit the latest course", async () => {
const med = await makeMedication({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
});
await postCourse(med.id, { startsOn: "2026-06-10", endsOn: "2026-06-16" });
const { PUT } = await import("@/app/api/medications/[id]/route");
const res = await PUT(
req("PUT", `/api/medications/${med.id}`, { endsOn: "2026-06-18" }),
{ params: Promise.resolve({ id: med.id }) },
);
expect(res.status).toBe(200);
expect(await courses(med.id)).toEqual([
["2026-03-01", "2026-03-07"],
["2026-06-10", "2026-06-18"],
]);
// Clearing the window would drop the history: refused, nothing changes.
const cleared = await PUT(
req("PUT", `/api/medications/${med.id}`, {
startsOn: null,
endsOn: null,
}),
{ params: Promise.resolve({ id: med.id }) },
);
expect(cleared.status).toBe(422);
expect((await cleared.json()).meta.errorCode).toBe(
"medications.course.windowRequired",
);
expect(await window(med.id)).toEqual({
startsOn: "2026-06-10",
endsOn: "2026-06-18",
});
});
it("publishes the resolved course fields on the list and the detail", async () => {
const med = await makeMedication({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
});
await postCourse(med.id, { startsOn: "2026-04-01", endsOn: "2026-04-03" });
await getPrismaClient().medicationIntakeEvent.create({
data: {
userId: OWNER,
medicationId: med.id,
scheduledFor: new Date("2026-03-02T08:00:00.000Z"),
takenAt: new Date("2026-03-02T08:03:00.000Z"),
},
});
const { GET } = await import("@/app/api/medications/[id]/route");
const body = await (
await GET(req("GET", `/api/medications/${med.id}`), {
params: Promise.resolve({ id: med.id }),
})
).json();
expect(body.data).toMatchObject({
startsOn: expect.stringContaining("2026-04-01"),
courseCount: 2,
previousCourseEndedOn: "2026-04-03",
canStartCourse: true,
courseStatus: "ENDED",
});
expect(
body.data.courses.map(
(c: { startsOn: string; status: string; takenDoses: number }) => [
c.startsOn,
c.status,
c.takenDoses,
],
),
).toEqual([
["2026-03-01", "ENDED", 1],
["2026-04-01", "ENDED", 0],
]);
const { GET: list } = await import("@/app/api/medications/route");
const listBody = await (await list()).json();
expect(listBody.data[0]).toMatchObject({
courseCount: 2,
canStartCourse: true,
});
});
});