Files
HealthLog/tests/integration/vaccination-booster-mint.test.ts
T
Marc-André Bombeck b5ce44628a feat(vaccinations): mint the booster reminder a logged dose suggests
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an
  ordinary VORSORGE reminder keyed on the dose's primary antigen; the
  published origin enum and reminder DTO are unchanged
- idempotent by antigen — a second confirmation re-anchors the existing
  reminder rather than minting a second (WR-9 seen red on the reminder count,
  reverted by content hash)
- rolling day cadence so logging the next dose re-anchors the due date through
  the Phase 1 satisfy matcher; the whole loop proven through real routes
- booster prompt copy in six locales, plural-aware
2026-08-08 21:01:25 +02:00

234 lines
7.6 KiB
TypeScript

/**
* The booster mint — rung 2 — through the real routes, real Postgres, real
* reminder engine.
*
* Every property here is about ROWS: how many reminders exist after a second
* confirmation, whether the minted row lists as an ordinary Vorsorge reminder,
* and whether logging the next dose moves the due date the mint set. None can
* be answered from a fixture.
*
* The idempotence case is why the file exists: one antigen holds at most one
* minted booster reminder, so a second confirmation re-anchors rather than
* mints a second (WR-9). Fixture dates are relative — a test about "moves
* forward" rots against a fixed calendar date.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { POST as createVaccination } from "@/app/api/vaccinations/route";
import { POST as mintBooster } from "@/app/api/vaccinations/[id]/booster/route";
import { GET as listReminders } from "@/app/api/measurement-reminders/route";
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/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const OWNER_ID = "mint-owner";
const DAY_MS = 24 * 60 * 60 * 1000;
function daysAgo(days: number): Date {
return new Date(Date.now() - days * DAY_MS);
}
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * DAY_MS);
}
async function seedSession() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: OWNER_ID,
username: "mint-owner",
email: "mint-owner@example.test",
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: {
userId: OWNER_ID,
expiresAt: daysFromNow(7),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
async function createDose(body: unknown): Promise<{ id: string }> {
const res = await createVaccination(
new Request("http://localhost/api/vaccinations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
);
expect(res.status, JSON.stringify(await res.clone().json())).toBe(201);
return ((await res.json()) as { data: { id: string } }).data;
}
function mint(vaccinationId: string, body: unknown): Promise<Response> {
return mintBooster(
new Request(`http://localhost/api/vaccinations/${vaccinationId}/booster`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
{ params: Promise.resolve({ id: vaccinationId }) },
);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedSession();
});
describe("the booster mint writes an ordinary Vorsorge reminder", () => {
it("mints one VORSORGE reminder keyed on the dose's primary antigen", async () => {
const dose = await createDose({
occurredAt: daysAgo(2).toISOString(),
antigenSlug: "tetanus",
});
const res = await mint(dose.id, {
intervalMonths: 120,
label: "Tetanus booster",
});
expect(res.status, JSON.stringify(await res.clone().json())).toBe(201);
const prisma = getPrismaClient();
const reminders = await prisma.measurementReminder.findMany({
where: { userId: OWNER_ID, deletedAt: null },
});
expect(reminders).toHaveLength(1);
expect(reminders[0]!.origin).toBe("VORSORGE");
expect(reminders[0]!.vaccinationAntigen).toBe("tetanus");
expect(reminders[0]!.nextDueAt!.getTime()).toBeGreaterThan(Date.now());
const stamped = await prisma.vaccinationRecord.findUniqueOrThrow({
where: { id: dose.id },
});
expect(stamped.reminderId).toBe(reminders[0]!.id);
});
it("lists as an ordinary Vorsorge row with no new DTO field", async () => {
const dose = await createDose({
occurredAt: daysAgo(2).toISOString(),
antigenSlug: "tetanus",
});
await mint(dose.id, { intervalMonths: 120, label: "Tetanus booster" });
const res = await listReminders();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<Record<string, unknown> & { origin: string }>;
};
const minted = body.data.find((r) => r.origin === "VORSORGE");
expect(minted).toBeDefined();
expect(minted).not.toHaveProperty("vaccinationAntigen");
});
});
describe("one antigen, at most one minted reminder (WR-9)", () => {
it("re-anchors on a second confirmation rather than minting a second", async () => {
const dose = await createDose({
occurredAt: daysAgo(2).toISOString(),
antigenSlug: "tetanus",
});
const first = await mint(dose.id, {
intervalMonths: 120,
label: "Tetanus booster",
});
expect(first.status).toBe(201);
const second = await mint(dose.id, {
intervalMonths: 60,
label: "Tetanus booster (sooner)",
});
// The count is the property WR-9 breaks: a broken idempotence branch mints
// a second row, and this is where it must go red.
const reminders = await getPrismaClient().measurementReminder.findMany({
where: {
userId: OWNER_ID,
deletedAt: null,
vaccinationAntigen: "tetanus",
},
});
expect(
reminders,
"one antigen holds at most one minted booster reminder",
).toHaveLength(1);
// A re-anchor answers 200 and carries the second confirmation's edits.
expect(second.status).toBe(200);
expect(reminders[0]!.label).toBe("Tetanus booster (sooner)");
});
});
describe("the whole activity-aware loop, through real routes", () => {
it("mint → log the next dose → the matcher re-anchors the minted reminder", async () => {
const first = await createDose({
occurredAt: daysAgo(730).toISOString(),
antigenSlug: "tetanus",
});
const mintRes = await mint(first.id, {
intervalMonths: 120,
label: "Tetanus booster",
});
expect(mintRes.status).toBe(201);
const reminderId = (
(await mintRes.json()) as { data: { reminder: { id: string } } }
).data.reminder.id;
const prisma = getPrismaClient();
const before = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: reminderId },
});
expect(before.lastSatisfiedAt).toBeNull();
const occurredAt = daysAgo(1);
const second = await createVaccination(
new Request("http://localhost/api/vaccinations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
occurredAt: occurredAt.toISOString(),
antigenSlug: "tetanus",
}),
}) as never,
);
expect(second.status).toBe(201);
const after = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: reminderId },
});
expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString());
expect(after.nextDueAt!.getTime()).toBeGreaterThan(
before.nextDueAt!.getTime(),
);
});
});