Files
HealthLog/tests/integration/supplement-nutrients.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

721 lines
23 KiB
TypeScript

/**
* v1.43 — supplement composition to micronutrients, against a real Postgres.
*
* Drives the shipped routes and the real recompute:
* - the catalogue answers behind the nutrients module, with DHA and EPA,
* input units and upper limits;
* - the PUT stores amounts canonically (vitamin D in IU), refuses an
* unknown code and a unit the code does not offer, and 404s a foreign
* medication;
* - the contract's acceptance case: 25 µg vitamin D per capsule, two
* capsules per dose, one dose taken and one skipped → 50 µg that day, all
* of it SUPPLEMENT; a third capsule pair crosses the upper limit;
* - marking the taken dose as not taken, correcting it and deleting it each
* remove the day's SUPPLEMENT row; a changed composition recomputes the
* history; an automatically recorded dose counts;
* - the recompute is idempotent, writes nothing with the module off, and a
* deleted medication takes its rows with it;
* - the label read is refused without a provider, under the operator's
* switch and with the module off, and is queued as a
* `SUPPLEMENT_LABEL_EXTRACT` run when every layer is open.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { encrypt } from "@/lib/crypto";
import { userDayKey } from "@/lib/tz/format";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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);
},
})),
};
});
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
// The label read hands its run to a queue; this one accepts the job so the
// 202 and the stored run can be read back. Nothing here runs the job, so no
// model is called. `getGlobalBoss` is otherwise null in this suite, which is
// what makes the composition recompute run inline.
const queuedRuns: unknown[] = [];
let bossAvailable = false;
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () =>
bossAvailable
? {
send: vi.fn(async (_queue: string, payload: unknown) => {
queuedRuns.push(payload);
return "job-1";
}),
}
: null,
}));
const TZ = "Europe/Berlin";
let counter = 0;
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
interface Envelope<T = unknown> {
data: T;
error: string | null;
meta?: Record<string, unknown>;
}
async function makeUser(
options: { nutrients?: boolean; overrides?: Record<string, unknown> } = {},
): Promise<string> {
const suffix = `sup-${counter++}`;
const user = await getPrismaClient().user.create({
data: {
username: suffix,
email: `${suffix}@example.test`,
timezone: TZ,
locale: "en",
onboardingCompletedAt: new Date(),
modulePreferencesJson: { nutrients: options.nutrients ?? true },
...options.overrides,
},
});
const session = await getPrismaClient().session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user.id;
}
async function call<T = unknown>(
handler: Handler,
method: string,
path: string,
body?: unknown,
params: Record<string, string> = {},
): Promise<{ status: number; body: Envelope<T> }> {
const res = await handler(
new NextRequest(`http://localhost${path}`, {
method,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
}),
{ params: Promise.resolve(params) },
);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
/** A vitamin D supplement taken as needed, two capsules a dose. */
async function vitaminD(userId: string): Promise<string> {
const medication = await getPrismaClient().medication.create({
data: {
userId,
name: "Vitamin D3",
dose: "2 capsules",
unitsPerDose: 2,
asNeeded: true,
},
select: { id: true },
});
return medication.id;
}
async function putIngredients(
medicationId: string,
body: unknown,
): Promise<{ status: number; body: Envelope }> {
const { PUT } = await import("@/app/api/medications/[id]/ingredients/route");
return call(
PUT as Handler,
"PUT",
`/api/medications/${medicationId}/ingredients`,
body,
{ id: medicationId },
);
}
async function logDose(
medicationId: string,
at: Date,
skipped = false,
): Promise<string> {
const { POST } = await import("@/app/api/medications/[id]/intake/route");
const { status, body } = await call<{ id: string }>(
POST as Handler,
"POST",
`/api/medications/${medicationId}/intake`,
skipped
? { skipped: true, scheduledFor: at.toISOString() }
: { takenAt: at.toISOString() },
{ id: medicationId },
);
expect(status, JSON.stringify(body)).toBeLessThan(300);
return body.data.id;
}
interface DailyBody {
days: Array<{
day: string;
amount: number;
bySource: Record<string, number>;
aboveUpperLimit: boolean | null;
}>;
upperLimit: { value: number; scope: string } | null;
}
async function vitaminDOn(day: string) {
const { GET } = await import("@/app/api/nutrients/daily/route");
const { status, body } = await call<DailyBody>(
GET as Handler,
"GET",
"/api/nutrients/daily?nutrient=vitamin_d&days=7",
);
expect(status).toBe(200);
return {
entry: body.data.days.find((d) => d.day === day)!,
upperLimit: body.data.upperLimit,
};
}
async function supplementRows(userId: string) {
return getPrismaClient().nutrientIntakeDay.findMany({
where: { userId, source: "SUPPLEMENT" },
orderBy: [{ day: "asc" }, { nutrient: "asc" }],
select: { day: true, nutrient: true, amount: true, unit: true },
});
}
/** An instant this morning in Berlin, safely in the past. */
function earlierToday(hoursAgo = 1): Date {
return new Date(Date.now() - hoursAgo * 60 * 60 * 1000);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
queuedRuns.length = 0;
bossAvailable = false;
});
describe("GET /api/nutrients/catalog", () => {
it("answers with every code, DHA and EPA unsyncable, input units and upper limits", async () => {
await makeUser();
const { GET } = await import("@/app/api/nutrients/catalog/route");
const { status, body } = await call<{
entries: Array<{
code: string;
unit: string;
label: string;
syncable: boolean;
inputUnits: Array<{ unit: string; factorToCanonical: number }>;
upperLimit: { value: number; scope: string; source: string } | null;
}>;
}>(GET as Handler, "GET", "/api/nutrients/catalog");
expect(status).toBe(200);
const byCode = new Map(body.data.entries.map((e) => [e.code, e]));
expect(byCode.get("dha")).toMatchObject({
unit: "mg",
syncable: false,
label: "DHA (omega-3)",
upperLimit: null,
});
expect(byCode.get("epa")?.syncable).toBe(false);
expect(byCode.get("vitamin_d")?.inputUnits).toContainEqual({
unit: "IU",
factorToCanonical: 0.025,
});
expect(byCode.get("vitamin_d")?.upperLimit).toMatchObject({
value: 100,
scope: "total",
});
expect(byCode.get("magnesium")?.upperLimit?.scope).toBe("supplemental");
});
it("is behind the nutrients module", async () => {
await makeUser({ nutrients: false });
const { GET } = await import("@/app/api/nutrients/catalog/route");
const { status, body } = await call(
GET as Handler,
"GET",
"/api/nutrients/catalog",
);
expect(status).toBe(403);
expect(body.meta?.errorCode).toBe("module.disabled");
});
});
describe("PUT /api/medications/{id}/ingredients", () => {
it("stores vitamin D entered in IU canonically and keeps what was entered", async () => {
const userId = await makeUser();
const medicationId = await vitaminD(userId);
const { status, body } = await putIngredients(medicationId, {
ingredients: [
{ nutrient: "vitamin_d", amount: 1000, unit: "IU" },
{ nutrient: "magnesium", amount: 0.1, unit: "g" },
],
});
expect(status).toBe(200);
expect(body.data).toMatchObject({
ingredients: [
{
nutrient: "vitamin_d",
amountPerUnit: 25,
unit: "ug",
enteredAmount: 1000,
enteredUnit: "IU",
label: "Vitamin D",
},
{
nutrient: "magnesium",
amountPerUnit: 100,
unit: "mg",
enteredAmount: 0.1,
enteredUnit: "g",
},
],
recompute: { queued: false },
});
const stored = await getPrismaClient().medicationIngredient.findMany({
where: { medicationId },
orderBy: { nutrient: "asc" },
});
expect(
stored.map((s) => [s.nutrient, s.amountPerUnit, s.unit, s.source]),
).toEqual([
["magnesium", 100, "mg", "MANUAL"],
["vitamin_d", 25, "ug", "MANUAL"],
]);
// The detail read carries it, and the list says it has one.
const detail = await import("@/app/api/medications/[id]/route");
const read = await call<{ ingredients: Array<{ nutrient: string }> }>(
detail.GET as Handler,
"GET",
`/api/medications/${medicationId}`,
undefined,
{ id: medicationId },
);
expect(read.body.data.ingredients.map((i) => i.nutrient)).toEqual([
"vitamin_d",
"magnesium",
]);
});
it("refuses a unit the code does not offer, naming the row and the units", async () => {
const userId = await makeUser();
const medicationId = await vitaminD(userId);
const { status, body } = await putIngredients(medicationId, {
ingredients: [
{ nutrient: "vitamin_d", amount: 25, unit: "ug" },
{ nutrient: "vitamin_e", amount: 400, unit: "IU" },
],
});
expect(status).toBe(422);
expect(body.meta).toMatchObject({
errorCode: "medications.ingredients.unitMismatch",
index: 1,
allowedUnits: ["mg", "ug", "g"],
});
expect(
await getPrismaClient().medicationIngredient.count({
where: { medicationId },
}),
).toBe(0);
});
it("refuses an unknown code and a duplicate code", async () => {
const userId = await makeUser();
const medicationId = await vitaminD(userId);
const unknown = await putIngredients(medicationId, {
ingredients: [{ nutrient: "coenzyme_q10", amount: 30, unit: "mg" }],
});
expect(unknown.status).toBe(422);
expect(unknown.body.meta?.errorCode).toBe(
"medications.ingredients.invalid",
);
const duplicate = await putIngredients(medicationId, {
ingredients: [
{ nutrient: "zinc", amount: 10, unit: "mg" },
{ nutrient: "zinc", amount: 5, unit: "mg" },
],
});
expect(duplicate.status).toBe(422);
});
it("answers 404 for a medication of another account", async () => {
const other = await makeUser();
const foreign = await vitaminD(other);
await makeUser();
const { status, body } = await putIngredients(foreign, {
ingredients: [{ nutrient: "zinc", amount: 10, unit: "mg" }],
});
expect(status).toBe(404);
expect(body.meta?.errorCode).toBe("medications.notFound");
});
it("is behind the nutrients module", async () => {
const userId = await makeUser({ nutrients: false });
const medicationId = await vitaminD(userId);
const { status, body } = await putIngredients(medicationId, {
ingredients: [{ nutrient: "zinc", amount: 10, unit: "mg" }],
});
expect(status).toBe(403);
expect(body.meta).toMatchObject({
errorCode: "module.disabled",
module: "nutrients",
});
});
});
describe("taken doses become the day's SUPPLEMENT micronutrients", () => {
async function setUp() {
const userId = await makeUser();
const medicationId = await vitaminD(userId);
expect(
(
await putIngredients(medicationId, {
ingredients: [{ nutrient: "vitamin_d", amount: 25, unit: "ug" }],
})
).status,
).toBe(200);
return { userId, medicationId };
}
it("counts one taken dose of two capsules as 50 µg and the skipped one as nothing", async () => {
const { medicationId } = await setUp();
const takenAt = earlierToday(2);
const day = userDayKey(takenAt, TZ);
await logDose(medicationId, takenAt);
await logDose(medicationId, earlierToday(1), true);
const { entry, upperLimit } = await vitaminDOn(day);
expect(entry.amount).toBe(50);
expect(entry.bySource).toEqual({ SUPPLEMENT: 50 });
expect(entry.aboveUpperLimit).toBe(false);
expect(upperLimit).toMatchObject({ value: 100, scope: "total" });
});
it("notes a day above the upper limit once the doses pass it", async () => {
const { medicationId } = await setUp();
// 60 µg a capsule, two a dose: 120 µg against a total limit of 100.
await putIngredients(medicationId, {
ingredients: [{ nutrient: "vitamin_d", amount: 60, unit: "ug" }],
});
const takenAt = earlierToday(2);
await logDose(medicationId, takenAt);
const { entry } = await vitaminDOn(userDayKey(takenAt, TZ));
expect(entry.amount).toBe(120);
expect(entry.aboveUpperLimit).toBe(true);
});
it("removes the row when the taken dose is marked as not taken", async () => {
const { userId, medicationId } = await setUp();
const intakeId = await logDose(medicationId, earlierToday(2));
expect(await supplementRows(userId)).toHaveLength(1);
const { POST } = await import("@/app/api/medications/intake/route");
const undo = await call(
POST as Handler,
"POST",
"/api/medications/intake",
{ intakeId, status: "skipped" },
);
expect(undo.status).toBe(200);
expect(await supplementRows(userId)).toEqual([]);
});
it("follows a correction and a delete of the dose", async () => {
const { userId, medicationId } = await setUp();
const intakeId = await logDose(medicationId, earlierToday(2));
const route =
await import("@/app/api/medications/[id]/intake/[eventId]/route");
const corrected = await call(
route.PUT as Handler,
"PUT",
`/api/medications/${medicationId}/intake/${intakeId}`,
{ skipped: true },
{ id: medicationId, eventId: intakeId },
);
expect(corrected.status).toBe(200);
expect(await supplementRows(userId)).toEqual([]);
const second = await logDose(medicationId, earlierToday(1));
expect((await supplementRows(userId)).map((r) => r.amount)).toEqual([50]);
const deleted = await call(
route.DELETE as Handler,
"DELETE",
`/api/medications/${medicationId}/intake/${second}`,
undefined,
{ id: medicationId, eventId: second },
);
expect(deleted.status).toBe(200);
expect(await supplementRows(userId)).toEqual([]);
});
it("recomputes the history when the composition changes", async () => {
const { userId, medicationId } = await setUp();
// One dose through the route today, and one recorded two days ago
// straight into the table (an imported history the PUT has to re-read).
await logDose(medicationId, earlierToday(1));
const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000);
await getPrismaClient().medicationIntakeEvent.create({
data: {
userId,
medicationId,
scheduledFor: twoDaysAgo,
takenAt: twoDaysAgo,
},
});
const { refreshDoseSlot } =
await import("@/lib/medications/dose-day-refresh");
await refreshDoseSlot({
userId,
medicationId,
scheduledFor: twoDaysAgo,
tz: TZ,
});
expect((await supplementRows(userId)).map((r) => r.amount)).toEqual([
50, 50,
]);
await putIngredients(medicationId, {
ingredients: [
{ nutrient: "vitamin_d", amount: 50, unit: "ug" },
{ nutrient: "dha", amount: 250, unit: "mg" },
],
});
const rows = await supplementRows(userId);
expect(
rows.filter((r) => r.nutrient === "vitamin_d").map((r) => r.amount),
).toEqual([100, 100]);
expect(
rows.filter((r) => r.nutrient === "dha").map((r) => r.amount),
).toEqual([500, 500]);
// An emptied composition leaves nothing behind.
await putIngredients(medicationId, { ingredients: [] });
expect(await supplementRows(userId)).toEqual([]);
});
it("is idempotent: a second pass writes the same rows", async () => {
const { userId, medicationId } = await setUp();
await logDose(medicationId, earlierToday(2));
const { recomputeSupplementNutrientsForUser } =
await import("@/lib/medications/ingredients/recompute");
const before = await supplementRows(userId);
await recomputeSupplementNutrientsForUser(userId);
await recomputeSupplementNutrientsForUser(userId);
expect(await supplementRows(userId)).toEqual(before);
});
it("takes the rows away with a deleted medication", async () => {
const { userId, medicationId } = await setUp();
await logDose(medicationId, earlierToday(2));
const route = await import("@/app/api/medications/[id]/route");
const res = await call(
route.DELETE as Handler,
"DELETE",
`/api/medications/${medicationId}`,
undefined,
{ id: medicationId },
);
expect(res.status).toBe(200);
expect(await supplementRows(userId)).toEqual([]);
});
it("writes nothing while the nutrients module is off", async () => {
const { userId, medicationId } = await setUp();
await getPrismaClient().user.update({
where: { id: userId },
data: { modulePreferencesJson: { nutrients: false } },
});
await logDose(medicationId, earlierToday(2));
expect(await supplementRows(userId)).toEqual([]);
});
it("counts a dose recorded automatically", async () => {
const userId = await makeUser();
const prisma = getPrismaClient();
const medication = await prisma.medication.create({
data: {
userId,
name: "Omega-3",
dose: "1 capsule",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
autoMarkTaken: true,
autoMarkTakenSince: new Date("2026-06-01T00:00:00.000Z"),
schedules: {
create: {
windowStart: "19:00",
windowEnd: "19:00",
timesOfDay: ["19:00"],
rrule: "FREQ=DAILY",
scheduleType: "SCHEDULED",
},
},
},
select: { id: true },
});
await prisma.medicationIngredient.create({
data: {
userId,
medicationId: medication.id,
nutrient: "epa",
amountPerUnit: 180,
unit: "mg",
source: "MANUAL",
},
});
const { runIntakeAutoTakePass } =
await import("@/lib/jobs/intake-auto-take");
const result = await runIntakeAutoTakePass(prisma, {
nowMs: Date.parse("2026-06-03T17:30:00.000Z"),
});
expect(result.recordedCount).toBeGreaterThan(0);
const rows = await supplementRows(userId);
expect(rows.length).toBe(result.recordedCount);
expect(rows.every((r) => r.nutrient === "epa" && r.amount === 180)).toBe(
true,
);
});
});
describe("POST /api/medications/{id}/ingredients/extract", () => {
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
"base64",
);
async function extract(medicationId: string, bytes: Buffer = PNG) {
const { POST } =
await import("@/app/api/medications/[id]/ingredients/extract/route");
const form = new FormData();
form.append(
"file",
new File([new Uint8Array(bytes)], "label.png", { type: "image/png" }),
);
const res = await (POST as Handler)(
new NextRequest(
`http://localhost/api/medications/${medicationId}/ingredients/extract`,
{ method: "POST", body: form },
),
{ params: Promise.resolve({ id: medicationId }) },
);
return { status: res.status, body: (await res.json()) as Envelope };
}
/** A person on their own Anthropic key, with the document consent. */
async function readyUser(options: { nutrients?: boolean } = {}) {
const userId = await makeUser({
...options,
overrides: {
aiProvider: "ANTHROPIC",
aiModel: "claude-sonnet-4-6",
aiAnthropicKeyEncrypted: encrypt("sk-ant-integration-test"),
},
});
await getPrismaClient().consentReceipt.create({
data: {
userId,
kind: "ai_extraction",
artefact: "test",
signedAt: new Date(),
},
});
return userId;
}
it("queues a SUPPLEMENT_LABEL_EXTRACT run when every layer is open", async () => {
bossAvailable = true;
const userId = await readyUser();
const medicationId = await vitaminD(userId);
const { status, body } = await extract(medicationId);
expect(status, JSON.stringify(body)).toBe(202);
const runs = await getPrismaClient().documentAiRun.findMany({
where: { userId },
});
expect(runs.map((r) => r.kind)).toEqual(["SUPPLEMENT_LABEL_EXTRACT"]);
expect(queuedRuns).toHaveLength(1);
});
it("refuses without a provider and queues nothing", async () => {
bossAvailable = true;
const userId = await makeUser();
const medicationId = await vitaminD(userId);
const { status, body } = await extract(medicationId);
expect(status).toBe(422);
expect(body.meta).toMatchObject({
capability: "supplementLabel",
reason: "no_provider",
});
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
});
it("refuses under the operator's switch and queues nothing", async () => {
bossAvailable = true;
const userId = await readyUser();
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", assistantDocumentAiEnabled: false },
update: { assistantDocumentAiEnabled: false },
});
const medicationId = await vitaminD(userId);
const { status, body } = await extract(medicationId);
expect(status).toBe(403);
expect(body.meta).toMatchObject({
errorCode: "assistant.disabled.documentAi",
capability: "supplementLabel",
reason: "operator_disabled",
});
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
});
it("refuses with the nutrients module off", async () => {
const userId = await readyUser({ nutrients: false });
const medicationId = await vitaminD(userId);
const { status, body } = await extract(medicationId);
expect(status).toBe(403);
expect(body.meta?.errorCode).toBe("module.disabled");
});
it("refuses a file that is not an image or a PDF", async () => {
bossAvailable = true;
const userId = await readyUser();
const medicationId = await vitaminD(userId);
const { status, body } = await extract(
medicationId,
Buffer.from("plain text, not a label"),
);
expect(status).toBe(422);
expect(body.meta?.errorCode).toBe(
"medications.ingredients.extract.fileType",
);
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
});
});