Merge branch 'feat/v139-targets-and-links' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:53:41 +02:00
commit e89f41710a
58 files changed
+2785 -214

No files matched your search

+88
View File
@@ -645,3 +645,91 @@ describe("link service — the vaccination pair", () => {
).rejects.toThrow(/No link table joins a vaccination to a labResult/);
});
});
describe("link service — a document filed against many doses (#1024)", () => {
async function seedDoses(userId: string, count: number) {
const prisma = getPrismaClient();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const dose = await prisma.vaccinationRecord.create({
data: {
userId,
occurredAt: new Date(Date.UTC(1991, i % 12, 1 + i)),
antigenSlug: i % 2 === 0 ? "dtap" : "polio",
},
});
ids.push(dose.id);
}
return ids;
}
it("files one page against a whole childhood schedule from the document side", async () => {
const prisma = getPrismaClient();
const doses = await seedDoses(OWNER, 12);
const result = await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: doses,
});
expect(result.changed).toBe(12);
// One table, read from either end: every dose now lists the page.
for (const dose of doses) {
const pages = await listTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: dose,
targetKind: "document",
});
expect(pages.map((page) => page.id)).toEqual([owner.document.id]);
}
// And the rows landed in the right columns.
const row = await prisma.vaccinationDocumentLink.findFirstOrThrow({
where: { vaccinationId: doses[0] },
});
expect(row.documentId).toBe(owner.document.id);
expect(row.userId).toBe(OWNER);
});
it("refuses another account's dose from the document side, and writes nothing", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: [stranger.vaccination.id],
});
expect(result.unknownTargetIds).toEqual([stranger.vaccination.id]);
expect(await prisma.vaccinationDocumentLink.count()).toBe(0);
});
it("unlinking from the document side leaves the other doses filed", async () => {
const prisma = getPrismaClient();
const doses = await seedDoses(OWNER, 3);
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: doses,
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: [doses[0]!, doses[2]!],
});
const linked = await listTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
});
expect(linked.map((dose) => dose.id).sort()).toEqual(
[doses[0]!, doses[2]!].sort(),
);
});
});
@@ -0,0 +1,177 @@
/**
* 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);
});
});