mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
483 lines
16 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|