mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'feat/v139-targets-and-links' into release/v1.39.0
This commit is contained in:
commit
e89f41710a
58 files changed
+2785
-214
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user