Files
HealthLog/tests/integration/vaccination-renewal.test.ts
T
Marc-André Bombeck 15d6251ad2 feat(vaccinations): show the planned booster's renewal state on the log
Whether a vaccination is still current was known only on the checkups
page. The vaccination log now shows it on each antigen's heading:
the booster the person planned when they logged a dose, marked as
overdue, due soon (within 30 days) or scheduled, tinted only in the
first two cases.

The state is resolved on the server from the person's own confirmed
booster reminder and published on the list response as renewals, so
the web page and the native client read the same answer. Nothing is
worked out from age, history or the catalogue: an antigen without a
planned booster shows no state. Renewals are null for a grant that does
not reach the measurements section the reminders live in.

Refs #1005
2026-09-24 09:44:51 +02:00

178 lines
5.6 KiB
TypeScript

/**
* The vaccination list carries each antigen's renewal state, read from the
* booster reminder the person confirmed, and only to a caller whose grant
* covers the measurements section that reminder lives in.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { NextRequest } from "next/server";
import type { ShareDomain } from "@/lib/sharing/scope";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables, switchSessionTo } 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 = "renewal-owner";
const DELEGATE_ID = "renewal-delegate";
/** A catalogue antigen that carries a booster interval. */
const ANTIGEN = "tetanus";
let doseId: string;
async function seedRecords() {
const prisma = getPrismaClient();
for (const [id, name] of [
[OWNER_ID, "owner"],
[DELEGATE_ID, "delegate"],
] as const) {
await prisma.user.create({
data: {
id,
username: `renewal-${name}`,
email: `renewal-${name}@example.test`,
timezone: "UTC",
},
});
}
const dose = await prisma.vaccinationRecord.create({
data: {
userId: OWNER_ID,
occurredAt: new Date("2026-03-01T00:00:00.000Z"),
antigenSlug: ANTIGEN,
},
});
doseId = dose.id;
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function switchInto(
access: "WRITE" | "MANAGE",
scope: ShareDomain[] | null,
) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access,
scope,
});
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
const session = await signIn(DELEGATE_ID);
await switchSessionTo(session.id, OWNER_ID);
}
async function planBooster(): Promise<Response> {
const { POST } = await import("@/app/api/vaccinations/[id]/booster/route");
return POST(
new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }),
}),
{ params: Promise.resolve({ id: doseId }) },
);
}
async function listVaccinations(): Promise<{
renewals: Array<{ antigen: string; state: string; daysUntil: number }> | null;
}> {
const { GET } = await import("@/app/api/vaccinations/route");
const res = await GET(new NextRequest("http://localhost/api/vaccinations"));
expect(res.status).toBe(200);
return (await res.json()).data;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedRecords();
});
describe("the vaccination list shows each antigen's renewal state (#1005)", () => {
it("reads the booster the owner confirmed as current", async () => {
await signIn(OWNER_ID);
expect((await planBooster()).status).toBe(201);
const { renewals } = await listVaccinations();
expect(renewals).toHaveLength(1);
expect(renewals![0]).toMatchObject({ antigen: ANTIGEN, state: "current" });
});
it("reads a booster whose date has passed as overdue", async () => {
await signIn(OWNER_ID);
expect((await planBooster()).status).toBe(201);
await getPrismaClient().measurementReminder.updateMany({
where: { userId: OWNER_ID },
data: { nextDueAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) },
});
const { renewals } = await listVaccinations();
expect(renewals![0]).toMatchObject({ antigen: ANTIGEN, state: "overdue" });
expect(renewals![0]!.daysUntil).toBeLessThan(0);
});
it("claims nothing for an antigen without a confirmed booster", async () => {
await signIn(OWNER_ID);
const { renewals } = await listVaccinations();
expect(renewals).toEqual([]);
});
it("never shows another account's booster", async () => {
await signIn(OWNER_ID);
expect((await planBooster()).status).toBe(201);
cookieJar.clear();
await signIn(DELEGATE_ID);
const { renewals } = await listVaccinations();
expect(renewals).toEqual([]);
});
it("withholds renewals from a grant that opened only the health background", async () => {
await signIn(OWNER_ID);
expect((await planBooster()).status).toBe(201);
cookieJar.clear();
await switchInto("WRITE", ["profile"]);
const { renewals } = await listVaccinations();
expect(renewals).toBeNull();
});
it("shows them to a grant that also covers measurements", async () => {
await signIn(OWNER_ID);
expect((await planBooster()).status).toBe(201);
cookieJar.clear();
await switchInto("WRITE", ["profile", "measurements"]);
const { renewals } = await listVaccinations();
expect(renewals).toHaveLength(1);
});
});