Files
HealthLog/tests/integration/vaccination-custom.test.ts
T
Marc-André Bombeck f71becef29 fix(vaccinations): answer 409 to a concurrent duplicate vaccine name
Two adds of the same custom vaccine name both passed the name lookup,
and the loser failed on the unique index with a 500. A unique violation
on the add or the rename now answers the same 409 the lookup gives.
2026-10-08 22:37:57 +02:00

455 lines
15 KiB
TypeScript

/**
* v1.42 (#1005) — the record's own vaccine definitions, through the real
* routes and a real Postgres.
*
* Pinned here: a definition is created, listed, renamed and removed under the
* resolved record; a dose logged against it counts into every listed
* antigen's series and clears a booster keyed to one of them, the way a
* catalogue combination does; an id another record holds is refused; removing
* the definition leaves every dose standing with a name; the booster mint keys
* on its first antigen; and the sharing levels hold — a READ delegate lists
* but cannot add, a WRITE delegate adds onto the owner's record but cannot
* edit, and a Guardian inside a managed profile writes to the profile.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import {
GET as listCustom,
POST as createCustom,
} from "@/app/api/vaccinations/custom/route";
import {
DELETE as deleteCustom,
PATCH as patchCustom,
} from "@/app/api/vaccinations/custom/[id]/route";
import {
GET as listVaccinations,
POST as createVaccination,
} from "@/app/api/vaccinations/route";
import { POST as planBooster } from "@/app/api/vaccinations/[id]/booster/route";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, 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 DAY_MS = 24 * 60 * 60 * 1000;
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
const daysFromNow = (days: number) => new Date(Date.now() + days * DAY_MS);
let sequence = 0;
interface Person {
id: string;
sessionId: string;
}
async function person(label: string): Promise<Person> {
const suffix = sequence++;
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `${label}-${suffix}`,
email: `${label}-${suffix}@example.test`,
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: daysFromNow(1) },
});
return { id: user.id, sessionId: session.id };
}
function signIn(who: Person): void {
headerJar.delete("authorization");
cookieJar.set("healthlog_session", who.sessionId);
}
function json(method: string, url: string, body?: unknown): Request {
return new Request(`http://localhost${url}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
const params = (id: string) => ({ params: Promise.resolve({ id }) }) as never;
async function addCustom(body: unknown): Promise<Response> {
return createCustom(json("POST", "/api/vaccinations/custom", body) as never);
}
async function getCustoms(): Promise<Response> {
// The GET takes no request; the wrapper still hands one through.
return (listCustom as (request: Request) => Promise<Response>)(
json("GET", "/api/vaccinations/custom"),
);
}
async function editCustom(id: string, body: unknown): Promise<Response> {
return patchCustom(
json("PATCH", `/api/vaccinations/custom/${id}`, body) as never,
params(id),
);
}
async function removeCustom(id: string): Promise<Response> {
return deleteCustom(
json("DELETE", `/api/vaccinations/custom/${id}`) as never,
params(id),
);
}
async function postDose(body: unknown): Promise<Response> {
return createVaccination(json("POST", "/api/vaccinations", body) as never);
}
interface DoseDto {
id: string;
customVaccineId: string | null;
customVaccine: { name: string } | null;
series: { antigen: string; position: number; total: number | null }[];
reminderId: string | null;
}
async function doses(): Promise<DoseDto[]> {
const res = await listVaccinations(json("GET", "/api/vaccinations") as never);
expect(res.status).toBe(200);
return ((await res.json()) as { data: { vaccinations: DoseDto[] } }).data
.vaccinations;
}
const TRAVEL = {
name: "Travel combo",
components: ["typhoid", "hepatitis-a"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 36,
};
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("an owner's own vaccine definitions", () => {
it("creates, lists and refuses a second definition of the same name", async () => {
const owner = await person("owner");
signIn(owner);
const created = await addCustom(TRAVEL);
expect(created.status).toBe(201);
const def = ((await created.json()) as { data: { id: string } }).data;
const listed = await getCustoms();
expect(listed.status).toBe(200);
expect(
((await listed.json()) as { data: { id: string; name: string }[] }).data,
).toEqual([expect.objectContaining({ id: def.id, name: "Travel combo" })]);
// The same product, typed differently, is one product to the reader.
const clash = await addCustom({ ...TRAVEL, name: "travel COMBO" });
expect(clash.status).toBe(409);
expect((await clash.json()).meta?.errorCode).toBe(
"vaccination.custom.name-taken",
);
// An antigen outside the closed list is refused.
const bad = await addCustom({ name: "Other", components: ["unicorn-pox"] });
expect(bad.status).toBe(422);
});
it("answers 409, not 500, to the loser of two concurrent adds of one name", async () => {
const owner = await person("owner");
signIn(owner);
const prisma = getPrismaClient();
// Hold every insert long enough that both requests have passed the name
// lookup before either commits, so the loser meets the unique index.
await prisma.$executeRawUnsafe(`
CREATE OR REPLACE FUNCTION slow_custom_vaccine_insert() RETURNS trigger AS $$
BEGIN PERFORM pg_sleep(0.3); RETURN NEW; END; $$ LANGUAGE plpgsql`);
await prisma.$executeRawUnsafe(`
CREATE TRIGGER slow_custom_vaccine_insert BEFORE INSERT ON "custom_vaccines"
FOR EACH ROW EXECUTE FUNCTION slow_custom_vaccine_insert()`);
try {
const statuses = (
await Promise.all([addCustom(TRAVEL), addCustom(TRAVEL)])
).map((r) => r.status);
expect(statuses.sort()).toEqual([201, 409]);
expect(
await prisma.customVaccine.count({ where: { userId: owner.id } }),
).toBe(1);
} finally {
await prisma.$executeRawUnsafe(
'DROP TRIGGER IF EXISTS slow_custom_vaccine_insert ON "custom_vaccines"',
);
await prisma.$executeRawUnsafe(
"DROP FUNCTION IF EXISTS slow_custom_vaccine_insert()",
);
}
});
it("counts a dose into every listed antigen and clears a booster keyed to one", async () => {
const owner = await person("owner");
signIn(owner);
const prisma = getPrismaClient();
const def = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
// A hepatitis A booster already planned and due.
const reminder = await prisma.measurementReminder.create({
data: {
userId: owner.id,
label: "hepatitis A booster",
intervalDays: 3650,
notifyHour: 9,
vaccinationAntigen: "hepatitis-a",
nextDueAt: daysAgo(1),
},
});
const res = await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: def.id,
});
expect(res.status, JSON.stringify(await res.clone().json())).toBe(201);
const dto = ((await res.json()) as { data: DoseDto }).data;
expect(dto.customVaccine?.name).toBe("Travel combo");
expect(dto.series).toEqual([
expect.objectContaining({ antigen: "typhoid", position: 1, total: 2 }),
expect.objectContaining({
antigen: "hepatitis-a",
position: 1,
total: 2,
}),
]);
expect(dto.reminderId).toBe(reminder.id);
expect(
(
await prisma.measurementReminder.findUniqueOrThrow({
where: { id: reminder.id },
})
).lastSatisfiedAt,
).not.toBeNull();
// An edit to the definition is read by every dose that names it.
expect((await editCustom(def.id, { components: ["typhoid"] })).status).toBe(
200,
);
expect((await doses())[0].series.map((p) => p.antigen)).toEqual([
"typhoid",
]);
});
it("keys the booster mint on the definition's first antigen", async () => {
const owner = await person("owner");
signIn(owner);
const def = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
const dose = (
(await (
await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: def.id,
})
).json()) as { data: { id: string } }
).data;
const res = await planBooster(
json("POST", `/api/vaccinations/${dose.id}/booster`, {
intervalMonths: 36,
label: "Travel combo booster",
}) as never,
params(dose.id),
);
expect(res.status).toBe(201);
const minted = await getPrismaClient().measurementReminder.findFirstOrThrow(
{ where: { userId: owner.id } },
);
expect(minted.vaccinationAntigen).toBe("typhoid");
});
it("refuses a definition another record holds", async () => {
const stranger = await person("stranger");
signIn(stranger);
const foreign = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
const owner = await person("owner");
signIn(owner);
const res = await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: foreign.id,
});
expect(res.status).toBe(404);
expect((await res.json()).meta?.errorCode).toBe(
"vaccination.custom-vaccine-not-found",
);
expect((await editCustom(foreign.id, { name: "Mine" })).status).toBe(404);
expect((await removeCustom(foreign.id)).status).toBe(404);
expect(
await getPrismaClient().vaccinationRecord.count({
where: { userId: owner.id },
}),
).toBe(0);
});
it("removing a definition keeps every dose, each with a name, and the name can be used again", async () => {
const owner = await person("owner");
signIn(owner);
const prisma = getPrismaClient();
const def = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
await postDose({
occurredAt: daysAgo(30).toISOString(),
customVaccineId: def.id,
});
await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: def.id,
vaccineName: "as the pass says",
});
expect((await removeCustom(def.id)).status).toBe(200);
// Removing twice succeeds.
expect((await removeCustom(def.id)).status).toBe(200);
const rows = await prisma.vaccinationRecord.findMany({
where: { userId: owner.id, deletedAt: null },
orderBy: { occurredAt: "asc" },
});
expect(rows.map((row) => [row.customVaccineId, row.vaccineName])).toEqual([
[null, "Travel combo"],
[null, "as the pass says"],
]);
expect(
((await (await getCustoms()).json()) as { data: unknown[] }).data,
).toEqual([]);
const audit = await prisma.auditLog.findFirstOrThrow({
where: { action: "vaccination.custom.delete" },
orderBy: { createdAt: "asc" },
});
const details =
typeof audit.details === "string"
? JSON.parse(audit.details)
: audit.details;
expect(details).toMatchObject({ dosesUnlinked: 2 });
// The same name again brings the definition back, without its old doses.
const again = await addCustom({ ...TRAVEL, boosterIntervalMonths: 12 });
expect(again.status).toBe(201);
expect(((await again.json()) as { data: { id: string } }).data.id).toBe(
def.id,
);
expect(
await prisma.vaccinationRecord.count({
where: { userId: owner.id, customVaccineId: def.id },
}),
).toBe(0);
});
});
describe("sharing and managed profiles", () => {
async function switchInAs(
ownerId: string,
access: "READ" | "WRITE",
): Promise<Person> {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const delegate = await person("delegate");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegate.id,
access,
scope: ["profile"],
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
signIn(delegate);
await switchSessionTo(delegate.sessionId, ownerId);
return delegate;
}
it("lets a READ delegate list the definitions but not add one", async () => {
const owner = await person("owner");
await getPrismaClient().customVaccine.create({
data: { userId: owner.id, name: "Owner's own", components: ["rabies"] },
});
await switchInAs(owner.id, "READ");
const listed = await getCustoms();
expect(listed.status).toBe(200);
expect(
((await listed.json()) as { data: { name: string }[] }).data.map(
(row) => row.name,
),
).toEqual(["Owner's own"]);
const res = await addCustom(TRAVEL);
expect(res.status).toBe(403);
expect((await res.json()).meta?.errorCode).toBe("sharing.access.denied");
});
it("lets a WRITE delegate add onto the owner's record but not edit or remove", async () => {
const owner = await person("owner");
const delegate = await switchInAs(owner.id, "WRITE");
const res = await addCustom(TRAVEL);
expect(res.status).toBe(201);
const def = ((await res.json()) as { data: { id: string } }).data;
const prisma = getPrismaClient();
expect(
(await prisma.customVaccine.findUniqueOrThrow({ where: { id: def.id } }))
.userId,
).toBe(owner.id);
expect(
await prisma.customVaccine.count({ where: { userId: delegate.id } }),
).toBe(0);
expect((await editCustom(def.id, { name: "Renamed" })).status).toBe(403);
expect((await removeCustom(def.id)).status).toBe(403);
});
it("writes a Guardian's definition to the managed profile", async () => {
const { createManagedProfile } =
await import("@/lib/managed-profiles/create");
const guardian = await person("guardian");
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Child record",
dateOfBirth: null,
locale: "en",
timezone: "Europe/Berlin",
});
signIn(guardian);
await switchSessionTo(guardian.sessionId, profile.id);
expect((await addCustom(TRAVEL)).status).toBe(201);
const prisma = getPrismaClient();
expect(
await prisma.customVaccine.count({ where: { userId: profile.id } }),
).toBe(1);
expect(
await prisma.customVaccine.count({ where: { userId: guardian.id } }),
).toBe(0);
});
});