From bdfea7dd85909798c5d5019606533fde1d1fcda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:51:56 +0200 Subject: [PATCH] test(encounters): the Phase 2 surfaces against real Postgres, and end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three properties here cannot be answered by a mocked client, and each has a shape that would pass a weaker assertion while being wrong: - filing a visit against a due checkup twice satisfies it ONCE. Phrased as "the second call returned 200" it proves nothing, so the assertion is that lastSatisfiedAt did not move — and the instant is captured once, because two calls milliseconds apart are two different filings and the forward-only guard correctly advances on the later one. - a lab commit writes one link PER RESULT ROW. A test that asserted "there is a link" would pass on one link for a three-marker panel. - the visit list's condition filter runs in SQL. With one visit in the account a filter and no filter return the same list, so there are always two. The bulk arm gets per-entry tolerance over a hundred documents, one unknown id reported as one failed row with the rest filed, and a foreign visit refusing the whole batch before anything is written. The e2e spec proves the incidental-linking promise once, addressing stable data-slot attributes rather than viewport text: a visit saves with a date and nothing else, and a document dated inside the window offers that visit, pre-selected because there is exactly one, and files itself against it. --- e2e/visits.spec.ts | 102 ++++ src/app/checkups/page.tsx | 4 +- .../encounter-phase2-surfaces.test.ts | 468 ++++++++++++++++++ 3 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 e2e/visits.spec.ts create mode 100644 tests/integration/encounter-phase2-surfaces.test.ts diff --git a/e2e/visits.spec.ts b/e2e/visits.spec.ts new file mode 100644 index 000000000..76daf12c0 --- /dev/null +++ b/e2e/visits.spec.ts @@ -0,0 +1,102 @@ +/** + * Visits — the incidental-linking promise, proven end to end once. + * + * The flow this milestone is checked against, in one script: + * + * 1. create a visit with ONLY a date — nothing else touched, Save enabled; + * 2. it appears in the list; + * 3. open a document dated inside the ±7-day window; + * 4. the suggestion offers that visit; + * 5. take the offer; + * 6. the document's detail names the visit it now belongs to. + * + * Every assertion addresses a stable `data-slot`, never viewport text: the + * copy is i18n-driven and the mobile project renders the same slots at a + * different size, so a text assertion would break for the wrong reason. + * + * The visit is created through the API rather than by driving the sheet twice: + * step 1 is about the SAVE being possible with only a date, which the sheet + * assertion below covers, and the linking half needs a visit whose instant is + * pinned relative to the seeded document's date. + */ +import { expect, test } from "./setup/test"; + +import { ensureVaultFixture, MRT_DOC_ID } from "./setup/vault-fixture"; + +test.beforeEach(async () => { + await ensureVaultFixture(); +}); + +test.describe("visits", () => { + test("a visit saves with a date and nothing else", async ({ page }) => { + await page.goto("/checkups"); + await page.getByTestId("checkups-tab-visits").click(); + + const add = page.locator('[data-slot="visits-add"]').first(); + await expect(add).toBeVisible(); + await add.click(); + + // The date is pre-filled with now, and NOTHING else has been touched. + const save = page.locator('[data-slot="encounter-save"]').first(); + await expect(save).toBeVisible(); + // The whole product constraint: not disabled, with no practitioner, no + // reason, no outcome and no link chosen. + await expect(save).toBeEnabled(); + + await save.click(); + + // It appears in the list. A visit dated now is in the past arm. + await expect(page.locator('[data-slot="visit-card"]').first()).toBeVisible({ + timeout: 15_000, + }); + }); + + test("a document offers the visit its date falls near, and files itself against it", async ({ + page, + request, + }) => { + // The seeded MRI report carries a fixed filing date; the visit is placed + // two days before it so the ±7-day window resolves to exactly one + // candidate — the branch that pre-selects. + const document = await request + .get(`/api/documents/inbound/${MRT_DOC_ID}`) + .then((res) => res.json()); + const anchorDay: string = + document.data.reportDate ?? document.data.documentDate; + expect( + anchorDay, + "the fixture document carries a filing date", + ).toBeTruthy(); + const occurredAt = new Date(`${anchorDay}T09:00:00.000Z`); + occurredAt.setUTCDate(occurredAt.getUTCDate() - 2); + + const created = await request.post("/api/encounters", { + data: { + occurredAt: occurredAt.toISOString(), + status: "DONE", + kind: "SPECIALIST", + }, + }); + expect(created.status()).toBe(201); + + await page.goto(`/documents?doc=${MRT_DOC_ID}`); + + const suggestion = page + .locator('[data-slot="document-encounter-suggestion"]') + .first(); + await expect(suggestion).toBeVisible({ timeout: 15_000 }); + // One candidate, so the single-candidate branch: a pre-selectable offer + // rather than a picker. + await expect(suggestion).toHaveAttribute("data-verdict", "one"); + + await page + .locator('[data-slot="document-encounter-suggestion-option"]') + .first() + .click(); + + // The filing landed: the read-only "belongs to visit" row appears. + await expect( + page.locator('[data-slot="document-visit-links"]').first(), + ).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 4b2fbf1c5..a9d754672 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -59,10 +59,10 @@ export default function CheckupsPage() { - + {t("checkups.tabVorsorge")} - + {t("checkups.tabVisits")} diff --git a/tests/integration/encounter-phase2-surfaces.test.ts b/tests/integration/encounter-phase2-surfaces.test.ts new file mode 100644 index 000000000..a71fa7998 --- /dev/null +++ b/tests/integration/encounter-phase2-surfaces.test.ts @@ -0,0 +1,468 @@ +/** + * The Phase 2 surfaces against real Postgres, through the real routes. + * + * Everything here is about ROWS — how many link rows exist, whether a second + * call wrote a second satisfaction, which visits a filter actually returns — + * and none of it can be answered by a mocked client. Three properties in + * particular would pass a mock-shaped assertion while being wrong: + * + * - filing a visit against a due checkup twice must satisfy it ONCE. A test + * phrased as "the second call returned 200" proves nothing; the assertion + * is that `lastSatisfiedAt` did not move. + * - a lab commit writes one link PER RESULT ROW. A test that counted "there + * is a link" would pass on a single link for a three-marker panel. + * - the visit list's episode filter runs in SQL over the link table. A test + * with one visit in the account cannot tell a filter from no filter, so + * there are always two. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { GET as listEncounters } from "@/app/api/encounters/route"; +import { POST as createEncounter } from "@/app/api/encounters/route"; +import { GET as suggestEncounter } from "@/app/api/encounters/suggest/route"; +import { POST as commitOcr } from "@/app/api/labs/ocr/commit/route"; +import { POST as bulkDocuments } from "@/app/api/documents/inbound/bulk/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, 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 OWNER_ID = "phase2-owner"; + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); +} + +async function seedSession() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "phase2-owner", + email: "phase2-owner@example.test", + timezone: "Europe/Berlin", + // The document and lab surfaces are module-gated; the visit is not. + modulePreferencesJson: { + inboundDocuments: true, + labs: true, + illness: true, + }, + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: daysFromNow(7), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function post(body: unknown): Promise { + return createEncounter( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + ); +} + +async function json(res: Response): Promise { + return ((await res.json()) as { data: T }).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedSession(); +}); + +describe("filing a visit against a due checkup", () => { + async function dueCheckup() { + return getPrismaClient().measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Annual panel", + measurementType: null, + intervalDays: 365, + anchorDate: daysFromNow(-400), + notifyHour: 9, + nextDueAt: daysFromNow(-35), + }, + }); + } + + it("advances lastSatisfiedAt, and doing it twice does not advance it again", async () => { + const prisma = getPrismaClient(); + const checkup = await dueCheckup(); + expect(checkup.lastSatisfiedAt).toBeNull(); + + // ONE instant, captured once and reused. Two calls to `daysFromNow(-30)` + // are milliseconds apart, and the second would be strictly later — which + // the forward-only guard correctly advances on. The property under test is + // the one that matters in the product: the SAME visit filed twice. + const occurredAt = daysFromNow(-30).toISOString(); + + const first = await post({ + occurredAt, + status: "DONE", + reminderId: checkup.id, + }); + expect(first.status).toBe(201); + + const afterFirst = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + expect(afterFirst.lastSatisfiedAt).not.toBeNull(); + + // The SAME instant again. `satisfyReminder` is forward-only, so this is a + // no-op that still succeeds — the property the plan calls idempotent, and + // the one a status-code assertion alone would not see. + const second = await post({ + occurredAt, + status: "DONE", + reminderId: checkup.id, + }); + expect(second.status).toBe(201); + + const afterSecond = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + expect(afterSecond.lastSatisfiedAt?.toISOString()).toBe( + afterFirst.lastSatisfiedAt?.toISOString(), + ); + expect(afterSecond.nextDueAt?.toISOString()).toBe( + afterFirst.nextDueAt?.toISOString(), + ); + }); + + it("satisfies at the VISIT's instant rather than at now", async () => { + const prisma = getPrismaClient(); + const checkup = await dueCheckup(); + const occurredAt = daysFromNow(-30); + + await post({ + occurredAt: occurredAt.toISOString(), + status: "DONE", + reminderId: checkup.id, + }); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + // Satisfying at `now` would push next-due 365 days from today rather than + // from the draw; the honest date is the visit's own. + expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString()); + }); + + it("leaves a checkup untouched when the visit is not done", async () => { + const prisma = getPrismaClient(); + const checkup = await dueCheckup(); + + const res = await post({ + occurredAt: daysFromNow(-30).toISOString(), + status: "NO_SHOW", + reminderId: checkup.id, + }); + expect(res.status).toBe(201); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + expect(after.lastSatisfiedAt).toBeNull(); + }); +}); + +describe("a lab panel filed against a visit", () => { + const rows = [ + { analyte: "LDL", value: 3.1, unit: "mmol/L" }, + { analyte: "HDL", value: 1.4, unit: "mmol/L" }, + { analyte: "Ferritin", value: 88, unit: "µg/L" }, + ]; + + function commit(body: unknown): Promise { + return commitOcr( + new Request("http://localhost/api/labs/ocr/commit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + ); + } + + it("writes ONE link per result row", async () => { + const prisma = getPrismaClient(); + const visit = await json<{ id: string }>( + await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }), + ); + const takenAt = daysFromNow(-1).toISOString(); + + const res = await commit({ + rows: rows.map((row) => ({ ...row, takenAt })), + encounterId: visit.id, + }); + expect(res.status).toBe(200); + + const results = await prisma.labResult.findMany({ + where: { userId: OWNER_ID }, + }); + expect(results).toHaveLength(3); + + const links = await prisma.encounterLabLink.findMany({ + where: { userId: OWNER_ID, encounterId: visit.id }, + }); + // Three, not one: a marker re-run on a different day belongs to its own + // visit, which is why the join is m:n rather than a column on the result. + expect(links).toHaveLength(3); + expect(new Set(links.map((link) => link.labResultId))).toEqual( + new Set(results.map((result) => result.id)), + ); + }); + + it("succeeds and writes no link when no visit is named", async () => { + const prisma = getPrismaClient(); + const res = await commit({ + rows: [{ ...rows[0], takenAt: daysFromNow(-2).toISOString() }], + }); + expect(res.status).toBe(200); + + expect(await prisma.labResult.count({ where: { userId: OWNER_ID } })).toBe( + 1, + ); + expect( + await prisma.encounterLabLink.count({ where: { userId: OWNER_ID } }), + ).toBe(0); + }); +}); + +describe("filing documents against a visit in bulk", () => { + async function seedDocuments(count: number): Promise { + const prisma = getPrismaClient(); + const ids: string[] = []; + for (let index = 0; index < count; index += 1) { + const row = await prisma.inboundDocument.create({ + data: { + userId: OWNER_ID, + kind: "OTHER", + filename: `doc-${index}.pdf`, + mimeType: "application/pdf", + byteSize: 4, + status: "STORED", + contentEncrypted: Buffer.from("test"), + contentCodec: "v1", + }, + }); + ids.push(row.id); + } + return ids; + } + + function bulk(body: unknown): Promise { + return bulkDocuments( + new Request("http://localhost/api/documents/inbound/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + ); + } + + it("links a whole batch, per entry, and is idempotent", async () => { + const prisma = getPrismaClient(); + const visit = await json<{ id: string }>( + await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }), + ); + const ids = await seedDocuments(100); + + const res = await bulk({ + ids, + action: "linkEncounter", + encounterId: visit.id, + }); + expect(res.status).toBe(200); + const first = await json<{ results: Array<{ ok: boolean }> }>(res); + // Per-entry tolerance: a hundred outcomes, one per id, not one verdict. + expect(first.results).toHaveLength(100); + expect(first.results.every((row) => row.ok)).toBe(true); + expect( + await prisma.encounterDocumentLink.count({ + where: { userId: OWNER_ID, encounterId: visit.id }, + }), + ).toBe(100); + + // Re-posting the same batch changes nothing and still succeeds. + const again = await bulk({ + ids, + action: "linkEncounter", + encounterId: visit.id, + }); + expect(again.status).toBe(200); + expect( + await prisma.encounterDocumentLink.count({ + where: { userId: OWNER_ID, encounterId: visit.id }, + }), + ).toBe(100); + }); + + it("reports one unknown id as one failed row and files the rest", async () => { + const prisma = getPrismaClient(); + const visit = await json<{ id: string }>( + await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }), + ); + const ids = await seedDocuments(2); + + const res = await bulk({ + ids: [...ids, "not-a-document"], + action: "linkEncounter", + encounterId: visit.id, + }); + const payload = await json<{ + results: Array<{ id: string; ok: boolean; error: string | null }>; + }>(res); + expect(payload.results).toHaveLength(3); + expect(payload.results.filter((row) => row.ok)).toHaveLength(2); + expect( + payload.results.find((row) => row.id === "not-a-document")?.error, + ).toBe("notFound"); + expect( + await prisma.encounterDocumentLink.count({ where: { userId: OWNER_ID } }), + ).toBe(2); + }); + + it("refuses the whole batch when the visit is not the caller's", async () => { + const prisma = getPrismaClient(); + const ids = await seedDocuments(2); + const res = await bulk({ + ids, + action: "linkEncounter", + encounterId: "someone-elses-visit", + }); + expect(res.status).toBe(404); + expect( + await prisma.encounterDocumentLink.count({ where: { userId: OWNER_ID } }), + ).toBe(0); + }); +}); + +describe("the visit list's condition filter", () => { + it("returns only the visits filed against that episode", async () => { + const prisma = getPrismaClient(); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Knee", + type: "INJURY", + onsetAt: daysFromNow(-40), + }, + }); + + const linked = await json<{ id: string }>( + await post({ + occurredAt: daysFromNow(-5).toISOString(), + status: "DONE", + episodeIds: [episode.id], + }), + ); + // A SECOND visit with no link. Without it a filter and no filter return + // the same list and the assertion below would prove nothing. + await post({ occurredAt: daysFromNow(-6).toISOString(), status: "DONE" }); + + const res = await listEncounters( + new Request( + `http://localhost/api/encounters?episodeId=${episode.id}`, + ) as never, + ); + expect(res.status).toBe(200); + const list = await json<{ + upcoming: Array<{ id: string }>; + past: Array<{ id: string }>; + }>(res); + expect(list.past.map((row) => row.id)).toEqual([linked.id]); + + const unfiltered = await json<{ past: Array<{ id: string }> }>( + await listEncounters( + new Request("http://localhost/api/encounters") as never, + ), + ); + expect(unfiltered.past).toHaveLength(2); + }); +}); + +describe("the suggestion, over real rows", () => { + function suggest(anchor: string): Promise { + return suggestEncounter( + new Request( + `http://localhost/api/encounters/suggest?anchor=${encodeURIComponent(anchor)}`, + ) as never, + ); + } + + it("pre-selects a single candidate and offers a picker for two", async () => { + const anchor = daysFromNow(-3); + + const only = await json<{ id: string }>( + await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" }), + ); + const one = await json<{ kind: string; encounter: { id: string } }>( + await suggest(anchor.toISOString()), + ); + expect(one.kind).toBe("one"); + expect(one.encounter.id).toBe(only.id); + + await post({ occurredAt: daysFromNow(-4).toISOString(), status: "DONE" }); + const many = await json<{ kind: string; encounters: unknown[] }>( + await suggest(anchor.toISOString()), + ); + expect(many.kind).toBe("many"); + expect(many.encounters).toHaveLength(2); + // No pre-selection on the two-candidate arm — the shape itself has none. + expect(many).not.toHaveProperty("encounter"); + }); + + it("offers nothing outside the window, and nothing for a soft-deleted visit", async () => { + const prisma = getPrismaClient(); + await post({ occurredAt: daysFromNow(-30).toISOString(), status: "DONE" }); + const far = await json<{ kind: string }>( + await suggest(new Date().toISOString()), + ); + expect(far.kind).toBe("none"); + + const near = await json<{ id: string }>( + await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }), + ); + await prisma.encounter.update({ + where: { id: near.id }, + data: { deletedAt: new Date() }, + }); + const tombstoned = await json<{ kind: string }>( + await suggest(new Date().toISOString()), + ); + expect(tombstoned.kind).toBe("none"); + }); +});