diff --git a/tests/integration/mcp-record-links.test.ts b/tests/integration/mcp-record-links.test.ts new file mode 100644 index 000000000..ec4a29cad --- /dev/null +++ b/tests/integration/mcp-record-links.test.ts @@ -0,0 +1,420 @@ +/** + * The links an MCP read lists beside a lab reading, a medication and a + * document, against real Postgres and the real link tables. + * + * What the unit suites cannot show: that the link service's owner narrowing + * keeps another account's document out of every list (including through a + * link row that points across accounts), that a switched-off Documents module + * drops document links without reading them, that an imported document names + * its source system and id but never its instance address, and that the cap + * says when it cut a list short. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.APP_URL ??= "http://localhost"; + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { linkTargets } from "@/lib/links"; +import { MCP_TOOLS } from "@/lib/mcp/tools"; +import { MAX_LINKS, type RecordLink } from "@/lib/mcp/record-links"; +import type { McpAuthContext } from "@/lib/mcp/auth"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const ctx = (userId: string): McpAuthContext => ({ + userId, + tokenId: "t", + scopes: ["health:read"], + binding: `${userId}:t`, + canRead: true, + canWrite: false, +}); + +function tool(name: string) { + const def = MCP_TOOLS.find((t) => t.name === name); + if (!def) throw new Error(`tool ${name} not registered`); + return def; +} + +let counter = 0; + +async function makeUser(modules: Record = {}) { + const suffix = `${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `mcp-links-${suffix}`, + email: `mcp-links-${suffix}@example.test`, + timezone: "UTC", + modulePreferencesJson: modules, + }, + }); +} + +async function makeDocument( + userId: string, + title: string, + source?: { system: string; id: string }, +) { + return getPrismaClient().inboundDocument.create({ + data: { + userId, + title, + mimeType: "application/pdf", + byteSize: 4, + contentEncrypted: encryptToBytes("pdf!"), + documentDate: new Date("2026-03-01T00:00:00Z"), + ...(source + ? { + sourceSystem: source.system, + sourceId: source.id, + sourceInstance: "https://paperless.example.test", + } + : {}), + }, + }); +} + +async function makeLab(userId: string, analyte: string, takenAt: string) { + return getPrismaClient().labResult.create({ + data: { + userId, + analyte, + value: 120, + unit: "mg/dL", + takenAt: new Date(takenAt), + }, + }); +} + +/** + * One account's record: an LDL reading read from an imported report and filed + * against a visit, an older unlinked LDL reading, an HDL reading from the same + * report, a medication filed against the report, a condition. + */ +async function seedRecord(userId: string) { + const db = getPrismaClient(); + const report = await makeDocument(userId, "Lipid panel", { + system: "PAPERLESS", + id: "1234", + }); + const visit = await db.encounter.create({ + data: { + userId, + occurredAt: new Date("2026-03-01T09:00:00Z"), + kind: "ROUTINE", + }, + }); + const ldl = await makeLab(userId, "LDL", "2026-03-01T09:00:00Z"); + const olderLdl = await makeLab(userId, "LDL", "2025-03-01T09:00:00Z"); + const hdl = await makeLab(userId, "HDL", "2026-03-01T09:00:00Z"); + const med = await db.medication.create({ + data: { userId, name: "Atorvastatin", dose: "20mg" }, + }); + const condition = await db.illnessEpisode.create({ + data: { + userId, + label: "Knee pain", + type: "INJURY", + onsetAt: new Date("2026-02-01T00:00:00Z"), + }, + }); + await linkTargets(db, { + userId, + sourceKind: "labResult", + sourceId: ldl.id, + targetKind: "document", + targetIds: [report.id], + }); + await linkTargets(db, { + userId, + sourceKind: "labResult", + sourceId: hdl.id, + targetKind: "document", + targetIds: [report.id], + }); + await linkTargets(db, { + userId, + sourceKind: "encounter", + sourceId: visit.id, + targetKind: "labResult", + targetIds: [ldl.id], + }); + await linkTargets(db, { + userId, + sourceKind: "medication", + sourceId: med.id, + targetKind: "document", + targetIds: [report.id], + }); + return { report, visit, ldl, olderLdl, hdl, med, condition }; +} + +type Reading = { + analyte?: string; + links: string[]; + linksTruncated: boolean; + history?: { earlier: Array<{ links: string[]; linksTruncated: boolean }> }; +}; +type LabsAnswer = { + present: boolean; + data: { recent: Reading[] }; + linkedRecords: RecordLink[]; +}; +type FetchAnswer = { + title: string; + text: string; + metadata: Record & { + links?: RecordLink[]; + linksTruncated?: boolean; + }; +}; + +const fetchId = async (userId: string, id: string) => + (await tool("fetch").run(ctx(userId), { id })) as FetchAnswer; + +const analyteOf = (r: Reading) => + (r.analyte ?? "").replace(/<<<[A-Z_]+>>>/g, ""); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("get_labs lists each reading's links", () => { + it("names a linked report once, with its source system and id but no address", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const answer = (await tool("get_labs").run(ctx(user.id), {})) as LabsAnswer; + const ldl = answer.data.recent.find((x) => analyteOf(x) === "LDL")!; + const hdl = answer.data.recent.find((x) => analyteOf(x) === "HDL")!; + expect(ldl.links).toEqual([ + `document:${r.report.id}`, + `visit:${r.visit.id}`, + ]); + expect(ldl.linksTruncated).toBe(false); + expect(hdl.links).toEqual([`document:${r.report.id}`]); + // The report both readings came from is described once. + const docs = answer.linkedRecords.filter((l) => l.kind === "document"); + expect(docs).toEqual([ + { + id: `document:${r.report.id}`, + kind: "document", + label: "Lipid panel", + date: "2026-03-01", + sourceSystem: "PAPERLESS", + sourceId: "1234", + }, + ]); + expect(JSON.stringify(answer)).not.toContain("paperless.example.test"); + // No raw lab result id is left on the wire. + expect(JSON.stringify(answer)).not.toContain(r.ldl.id); + }); + + it("lists the links of earlier values and of a trajectory, [] when none", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const named = (await tool("get_labs").run(ctx(user.id), { + analyte: "LDL", + })) as LabsAnswer; + const [entry] = named.data.recent; + expect(entry.links).toEqual([ + `document:${r.report.id}`, + `visit:${r.visit.id}`, + ]); + expect(entry.history?.earlier).toEqual([ + expect.objectContaining({ links: [], linksTruncated: false }), + ]); + + const trajectory = (await tool("get_labs").run(ctx(user.id), { + analyte: "LDL", + history: true, + })) as { readings: Array<{ links: string[] }>; linkedRecords: unknown[] }; + expect(trajectory.readings.map((x) => x.links)).toEqual([ + [`document:${r.report.id}`, `visit:${r.visit.id}`], + [], + ]); + expect(trajectory.linkedRecords).toHaveLength(2); + }); + + it("drops document links while the Documents module is off", async () => { + const user = await makeUser({ inboundDocuments: false }); + const r = await seedRecord(user.id); + + const answer = (await tool("get_labs").run(ctx(user.id), {})) as LabsAnswer; + const ldl = answer.data.recent.find((x) => analyteOf(x) === "LDL")!; + expect(ldl.links).toEqual([`visit:${r.visit.id}`]); + expect(answer.linkedRecords.map((l) => l.kind)).toEqual(["visit"]); + + const lab = await fetchId(user.id, "lab:LDL"); + expect(lab.metadata.links?.map((l) => l.kind)).toEqual(["visit"]); + const med = await fetchId(user.id, `med:${r.med.id}`); + expect(med.metadata.links).toEqual([]); + expect(med.text).toContain("Linked records: none."); + }); +}); + +describe("fetch lists a lab's and a medication's links", () => { + it("lists the latest reading's links, with the source in the prose", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const lab = await fetchId(user.id, "lab:LDL"); + expect(lab.metadata.links?.map((l) => l.id)).toEqual([ + `document:${r.report.id}`, + `visit:${r.visit.id}`, + ]); + expect(lab.metadata.linksTruncated).toBe(false); + expect(lab.text).toContain("Linked records: document"); + expect(lab.text).toContain("PAPERLESS id"); + + const med = await fetchId(user.id, `med:${r.med.id}`); + expect(med.metadata.links).toEqual([ + expect.objectContaining({ + id: `document:${r.report.id}`, + sourceSystem: "PAPERLESS", + sourceId: "1234", + }), + ]); + }); + + it("says Linked records: none for a reading linked to nothing", async () => { + const user = await makeUser(); + await makeLab(user.id, "Ferritin", "2026-01-01T09:00:00Z"); + const lab = await fetchId(user.id, "lab:Ferritin"); + expect(lab.metadata.links).toEqual([]); + expect(lab.metadata.linksTruncated).toBe(false); + expect(lab.text).toContain("Linked records: none."); + }); + + it("caps a long list and says it did", async () => { + const user = await makeUser(); + const db = getPrismaClient(); + const med = await db.medication.create({ + data: { userId: user.id, name: "Ramipril", dose: "5mg" }, + }); + const docs = []; + for (let i = 0; i < MAX_LINKS + 3; i += 1) { + docs.push(await makeDocument(user.id, `Plan ${i}`)); + } + await linkTargets(db, { + userId: user.id, + sourceKind: "medication", + sourceId: med.id, + targetKind: "document", + targetIds: docs.map((d) => d.id), + }); + + const fetched = await fetchId(user.id, `med:${med.id}`); + expect(fetched.metadata.links).toHaveLength(MAX_LINKS); + expect(fetched.metadata.linksTruncated).toBe(true); + + const schedule = (await tool("get_medication_schedule").run( + ctx(user.id), + {}, + )) as { + medications: Array<{ + id: string; + links: RecordLink[]; + linksTruncated: boolean; + }>; + }; + expect(schedule.medications[0].id).toBe(`med:${med.id}`); + expect(schedule.medications[0].links).toHaveLength(MAX_LINKS); + expect(schedule.medications[0].linksTruncated).toBe(true); + }); +}); + +describe("fetch document lists the labs and medications filed against it", () => { + it("names the lab results read from it and the medications", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + const doc = await fetchId(user.id, `document:${r.report.id}`); + expect(doc.metadata.links?.map((l) => l.id)).toEqual([ + "lab:LDL", + "lab:HDL", + `med:${r.med.id}`, + ]); + expect(doc.text).toContain("Linked records: lab"); + expect(doc.text).toContain("medication"); + }); + + it("leaves out labs and medications whose module is off", async () => { + const user = await makeUser({ labs: false, medications: false }); + const r = await seedRecord(user.id); + const doc = await fetchId(user.id, `document:${r.report.id}`); + expect(doc.metadata.links).toEqual([]); + }); +}); + +describe("list rows carry the id fetch takes", () => { + it("on visits, the medication schedule and conditions", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const visits = (await tool("get_visits").run(ctx(user.id), { + months: 60, + })) as { visits: Array<{ id: string }> }; + expect(visits.visits.map((v) => v.id)).toEqual([`visit:${r.visit.id}`]); + + const schedule = (await tool("get_medication_schedule").run( + ctx(user.id), + {}, + )) as { medications: Array<{ id: string; links: RecordLink[] }> }; + expect(schedule.medications.map((m) => m.id)).toEqual([`med:${r.med.id}`]); + expect(schedule.medications[0].links.map((l) => l.id)).toEqual([ + `document:${r.report.id}`, + ]); + + const illness = (await tool("get_illness_recovery").run( + ctx(user.id), + {}, + )) as { data: { illness: { active: Array<{ id: string }> } } }; + expect(illness.data.illness.active.map((c) => c.id)).toEqual([ + `condition:${r.condition.id}`, + ]); + }); +}); + +describe("another account's records never appear", () => { + it("not through its own links, nor through a link row pointing across", async () => { + const me = await makeUser(); + const other = await makeUser(); + const mine = await seedRecord(me.id); + const theirs = await seedRecord(other.id); + // A link row in my account naming the other account's report: the target + // re-read is narrowed to my account, so it must drop out. + await getPrismaClient().labResultDocumentLink.create({ + data: { + userId: me.id, + labResultId: mine.ldl.id, + documentId: theirs.report.id, + }, + }); + await getPrismaClient().medicationDocumentLink.create({ + data: { + userId: me.id, + medicationId: mine.med.id, + documentId: theirs.report.id, + }, + }); + + const labs = JSON.stringify(await tool("get_labs").run(ctx(me.id), {})); + const lab = JSON.stringify(await fetchId(me.id, "lab:LDL")); + const med = JSON.stringify(await fetchId(me.id, `med:${mine.med.id}`)); + const schedule = JSON.stringify( + await tool("get_medication_schedule").run(ctx(me.id), {}), + ); + for (const answer of [labs, lab, med, schedule]) { + expect(answer).toContain(mine.report.id); + expect(answer).not.toContain(theirs.report.id); + expect(answer).not.toContain(theirs.visit.id); + } + const foreign = await fetchId(me.id, `document:${theirs.report.id}`); + expect(foreign.title).toBe("Not found"); + const myDoc = JSON.stringify( + await fetchId(me.id, `document:${mine.report.id}`), + ); + expect(myDoc).not.toContain(theirs.med.id); + }); +});