mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
1c0c194820
commit
5dd8f23890
2 files changed
+478
-1
No files matched your search
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
+141
-1
@@ -23,7 +23,13 @@
|
||||
* same date and link. `seedNamespaceDocs` adds disposable per-test
|
||||
* namespaces (bulk flows) that never collide with the doctor-flow corpus.
|
||||
*/
|
||||
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import {
|
||||
createCipheriv,
|
||||
createHash,
|
||||
createHmac,
|
||||
hkdfSync,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
|
||||
import pg from "pg";
|
||||
|
||||
@@ -68,6 +74,68 @@ function encryptBinary2(plaintext: Buffer): Buffer {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string into the `encrypt()`-string-as-UTF-8 Bytes shape
|
||||
* the content index stores `text_encrypted` in (`<keyId>.<base64(iv|tag|ct)>`
|
||||
* → UTF-8 bytes). Mirrors src/lib/crypto.ts `encrypt` + the coach bytes codec.
|
||||
*/
|
||||
function encryptStringToBytes(plaintext: string): Buffer {
|
||||
const { id, key } = resolveActiveKey();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const payload = Buffer.concat([iv, tag, enc]).toString("base64");
|
||||
return Buffer.from(`${id}.${payload}`, "utf8");
|
||||
}
|
||||
|
||||
// ─── Blind content-index tokens (mirrors src/lib/documents/content-index) ────
|
||||
//
|
||||
// HKDF-derived index subkey off the ACTIVE encryption key + domain label, then
|
||||
// each normalised token HMAC-SHA256'd and truncated to a 16-char hex tag. The
|
||||
// server hashes a search query the same way, so a body-only word seeded here is
|
||||
// findable through the real GIN union without ever storing the word in clear.
|
||||
|
||||
const CONTENT_INDEX_INFO = "healthlog:document-content-index:v1";
|
||||
|
||||
function indexSubkey(): Buffer {
|
||||
const { key } = resolveActiveKey();
|
||||
return Buffer.from(
|
||||
hkdfSync("sha256", key, new Uint8Array(0), CONTENT_INDEX_INFO, 32),
|
||||
);
|
||||
}
|
||||
|
||||
/** Tokenise like the server: NFKD de-accent, lowercase, alnum runs, len 3..64.
|
||||
* The seeded words are deliberately non-stopwords, so the stopword drop the
|
||||
* server also applies is a no-op here and the tag sets match exactly. */
|
||||
function tokenise(text: string): string[] {
|
||||
const normalised = text
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
for (const raw of normalised.split(/[^\p{L}\p{N}]+/u)) {
|
||||
const token = raw.trim();
|
||||
if (token.length < 3 || token.length > 64) continue;
|
||||
seen.add(token);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function tokeniseAndHash(text: string): string[] {
|
||||
const subkey = indexSubkey();
|
||||
const hashes = new Set<string>();
|
||||
for (const token of tokenise(text)) {
|
||||
hashes.add(
|
||||
createHmac("sha256", subkey)
|
||||
.update(token, "utf8")
|
||||
.digest("hex")
|
||||
.slice(0, 16),
|
||||
);
|
||||
}
|
||||
return [...hashes];
|
||||
}
|
||||
|
||||
// ─── Deterministic tiny documents ───────────────────────────────────────────
|
||||
|
||||
/** Minimal single-page PDF; `marker` lands in a comment so every doc's bytes
|
||||
@@ -96,6 +164,15 @@ function tinyPng(marker: string): Buffer {
|
||||
|
||||
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
|
||||
export const MRT_DOC_ID = "e2evaultmrt0000000000001";
|
||||
/** A stored PDF the AI-assist specs drive (mocked provider); title/kind are
|
||||
* bland so an applied suggestion is visibly different. */
|
||||
export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
|
||||
/** A stored document whose searchable body word lives ONLY in its content
|
||||
* index — never in the title or filename — so content search is the only
|
||||
* route to it. */
|
||||
export const CONTENT_DOC_ID = "e2evaultcontent00000001";
|
||||
/** The whole word that appears only in `CONTENT_DOC_ID`'s indexed body. */
|
||||
export const CONTENT_BODY_WORD = "pneumothorax";
|
||||
/** Filing date of the MRT report — "last autumn" relative to the fixture. */
|
||||
const MRT_DOC_DATE = "2025-10-14";
|
||||
|
||||
@@ -263,6 +340,69 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the P2 AI-assist + content-search fixtures:
|
||||
* - a bland PDF the assist / summary specs drive against a mocked provider;
|
||||
* - a document whose only searchable word (`CONTENT_BODY_WORD`) lives in its
|
||||
* blind content index, not its title/filename, so content search is the
|
||||
* sole route to it — exercising the real GIN token union end-to-end.
|
||||
* Idempotent (fixed ids upserted); safe to call from every spec's beforeAll.
|
||||
*/
|
||||
export async function ensureVaultAiFixture(): 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 upsertDocs(pool, userId, [
|
||||
{
|
||||
id: AI_PROBE_DOC_ID,
|
||||
kind: "OTHER",
|
||||
title: "AI probe report",
|
||||
filename: "ai-probe-report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
documentDate: "2026-04-02",
|
||||
bytes: tinyPdf(AI_PROBE_DOC_ID),
|
||||
},
|
||||
{
|
||||
// Title + filename deliberately omit CONTENT_BODY_WORD.
|
||||
id: CONTENT_DOC_ID,
|
||||
kind: "DOCTOR_REPORT",
|
||||
title: "Radiology note",
|
||||
filename: "radiology-note.pdf",
|
||||
mimeType: "application/pdf",
|
||||
documentDate: "2026-03-10",
|
||||
bytes: tinyPdf(CONTENT_DOC_ID),
|
||||
},
|
||||
]);
|
||||
|
||||
// Seed CONTENT_DOC_ID's blind content index: the body word is only findable
|
||||
// via the token union. The plaintext text is stored encrypted (never read
|
||||
// by the search path) purely to mirror a real index row.
|
||||
const body = `Chest imaging report. Findings consistent with ${CONTENT_BODY_WORD}. No pleural effusion noted.`;
|
||||
await pool.query(
|
||||
`INSERT INTO document_content_index
|
||||
(id, document_id, user_id, text_encrypted, search_tokens, source,
|
||||
provider_type, tokenizer_version, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'vision', NULL, '1', NOW(), NOW())
|
||||
ON CONFLICT (document_id) DO UPDATE SET
|
||||
text_encrypted = EXCLUDED.text_encrypted,
|
||||
search_tokens = EXCLUDED.search_tokens,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
"e2evaultcidx0000000000001",
|
||||
CONTENT_DOC_ID,
|
||||
userId,
|
||||
encryptStringToBytes(body),
|
||||
tokeniseAndHash(body),
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a disposable, namespaced batch of OTHER-kind PDF documents for the
|
||||
* bulk-flow specs (`title`/`filename` carry the prefix so a search filter
|
||||
|
||||
Reference in new issue
Block a user