test(encounters): the Phase 2 surfaces against real Postgres, and end to end

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:51:56 +02:00
1 parent 4d2f706dad
commit bdfea7dd85
3 files changed
+572 -2

No files matched your search

+102
View File
@@ -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 });
});
});
+2 -2
View File
@@ -59,10 +59,10 @@ export default function CheckupsPage() {
<PullToRefreshIndicator {...pull} />
<Tabs value={view} onValueChange={setView}>
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
<TabsTrigger value="vorsorge" data-slot="checkups-tab-vorsorge">
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
{t("checkups.tabVorsorge")}
</TabsTrigger>
<TabsTrigger value="visits" data-slot="checkups-tab-visits">
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
{t("checkups.tabVisits")}
</TabsTrigger>
</TabsList>
@@ -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<Response> {
return createEncounter(
new Request("http://localhost/api/encounters", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
);
}
async function json<T>(res: Response): Promise<T> {
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<Response> {
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<string[]> {
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<Response> {
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<Response> {
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");
});
});