Files
HealthLog/tests/integration/body-sites.test.ts
T
Marc-André Bombeck 14f0fc4d4f feat(body-sites): record a body site on conditions and add a body-site view
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.

- migration 0354: two nullable columns on illness_episodes, reusing the
  laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
  keeps the stored value, so an edit from the shipped iPhone app, which
  never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
  conditions, folded after the decrypt and bounded per kind; with site
  (and laterality) the visits and conditions there, one hop to their
  documents, lab results and visits. Read in the profile section;
  conditions only under an illness grant with the module on, and links
  into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
  end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
  one body-site field pair that suggests sites already used; a
  condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
  name the site on conditions; the Coach illness block carries it
  sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
  registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 21:55:14 +02:00

491 lines
16 KiB
TypeScript

/**
* Body sites on conditions and the body-site view, against real Postgres and
* through the real routes (v1.39.2).
*
* What only a real database can answer:
*
* - the two new columns exist (migration 0354) and the site is ciphertext on
* disk, not the words the person typed;
* - an edit from the shipped iPhone app, whose body never names the site,
* leaves the stored site and side exactly as they were;
* - the view groups sites across procedures and conditions and walks one hop
* to what is linked to each, through the link service's real tables;
* - a delegate learns nothing a grant does not cover: without the illness
* section there is no condition, no condition-only site and no condition
* name; a link into an ungranted section is a placeholder with no label.
*
* Mutation checks (each run, each seen red):
* - pass `conditionsReadable: true` unconditionally in the route → both
* "profile-only delegate" cases go red (the condition-only site appears);
* - pass `() => true` as the condition document redaction predicate →
* "profile + illness delegate" goes red (the letter's title leaks);
* - write `bodySiteEncrypted: null` unconditionally in the PATCH → "an edit
* from the shipped iPhone app" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import type { ShareDomain } from "@/lib/sharing/scope";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import type { BodySiteListDTO } from "@/lib/body-sites/dto";
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 = "body-site-owner";
const DELEGATE_ID = "body-site-delegate";
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: daysFromNow(1), mfaVerifiedAt: new Date() },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function switchInto(scope: ShareDomain[] | null) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access: "READ",
scope,
});
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
const session = await signIn(DELEGATE_ID);
await switchSessionTo(session.id, OWNER_ID);
}
async function seedRecord() {
const prisma = getPrismaClient();
for (const [id, name] of [
[OWNER_ID, "owner"],
[DELEGATE_ID, "delegate"],
] as const) {
await prisma.user.create({
data: {
id,
username: `body-site-${name}`,
email: `body-site-${name}@example.test`,
timezone: "Europe/Berlin",
locale: "en",
},
});
}
const mri = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "OTHER",
title: "MRI knee report",
mimeType: "application/pdf",
byteSize: 10,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: daysFromNow(-40),
},
});
const physio = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "OTHER",
title: "Physio letter",
mimeType: "application/pdf",
byteSize: 10,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: daysFromNow(-20),
},
});
const crp = await prisma.labResult.create({
data: {
userId: OWNER_ID,
analyte: "CRP",
value: 4,
unit: "mg/L",
takenAt: daysFromNow(-30),
},
});
const tear = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Meniscus tear",
type: "INJURY",
onsetAt: daysFromNow(-60),
bodySiteEncrypted: encryptToBytes("knee"),
laterality: "LEFT",
},
});
const lumbago = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Lumbago",
type: "CHRONIC",
onsetAt: daysFromNow(-400),
bodySiteEncrypted: encryptToBytes("Lower back"),
},
});
const arthroscopy = await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-30),
status: "DONE",
kind: "PROCEDURE",
reasonEncrypted: encryptToBytes("Arthroscopy"),
bodySiteEncrypted: encryptToBytes("Knee"),
laterality: "LEFT",
},
});
await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-500),
status: "DONE",
kind: "PROCEDURE",
reasonEncrypted: encryptToBytes("Rotator cuff repair"),
bodySiteEncrypted: encryptToBytes("Shoulder"),
laterality: "RIGHT",
},
});
// A visit with no site never appears in the view.
await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: daysFromNow(-5),
status: "DONE",
kind: "ROUTINE",
},
});
await prisma.encounterDocumentLink.create({
data: { userId: OWNER_ID, encounterId: arthroscopy.id, documentId: mri.id },
});
await prisma.encounterLabLink.create({
data: {
userId: OWNER_ID,
encounterId: arthroscopy.id,
labResultId: crp.id,
},
});
await prisma.encounterConditionLink.create({
data: { userId: OWNER_ID, encounterId: arthroscopy.id, episodeId: tear.id },
});
await prisma.documentConditionLink.create({
data: { userId: OWNER_ID, documentId: physio.id, episodeId: tear.id },
});
return { mri, physio, crp, tear, lumbago, arthroscopy };
}
async function bodySites(
query = "",
): Promise<{ status: number; body: { data: BodySiteListDTO } }> {
const { GET } = await import("@/app/api/body-sites/route");
const res = await GET(
new Request(
`http://localhost/api/body-sites${query ? `?${query}` : ""}`,
) as never,
);
return { status: res.status, body: await res.json() };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("GET /api/body-sites — the owner", () => {
it("groups sites across procedures and conditions", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { status, body } = await bodySites();
expect(status).toBe(200);
expect(body.data.selection).toBeUndefined();
expect(body.data.sites).toEqual([
{
bodySite: "Knee",
procedures: 1,
conditions: 1,
sides: [{ laterality: "LEFT", count: 2 }],
},
{
bodySite: "Shoulder",
procedures: 1,
conditions: 0,
sides: [{ laterality: "RIGHT", count: 1 }],
},
{
bodySite: "Lower back",
procedures: 0,
conditions: 1,
sides: [{ laterality: null, count: 1 }],
},
]);
});
it("walks from a site to its procedures, conditions and their links", async () => {
const seeded = await seedRecord();
await signIn(OWNER_ID);
const { status, body } = await bodySites("site=KNEE&laterality=LEFT");
expect(status).toBe(200);
const selection = body.data.selection!;
expect(selection.bodySite).toBe("Knee");
expect(selection.laterality).toBe("LEFT");
expect(selection.visits.map((v) => v.id)).toEqual([seeded.arthroscopy.id]);
const links = selection.visits[0].links!;
expect(links.documents).toEqual([
expect.objectContaining({ label: "MRI knee report", redacted: false }),
]);
expect(links.labResults).toEqual([
expect.objectContaining({ label: "CRP", redacted: false }),
]);
expect(links.conditions).toEqual([
expect.objectContaining({ label: "Meniscus tear", redacted: false }),
]);
expect(selection.conditions).toHaveLength(1);
const condition = selection.conditions![0];
expect(condition).toMatchObject({
id: seeded.tear.id,
label: "Meniscus tear",
bodySite: "knee",
laterality: "LEFT",
});
expect(condition.links.documents).toEqual([
expect.objectContaining({ label: "Physio letter", redacted: false }),
]);
expect(condition.links.visits).toEqual([
expect.objectContaining({
id: seeded.arthroscopy.id,
label: "PROCEDURE",
redacted: false,
}),
]);
});
it("a picked side leaves out the other side", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { body } = await bodySites("site=Knee&laterality=RIGHT");
expect(body.data.selection!.visits).toEqual([]);
expect(body.data.selection!.conditions).toEqual([]);
});
it("with the illness module off, conditions are not read", async () => {
await seedRecord();
await getPrismaClient().user.update({
where: { id: OWNER_ID },
data: { modulePreferencesJson: { illness: false } },
});
await signIn(OWNER_ID);
const { body } = await bodySites("site=Knee");
expect(body.data.sites.map((s) => s.bodySite)).toEqual([
"Knee",
"Shoulder",
]);
expect(body.data.selection!.conditions).toBeNull();
expect(body.data.selection!.visits).toHaveLength(1);
});
it("refuses a side without a site", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { status } = await bodySites("laterality=LEFT");
expect(status).toBe(422);
});
});
describe("GET /api/body-sites — delegates", () => {
it("profile-only delegate: no condition, no condition site, placeholders for links", async () => {
await seedRecord();
await switchInto(["profile"]);
const list = await bodySites();
expect(list.status).toBe(200);
// The condition-only site is not among the choices, and the knee counts
// only the procedure.
expect(list.body.data.sites).toEqual([
expect.objectContaining({
bodySite: "Knee",
procedures: 1,
conditions: 0,
}),
expect.objectContaining({ bodySite: "Shoulder" }),
]);
const { status, body } = await bodySites("site=Knee&laterality=LEFT");
expect(status).toBe(200);
const selection = body.data.selection!;
expect(selection.conditions).toBeNull();
const links = selection.visits[0].links!;
for (const family of [
links.documents,
links.labResults,
links.conditions,
]) {
expect(family).toHaveLength(1);
expect(family[0]).toMatchObject({
label: null,
date: null,
redacted: true,
});
}
// Nothing a grant did not cover appears anywhere in the answer.
const text = JSON.stringify(body);
for (const secret of [
"Meniscus",
"Lumbago",
"Lower back",
"MRI knee report",
"Physio letter",
"CRP",
]) {
expect(text).not.toContain(secret);
}
});
it("profile + illness delegate: conditions, with their documents as placeholders", async () => {
await seedRecord();
await switchInto(["profile", "illness"]);
const { status, body } = await bodySites("site=Knee");
expect(status).toBe(200);
const selection = body.data.selection!;
expect(selection.conditions).toHaveLength(1);
expect(selection.conditions![0].label).toBe("Meniscus tear");
expect(selection.conditions![0].links.documents).toEqual([
expect.objectContaining({ label: null, date: null, redacted: true }),
]);
// The visit's condition link is readable now; its document still is not.
const links = selection.visits[0].links!;
expect(links.conditions[0]).toMatchObject({
label: "Meniscus tear",
redacted: false,
});
expect(links.documents[0]).toMatchObject({ label: null, redacted: true });
const text = JSON.stringify(body);
expect(text).not.toContain("Physio letter");
expect(text).not.toContain("MRI knee report");
});
it("a grant without the visits' section is refused", async () => {
await seedRecord();
await switchInto(["illness"]);
const { status } = await bodySites();
expect(status).toBe(403);
});
});
describe("condition body site on the episode routes", () => {
async function patchEpisode(id: string, body: unknown) {
const { PATCH } = await import("@/app/api/illness/episodes/[id]/route");
return PATCH(
new Request(`http://localhost/api/illness/episodes/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
{ params: Promise.resolve({ id }) } as never,
);
}
it("stores the site as ciphertext", async () => {
await seedRecord();
await signIn(OWNER_ID);
const { POST } = await import("@/app/api/illness/episodes/route");
const res = await POST(
new Request("http://localhost/api/illness/episodes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
label: "Tennis elbow",
type: "INJURY",
bodySite: "Elbow",
laterality: "RIGHT",
}),
}) as never,
);
expect(res.status).toBe(201);
const created = (await res.json()).data as { id: string };
const row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: created.id },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(Buffer.from(row.bodySiteEncrypted!).toString("utf8")).not.toContain(
"Elbow",
);
expect(decryptFromBytes(row.bodySiteEncrypted!)).toBe("Elbow");
expect(row.laterality).toBe("RIGHT");
});
it("an edit from the shipped iPhone app leaves the site and side alone", async () => {
const { tear } = await seedRecord();
await signIn(OWNER_ID);
// What `IllnessEpisodePatch` encodes for "mark recovered with a note":
// optional scalars only when set, the three full-value keys always.
const res = await patchEpisode(tear.id, {
resolvedAt: daysFromNow(-1).toISOString(),
parentConditionId: null,
note: "Back to running",
});
expect(res.status).toBe(200);
const body = (await res.json()).data as {
bodySite: string | null;
laterality: string | null;
};
expect(body.bodySite).toBe("knee");
expect(body.laterality).toBe("LEFT");
const row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: tear.id },
select: { bodySiteEncrypted: true, laterality: true, resolvedAt: true },
});
expect(decryptFromBytes(row.bodySiteEncrypted!)).toBe("knee");
expect(row.laterality).toBe("LEFT");
expect(row.resolvedAt).not.toBeNull();
});
it("the web client can change and clear it", async () => {
const { tear } = await seedRecord();
await signIn(OWNER_ID);
await patchEpisode(tear.id, { bodySite: "Left knee", laterality: null });
let row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: tear.id },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(decryptFromBytes(row.bodySiteEncrypted!)).toBe("Left knee");
expect(row.laterality).toBeNull();
await patchEpisode(tear.id, { bodySite: null });
row = await getPrismaClient().illnessEpisode.findUniqueOrThrow({
where: { id: tear.id },
select: { bodySiteEncrypted: true, laterality: true },
});
expect(row.bodySiteEncrypted).toBeNull();
});
});