Files
HealthLog/e2e/documents-ai.spec.ts
T
Marc-André Bombeck 5dd8f23890 test(documents): e2e for AI assist, session-only summary, and content search
Six specs over the P2 surface: the review-first draft that only writes on
an explicit save (never auto-committed), the transient summary panel with
its not-saved / not-a-diagnosis note, whole-word content search reaching a
body-only word through the real blind-token index union, the corpus
index-all backfill CTA, the provider-unavailable settings pointer, and the
text-mode image-only refusal that fires before any OCR. The vault fixture
gains a seeded content-index row (tokens hashed under the same HKDF subkey
the server derives) plus a bland assist probe document.
2026-07-07 19:45:08 +02:00

338 lines
11 KiB
TypeScript

/**
* Document vault — the P2 AI-assist + content-search acceptance suite.
*
* Pins the review-first contract (a suggestion prefills an editable field and
* is only ever written on an explicit save — never auto-committed), the
* session-only summary panel (transient, carrying the "not saved · not a
* diagnosis" note), whole-word content search reaching a body-only word through
* the real blind-token GIN union, the corpus "index all" backfill CTA, the calm
* provider-unavailable pointer (no provider → no assist button, a settings
* link instead), and the text-mode image-only refusal that fires client-side
* before any OCR/upload work.
*
* The seeded demo user has NO AI provider, so the unavailable state is the
* default; the enabled states are driven by `page.route` mocks of `usage` +
* the OCR capability probe + the AI endpoints. Content search uses NO mock — it
* seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives
* the real list union.
*/
import { expect, test, type Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureVaultFixture,
ensureVaultAiFixture,
AI_PROBE_DOC_ID,
CONTENT_DOC_ID,
CONTENT_BODY_WORD,
} from "./setup/vault-fixture";
/** The vault usage DTO the UI reads before offering upload + AI affordances. */
function usageBody(overrides: {
assistAvailable: boolean;
contentIndex: { enabled: boolean; indexedCount: number; totalCount: number };
}) {
return {
data: {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: 26_214_400,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: overrides.assistAvailable,
contentIndex: overrides.contentIndex,
},
error: null,
};
}
/** Fulfil the envelope shape the client unwraps (`(await res.json()).data`). */
async function fulfilJson(
route: Parameters<Parameters<Page["route"]>[1]>[0],
body: unknown,
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Mock the AI-availability surface: `usage` (assist + content-index flags) and
* the OCR capability probe (transport mode). Endpoints (suggest/summary/reindex)
* are mocked per-test on top of this.
*/
async function mockAiEnabled(
page: Page,
opts: {
mode: "vision" | "text";
pdfSupported?: boolean;
contentIndex?: {
enabled: boolean;
indexedCount: number;
totalCount: number;
};
},
): Promise<void> {
const contentIndex = opts.contentIndex ?? {
enabled: true,
indexedCount: 0,
totalCount: 1,
};
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, usageBody({ assistAvailable: true, contentIndex })),
);
await page.route("**/api/labs/ocr/capability", (route) =>
fulfilJson(route, {
data: {
available: true,
mode: opts.mode,
reason: null,
pdfSupported: opts.pdfSupported ?? opts.mode === "vision",
},
error: null,
}),
);
}
test.describe("document vault — AI assist + content search", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureVaultAiFixture();
});
// ── (a) Review-first assist: prefill → edit → save, nothing auto-committed ──
test("assist prefills an editable draft that only saves on commit", async ({
page,
}) => {
await mockAiEnabled(page, { mode: "vision" });
const suggestedTitle = `Suggested lab report ${Date.now()}`;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/suggest`,
(route) =>
fulfilJson(route, {
data: {
suggestions: {
title: suggestedTitle,
kind: "LAB_RESULT",
documentDate: "2025-03-03",
},
},
error: null,
}),
);
// Count PATCH writes to this document — a review-first draft must not fire
// one until the user commits.
let patchCount = 0;
page.on("request", (req) => {
if (
req.method() === "PATCH" &&
req.url().includes(`/api/documents/inbound/${AI_PROBE_DOC_ID}`)
) {
patchCount += 1;
}
});
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const suggest = sheet.locator('[data-slot="assist-suggest"]');
await expect(suggest).toBeEnabled();
await suggest.click();
const review = sheet.locator('[data-slot="assist-suggestion-review"]');
await expect(review).toBeVisible();
await expect(
review.getByText("AI suggestion — review before saving"),
).toBeVisible();
// Applying the title seeds the EDITABLE field only — no write yet.
await review.getByRole("button", { name: "Use title" }).click();
const titleInput = sheet.locator("#document-title-input");
await expect(titleInput).toHaveValue(suggestedTitle);
expect(patchCount).toBe(0);
// Commit (blur/Enter) → the write fires exactly once, carrying the title.
const saved = page.waitForRequest(
(req) =>
req.method() === "PATCH" &&
req.url().includes(`/api/documents/inbound/${AI_PROBE_DOC_ID}`),
);
await titleInput.press("Enter");
const savedReq = await saved;
expect(JSON.parse(savedReq.postData() ?? "{}").title).toBe(suggestedTitle);
expect(patchCount).toBe(1);
});
// ── (b) Session-only summary: transient, note present, closing discards ──
test("summary panel is transient and labelled not-saved / not-a-diagnosis", async ({
page,
}) => {
await mockAiEnabled(page, { mode: "vision" });
const summaryText = "A chest imaging report issued by a radiology clinic.";
let summaryCalls = 0;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/summary**`,
(route) => {
summaryCalls += 1;
return fulfilJson(route, {
data: { summary: summaryText },
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Summarise" }).click();
const panel = sheet.locator('[data-slot="document-summary-panel"]');
await expect(panel).toBeVisible();
await expect(panel.getByText(summaryText)).toBeVisible();
await expect(
panel.getByText(
"Not saved · not a diagnosis. Shown once for this view only.",
),
).toBeVisible();
expect(summaryCalls).toBe(1);
// Closing discards — the panel is gone and nothing lingers.
await panel.getByRole("button", { name: "Hide" }).click();
await expect(panel).not.toBeVisible();
});
// ── (c) Content search reaches a BODY-ONLY word through the token union ──
test("content search finds a word that lives only in the document body", async ({
page,
}) => {
await page.goto("/documents");
await expect(
page.getByRole("heading", { name: "Documents" }),
).toBeVisible();
// The word is absent from every title/filename — the only route is the
// real blind-token union over the seeded content index.
await page
.getByRole("searchbox", { name: "Search documents" })
.fill(CONTENT_BODY_WORD);
const card = page.getByRole("button", { name: "Open Radiology note" });
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveURL(new RegExp(`q=${CONTENT_BODY_WORD}`));
// The matched card advertises its searchable body.
const searchable = page
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
.locator('[data-slot="document-searchable"]');
await expect(searchable).toBeVisible();
// Sanity: the word really is absent from the short fields (server-side
// confirmation the union — not an ILIKE — did the work).
const viaApi = await page.request.get(
`/api/documents/inbound?q=${CONTENT_BODY_WORD}&limit=10`,
);
const body = await viaApi.json();
const hit = body.data.documents.find(
(d: { id: string }) => d.id === CONTENT_DOC_ID,
);
expect(hit).toBeTruthy();
expect(`${hit.title} ${hit.filename}`.toLowerCase()).not.toContain(
CONTENT_BODY_WORD,
);
});
// ── (d) Corpus "index all" backfill CTA ──────────────────────────────────
test("the index-all CTA enqueues the corpus backfill", async ({ page }) => {
await mockAiEnabled(page, {
mode: "vision",
contentIndex: { enabled: true, indexedCount: 1, totalCount: 10 },
});
let reindexCalls = 0;
await page.route("**/api/documents/inbound/reindex", (route) => {
reindexCalls += 1;
return fulfilJson(route, { data: { enqueued: 5 }, error: null });
});
await page.goto("/documents");
const hint = page.locator('[data-slot="content-search-hint"]');
await expect(hint).toBeVisible();
const indexAll = page.locator('[data-slot="content-index-all"]');
await expect(indexAll).toBeVisible();
await indexAll.click();
await expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible();
expect(reindexCalls).toBe(1);
});
// ── (e) No-provider degradation: calm pointer, no assist button ──────────
test("with no provider the detail sheet shows a calm settings pointer", async ({
page,
}) => {
// No mocks — the seeded demo user has no AI provider configured.
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
await expect(
sheet.locator('[data-slot="assist-unavailable"]'),
).toBeVisible();
await expect(
sheet.getByRole("link", { name: "Open AI settings" }),
).toBeVisible();
// The action button is absent — the endpoint would 422, so the UI never
// offers it.
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
test("text-mode assist refuses a PDF client-side before any request", async ({
page,
}) => {
await mockAiEnabled(page, { mode: "text", pdfSupported: false });
let suggestCalls = 0;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/suggest`,
(route) => {
suggestCalls += 1;
return fulfilJson(route, {
data: {
suggestions: { title: null, kind: null, documentDate: null },
},
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const suggest = sheet.locator('[data-slot="assist-suggest"]');
await expect(suggest).toBeEnabled();
await suggest.click();
// The PDF is refused locally (tesseract reads images only) — the error
// shows and the server suggest route is never called.
await expect(
sheet.getByText("Local OCR reads images only — not this file."),
).toBeVisible();
await page.waitForTimeout(300);
expect(suggestCalls).toBe(0);
});
});