Anchor the document visit-suggestion on a full instant

The suggestion anchored on the document's own date, which the DTO carries
as a date only. The suggest endpoint takes a full instant, so every
document asked the question as `2025-10-14` and got 422 back; the field
then rendered nothing and no document ever offered the visit its date
falls near. Lift the date to noon UTC before asking, the shape the two
lab moments already send.

The e2e that should have caught it never authenticated: the visits spec
carried no storage state, so its `page` redirected to login and its
`request` reads answered 401. It also asserts a global verdict — one
visit in the document's window — so clear the account's visits before
each test, run the file serially, and keep it to one project, since two
projects mutating the shared account would leave two candidates in the
window.

The accessibility mock lagged the same feature: the detail sheet reads
`encounterLinks` off the document and the mock omitted it, so the sheet
threw before it painted. Add the field the server always sends.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 18:46:55 +02:00
1 parent 9456b18497
commit 594898d350
5 files changed
+68 -2

No files matched your search

+1
View File
@@ -183,6 +183,7 @@ const inboundDocument = {
factCount: 0,
pendingCount: 0,
conditionLinks: [],
encounterLinks: [],
servingClass: "attachment",
hasContentIndex: true,
contentIndexSource: "vision",
+27
View File
@@ -328,6 +328,33 @@ async function upsertDocs(
}
}
/**
* Remove the seeded e2e user's visits and their document links.
*
* The incidental-linking spec asserts a GLOBAL verdict — "exactly one visit
* falls in this document's ±7-day window" — so it must own the window it counts
* against. A visit created by an earlier run, a Playwright retry, or a second
* project sharing the account would leave two rows in the window and turn the
* single-candidate branch into the picker branch. Clearing first makes the
* count the test creates the only count it reads. `encounter_document_links`
* goes first for the FK; both are user-scoped.
*/
export async function resetEncounters(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
await pool.query(
"DELETE FROM encounter_document_links WHERE user_id = $1",
[userId],
);
await pool.query("DELETE FROM encounters WHERE user_id = $1", [userId]);
} finally {
await pool.end();
}
}
/**
* Enable the vault module for the seeded e2e user and plant the doctor-flow
* corpus. Safe to call from every spec's beforeAll — every statement
+23 -1
View File
@@ -19,15 +19,37 @@
* assertion below covers, and the linking half needs a visit whose instant is
* pinned relative to the seeded document's date.
*/
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { ensureVaultFixture, MRT_DOC_ID } from "./setup/vault-fixture";
import {
ensureVaultFixture,
MRT_DOC_ID,
resetEncounters,
} from "./setup/vault-fixture";
test.beforeEach(async () => {
// Own the window the linking case counts against: clear the account's visits
// before each test so a retry or a prior run cannot leave a second candidate
// in the ±7-day window and flip the single-candidate verdict to the picker.
await resetEncounters();
await ensureVaultFixture();
});
// Serial, and single-project below: the two tests share one account, and the
// reset above would race a sibling running in parallel — the linking test would
// clear the visit the save test is still asserting. In order, in one worker,
// each test owns the account for its span.
test.describe.configure({ mode: "serial" });
test.describe("visits", () => {
// The seeded account owns the visits and the document this flow drives, and
// both the `page` and the `request` fixture read them under its session.
// Without this the whole file runs anonymous: `/checkups` redirects to login
// and every `/api/*` read answers 401, which is a test-setup gap, not a
// product one.
test.use({ storageState: STORAGE_STATE_PATH });
test("a visit saves with a date and nothing else", async ({ page }) => {
await page.goto("/checkups");
await page.getByTestId("checkups-tab-visits").click();
+7
View File
@@ -136,6 +136,13 @@ export default defineConfig({
// desktop project instead, where the shell below `md` renders from the
// same width-driven rules. Its file comment carries the reasoning.
"chrome-header-seam-banners.spec.ts",
// The incidental-linking journey creates a visit and files a document
// against it, then asserts a GLOBAL verdict — one candidate in the
// document's ±7-day window. Two projects mutating the one shared
// account in parallel would leave two visits in that window and flip
// the single-candidate branch to the picker. It proves a flow through
// stable data-slots, not a mobile layout, so it runs in one project.
"visits.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
],
@@ -12,6 +12,14 @@
* of the two; `documentDate` is the person's own filing date and is the
* fallback.
*
* Both DTO fields are date-only (`YYYY-MM-DD`), but the suggest endpoint takes
* a full instant — the same shape the two lab moments already hand it. So the
* date is lifted to noon UTC here, exactly as the OCR review does: the rule is
* a ±7-day window, so the time of day never changes the verdict, and noon keeps
* a viewer's local midnight from rounding the day either way. Passing the bare
* date instead is what made this suggestion silently never appear — the API
* answered 422 and the field rendered nothing.
*
* It renders nothing once the document already names a visit that this session
* did not choose: an offer to file something already filed is noise.
*/
@@ -31,9 +39,10 @@ export function DocumentEncounterSuggestion({
onChange: (encounterId: string | null) => void;
}) {
if (alreadyLinked) return null;
const instant = anchor ? `${anchor}T12:00:00.000Z` : null;
return (
<EncounterSuggestionField
anchor={anchor}
anchor={instant}
value={value}
onChange={onChange}
disabled={disabled}