mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
test(e2e): document-vault acceptance suite
Pins the doctor-flow script (type chip, condition chip, text search — three independent routes to one document), the URL deep-link contract (?kind / ?episode / ?year / ?doc), delete with undo, the bulk flows (50-item kind change, per-id partial failure, client-side chunking above 100 ids), keyboard-only navigation over the roving grid, page-wide drop and clipboard-paste intake, the duplicate-upload contract, the admin per-file cap (commit-on-Enter, rejection copy naming the limit), the touch long-press selection on the mobile project, and axe scans over the timeline, the open detail sheet, and bulk mode. The fixture seeds the 60-document corpus straight through Postgres — the upload endpoint's 60/hour bucket cannot carry API seeding across two Playwright projects — with blobs encrypted in the same binary2 layout the server writes, so previews exercise the real decrypt path. Idempotent throughout; mutating tests use disposable namespaces.
This commit is contained in:
1 parent
0ed3d2fcc1
commit
c899f5054e
2 files changed
+884
No files matched your search
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Document vault — the release acceptance suite.
|
||||
*
|
||||
* Pins the doctor-flow script (three independent routes to one document:
|
||||
* type chip, condition chip, text search), the URL deep-link contract
|
||||
* (`?kind` / `?episode` / `?year` / `?doc`), delete→undo, the bulk flows
|
||||
* (50-item kind change, partial failure, the >100 client-side chunking),
|
||||
* keyboard-only navigation, page-wide drop + clipboard paste intake, the
|
||||
* duplicate-upload contract, the admin per-file cap (commit-on-Enter +
|
||||
* rejection copy naming the limit), and axe scans over the timeline, the
|
||||
* open detail sheet, and bulk mode.
|
||||
*
|
||||
* Fixture: `e2e/setup/vault-fixture.ts` seeds the 60-document corpus (plus
|
||||
* the "Knie" episode and the linked MRT trio) straight through Postgres —
|
||||
* idempotent, so the desktop and mobile projects can both (re-)run it.
|
||||
* Mutating tests operate on their own seeded namespaces or on documents
|
||||
* they create, never on the shared doctor-flow corpus.
|
||||
*/
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
ensureVaultFixture,
|
||||
seedNamespaceDocs,
|
||||
KNIE_EPISODE_ID,
|
||||
MRT_DOC_ID,
|
||||
} from "./setup/vault-fixture";
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 26_214_400;
|
||||
|
||||
/** Scroll the shell's scroll container until `locator` is visible (the
|
||||
* timeline is virtualized — off-window rows are not in the DOM at all). */
|
||||
async function scrollUntilVisible(
|
||||
page: Page,
|
||||
locator: ReturnType<Page["locator"]>,
|
||||
maxSteps = 40,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
if (await locator.isVisible().catch(() => false)) return;
|
||||
await page
|
||||
.locator("#main-content")
|
||||
.evaluate((el) => el.scrollBy(0, el.clientHeight));
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
await expect(locator).toBeVisible();
|
||||
}
|
||||
|
||||
/** The invisible whole-card open button for a document titled `title`. */
|
||||
function openButton(page: Page, title: string) {
|
||||
return page.getByRole("button", { name: `Open ${title}` }).first();
|
||||
}
|
||||
|
||||
/** The card checkbox for a document titled `title`. */
|
||||
function selectBox(page: Page, title: string) {
|
||||
return page.getByRole("checkbox", { name: `Select ${title}` }).first();
|
||||
}
|
||||
|
||||
/** Upload `bytes` through the picker input and return when queued. */
|
||||
async function uploadViaPicker(
|
||||
page: Page,
|
||||
name: string,
|
||||
bytes: Buffer,
|
||||
mimeType = "application/pdf",
|
||||
): Promise<void> {
|
||||
await page
|
||||
.locator('[data-slot="document-upload-zone"] input[type="file"]')
|
||||
.setInputFiles([{ name, mimeType, buffer: bytes }]);
|
||||
}
|
||||
|
||||
function uniquePdf(marker: string): Buffer {
|
||||
return Buffer.from(
|
||||
`%PDF-1.4\n%${marker}-${Date.now()}-${Math.random()}\n1 0 obj<</Type/Catalog>>endobj\n%%EOF\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function runAxe(page: Page) {
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
const blocking = results.violations.filter(
|
||||
(v) => v.impact === "serious" || v.impact === "critical",
|
||||
);
|
||||
if (blocking.length > 0) {
|
||||
// Pretty-print so failures are actionable in CI logs.
|
||||
console.log(
|
||||
"axe violations:\n" +
|
||||
blocking
|
||||
.map(
|
||||
(v) =>
|
||||
` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes
|
||||
.map((n) => n.target.join(" "))
|
||||
.join("\n ")}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
return blocking;
|
||||
}
|
||||
|
||||
test.describe("document vault", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureVaultFixture();
|
||||
});
|
||||
|
||||
// ── §1A — the 30-second doctor flow, three independent routes ─────────
|
||||
|
||||
test("doctor flow A: the type chip reaches the MRT report", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Imaging", exact: true }).click();
|
||||
// Autumn 2025 sits below the newest sections — scroll the window down.
|
||||
await scrollUntilVisible(page, openButton(page, "MRT Knie"));
|
||||
await openButton(page, "MRT Knie").click();
|
||||
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("MRT Knie").first()).toBeVisible();
|
||||
// Inline preview (Class A PDF) mounts against the serve route.
|
||||
await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("doctor flow B: the condition chip reaches the MRT report", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents");
|
||||
await page
|
||||
.locator('[data-slot="document-filter-bar"]')
|
||||
.getByRole("button", { name: "Knie", exact: true })
|
||||
.click();
|
||||
|
||||
// The Knie filter narrows the corpus to the linked MRT trio.
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await expect(openButton(page, "MRT Aufnahme 1")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("doctor flow C: text search reaches the MRT report", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents");
|
||||
await page.getByRole("searchbox", { name: "Search documents" }).fill("MRT");
|
||||
// 200 ms debounce → URL → refetch.
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page).toHaveURL(/q=MRT/);
|
||||
});
|
||||
|
||||
// ── URL deep links ─────────────────────────────────────────────────────
|
||||
|
||||
test("deep links land filtered: ?kind, ?year, ?episode, ?doc", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents?kind=IMAGING&year=2025");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Imaging", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "2025", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await scrollUntilVisible(page, openButton(page, "MRT Knie"));
|
||||
|
||||
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}`);
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await expect(openButton(page, "MRT Aufnahme 2")).toBeVisible();
|
||||
|
||||
// `?doc=` opens the detail sheet; closing it strips the param.
|
||||
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&doc=${MRT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("MRT Knie").first()).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(sheet).not.toBeVisible();
|
||||
await expect(page).not.toHaveURL(/doc=/);
|
||||
});
|
||||
|
||||
test("the condition page links into the vault and back", async ({ page }) => {
|
||||
await page.goto(`/illness/${KNIE_EPISODE_ID}`);
|
||||
const card = page.locator('[data-slot="episode-documents-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await card.getByRole("link", { name: /MRT Knie/ }).click();
|
||||
// Lands on the vault, episode-filtered, with the detail sheet open.
|
||||
await expect(page).toHaveURL(/\/documents\?episode=/);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Delete → undo ──────────────────────────────────────────────────────
|
||||
|
||||
test("delete then undo restores the document", async ({ page }, testInfo) => {
|
||||
// Unique per run — a stable name would leave a same-named twin from a
|
||||
// previous run in the corpus and break the disappearance assertion.
|
||||
const title = `undo-probe-${testInfo.project.name}-${Date.now()}`;
|
||||
await page.goto("/documents");
|
||||
await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title));
|
||||
await page.getByRole("searchbox", { name: "Search documents" }).fill(title);
|
||||
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
|
||||
|
||||
await openButton(page, `${title}.pdf`).click();
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
await sheet.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
// Undo toast → restore → the card returns.
|
||||
await expect(page.getByText("Document deleted.")).toBeVisible();
|
||||
await expect(openButton(page, `${title}.pdf`)).not.toBeVisible();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Bulk flows (desktop-only: shift-click ranges are a pointer flow) ──
|
||||
|
||||
test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => {
|
||||
test.skip(isMobile, "range selection is a desktop pointer flow");
|
||||
test.setTimeout(90_000);
|
||||
await seedNamespaceDocs("bulkfifty", 50);
|
||||
|
||||
// Shared filing date → the namespace orders by id desc: "050" first,
|
||||
// "001" last. Anchor on the first card, shift-click the last.
|
||||
await page.goto("/documents?q=bulkfifty");
|
||||
await expect(openButton(page, "bulkfifty 050")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await selectBox(page, "bulkfifty 050").click();
|
||||
await scrollUntilVisible(page, openButton(page, "bulkfifty 001"));
|
||||
await selectBox(page, "bulkfifty 001").click({ modifiers: ["Shift"] });
|
||||
|
||||
const bar = page.locator('[data-slot="document-bulk-bar"]');
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(bar.getByText("50 selected")).toBeVisible();
|
||||
|
||||
await bar.getByRole("button", { name: "Change type" }).click();
|
||||
await page.getByRole("menuitem", { name: "Referral" }).click();
|
||||
await expect(page.getByText("50 document(s) updated")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Server state: all 50 now carry the new kind.
|
||||
const res = await page.request.get(
|
||||
"/api/documents/inbound?q=bulkfifty&kind=REFERRAL&limit=100",
|
||||
);
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.documents).toHaveLength(50);
|
||||
});
|
||||
|
||||
test("bulk: a partial failure surfaces per-id results", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "bulk selection is a desktop pointer flow");
|
||||
await seedNamespaceDocs("bulkpf", 5);
|
||||
|
||||
await page.goto("/documents?q=bulkpf");
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const box = selectBox(page, `bulkpf 00${i}`);
|
||||
await scrollUntilVisible(page, box);
|
||||
await box.click();
|
||||
}
|
||||
await expect(page.getByText("5 selected")).toBeVisible();
|
||||
|
||||
// Kill one of the five behind the selection's back (tombstone), so the
|
||||
// bulk call reports exactly one per-id failure.
|
||||
const list = await page.request.get(
|
||||
"/api/documents/inbound?q=bulkpf&limit=10",
|
||||
);
|
||||
const victim = (await list.json()).data.documents.find(
|
||||
(d: { title: string }) => d.title === "bulkpf 003",
|
||||
);
|
||||
const del = await page.request.delete(
|
||||
`/api/documents/inbound/${victim.id}`,
|
||||
);
|
||||
expect(del.status()).toBe(200);
|
||||
|
||||
const bar = page.locator('[data-slot="document-bulk-bar"]');
|
||||
await bar.getByRole("button", { name: "Change type" }).click();
|
||||
await page.getByRole("menuitem", { name: "Insurance" }).click();
|
||||
await expect(page.getByText("1 of 5 could not be updated")).toBeVisible();
|
||||
});
|
||||
|
||||
test("bulk: a selection above 100 chunks into two requests", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "range selection is a desktop pointer flow");
|
||||
test.setTimeout(120_000);
|
||||
await seedNamespaceDocs("bulkchunk", 101);
|
||||
|
||||
// Newest-first by id tiebreak: "101" renders first, "001" last. Walk
|
||||
// the infinite query to the end (pages of 50) so the range can span
|
||||
// all 101, then anchor + shift-click.
|
||||
await page.goto("/documents?q=bulkchunk");
|
||||
await expect(openButton(page, "bulkchunk 101")).toBeVisible();
|
||||
await selectBox(page, "bulkchunk 101").click();
|
||||
await scrollUntilVisible(page, openButton(page, "bulkchunk 001"), 80);
|
||||
await selectBox(page, "bulkchunk 001").click({ modifiers: ["Shift"] });
|
||||
await expect(page.getByText("101 selected")).toBeVisible();
|
||||
|
||||
let bulkPosts = 0;
|
||||
page.on("request", (req) => {
|
||||
if (
|
||||
req.method() === "POST" &&
|
||||
req.url().includes("/api/documents/inbound/bulk")
|
||||
) {
|
||||
bulkPosts += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const bar = page.locator('[data-slot="document-bulk-bar"]');
|
||||
await bar.getByRole("button", { name: "Change type" }).click();
|
||||
await page.getByRole("menuitem", { name: "Vaccination" }).click();
|
||||
await expect(page.getByText("101 document(s) updated")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(bulkPosts).toBe(2);
|
||||
});
|
||||
|
||||
// ── Keyboard-only doctor flow ──────────────────────────────────────────
|
||||
|
||||
test("keyboard-only: search, roving grid, Enter opens, Escape closes", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "keyboard navigation is a desktop flow");
|
||||
await page.goto("/documents");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
|
||||
// `/` focuses the search from anywhere.
|
||||
await page.keyboard.press("/");
|
||||
await expect(
|
||||
page.getByRole("searchbox", { name: "Search documents" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.type("MRT");
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
|
||||
// Tab from the search into the grid's single roving slot (bounded walk
|
||||
// across the chip rail), then arrows move the active card.
|
||||
let reachedGrid = false;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const slot = await page.evaluate(
|
||||
() => document.activeElement?.getAttribute("data-slot") ?? "",
|
||||
);
|
||||
if (slot === "document-open") {
|
||||
reachedGrid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(reachedGrid).toBe(true);
|
||||
|
||||
// Arrow keys move the roving slot; the focus lands asynchronously
|
||||
// (the target row may mount on the next virtualizer paint) — poll.
|
||||
const activeLabel = () =>
|
||||
page.evaluate(
|
||||
() => document.activeElement?.getAttribute("aria-label") ?? "",
|
||||
);
|
||||
const first = await activeLabel();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect.poll(activeLabel).not.toBe(first);
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect.poll(activeLabel).toBe(first);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
// Wait for the preview to finish loading — the sheet hands focus back
|
||||
// to the dialog after Chromium's PDF viewer grabs it, so Escape works
|
||||
// regardless of when the user presses it.
|
||||
await expect(dialog.locator("iframe")).toBeVisible();
|
||||
await page.waitForTimeout(250);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ── Touch: long-press selects (mobile project only) ────────────────────
|
||||
|
||||
test("long-press selects a card on touch", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "long-press is the touch selection gesture");
|
||||
await page.goto("/documents?q=MRT");
|
||||
const button = openButton(page, "MRT Knie");
|
||||
await expect(button).toBeVisible();
|
||||
|
||||
await button.dispatchEvent("touchstart");
|
||||
await page.waitForTimeout(700);
|
||||
await button.dispatchEvent("touchend");
|
||||
|
||||
await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible();
|
||||
await expect(page.getByText("1 selected")).toBeVisible();
|
||||
});
|
||||
|
||||
// ── Page-wide drop + clipboard paste intake ────────────────────────────
|
||||
|
||||
test("dropping a file anywhere on the page uploads it", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "OS file drag is a desktop gesture");
|
||||
await page.goto("/documents");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
|
||||
const marker = `drop-probe-${Date.now()}`;
|
||||
const dataTransfer = await page.evaluateHandle(
|
||||
([name, content]) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(
|
||||
new File([content], `${name}.pdf`, { type: "application/pdf" }),
|
||||
);
|
||||
return dt;
|
||||
},
|
||||
[marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const,
|
||||
);
|
||||
|
||||
await page.dispatchEvent("body", "dragenter", { dataTransfer });
|
||||
await expect(
|
||||
page.locator('[data-slot="document-drop-overlay"]'),
|
||||
).toBeVisible();
|
||||
await page.dispatchEvent("body", "drop", { dataTransfer });
|
||||
await expect(
|
||||
page.locator('[data-slot="document-drop-overlay"]'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// The stored card lands in the timeline once the upload settles.
|
||||
await page
|
||||
.getByRole("searchbox", { name: "Search documents" })
|
||||
.fill(marker);
|
||||
await expect(openButton(page, `${marker}.pdf`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("pasting a file from the clipboard uploads it", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "clipboard file paste is a desktop gesture");
|
||||
await page.goto("/documents");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
|
||||
const marker = `paste-probe-${Date.now()}`;
|
||||
await page.evaluate(
|
||||
([name, content]) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(
|
||||
new File([content], `${name}.pdf`, { type: "application/pdf" }),
|
||||
);
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
[marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("searchbox", { name: "Search documents" })
|
||||
.fill(marker);
|
||||
await expect(openButton(page, `${marker}.pdf`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("a duplicate upload highlights the existing row instead of storing twice", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "runs once on the desktop project (shared bucket)");
|
||||
await page.goto("/documents");
|
||||
const marker = `dup-probe-${Date.now()}`;
|
||||
const bytes = uniquePdf(marker);
|
||||
|
||||
await uploadViaPicker(page, `${marker}.pdf`, bytes);
|
||||
await page
|
||||
.getByRole("searchbox", { name: "Search documents" })
|
||||
.fill(marker);
|
||||
await expect(openButton(page, `${marker}.pdf`)).toBeVisible();
|
||||
|
||||
await uploadViaPicker(page, `${marker}-copy.pdf`, bytes);
|
||||
await expect(
|
||||
page.getByText("Already stored — highlighting the existing document."),
|
||||
).toBeVisible();
|
||||
// One row, not two — the copy never landed.
|
||||
await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ── Admin cap: commit-on-Enter + rejection copy naming the limit ──────
|
||||
|
||||
test("a 1 MB admin cap rejects an oversized upload with the limit in the copy", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "admin surface is a desktop flow");
|
||||
try {
|
||||
await page.goto("/admin/general");
|
||||
const capInput = page.getByRole("spinbutton", {
|
||||
name: "Document upload limit",
|
||||
});
|
||||
await expect(capInput).toBeEnabled();
|
||||
await capInput.fill("1");
|
||||
const saved = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/api/admin/settings") &&
|
||||
res.request().method() === "PUT",
|
||||
);
|
||||
await capInput.press("Enter");
|
||||
expect((await saved).status()).toBe(200);
|
||||
|
||||
await page.goto("/documents");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
// ~1.2 MB — over the 1 MB cap, far under the default.
|
||||
const big = Buffer.concat([
|
||||
Buffer.from("%PDF-1.4\n"),
|
||||
Buffer.alloc(1_200_000, 0x20),
|
||||
]);
|
||||
await uploadViaPicker(page, "oversized-scan.pdf", big);
|
||||
await expect(
|
||||
page.getByText("This file is larger than the 1 MB limit."),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
const restore = await page.request.put("/api/admin/settings", {
|
||||
data: { documentMaxFileBytes: DEFAULT_MAX_FILE_BYTES },
|
||||
});
|
||||
expect(restore.status()).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Accessibility ──────────────────────────────────────────────────────
|
||||
|
||||
test("axe: the vault timeline has no serious or critical violations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents");
|
||||
await expect(
|
||||
page.locator('[data-slot="document-card"]').first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
const blocking = await runAxe(page);
|
||||
expect(blocking).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("axe: the open detail sheet has no serious or critical violations", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "scanned once on the desktop project");
|
||||
await page.goto(`/documents?doc=${MRT_DOC_ID}`);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
const blocking = await runAxe(page);
|
||||
expect(blocking).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("axe: bulk mode has no serious or critical violations", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "scanned once on the desktop project");
|
||||
await page.goto("/documents?q=MRT");
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await selectBox(page, "MRT Knie").click();
|
||||
await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible();
|
||||
const blocking = await runAxe(page);
|
||||
expect(blocking).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Document-vault e2e fixture — seeded straight through Postgres (`pg`, the
|
||||
* same transport `global-setup.ts` uses) because the doctor-flow script
|
||||
* needs 60 documents and the upload endpoint's per-user rate bucket is
|
||||
* 60/hour: seeding through the API would burn the whole budget before the
|
||||
* first genuine upload test runs, and the two Playwright projects (desktop
|
||||
* + mobile) each re-run the seed.
|
||||
*
|
||||
* The seed is deterministic and idempotent:
|
||||
* - fixed row ids (`e2evault…`), upserted with `ON CONFLICT (id) DO
|
||||
* UPDATE` so a re-run self-heals whatever a previous run mutated;
|
||||
* - per-document unique plaintext (an id marker inside the bytes) so the
|
||||
* `(user_id, content_sha256)` live-dedupe partial unique index never
|
||||
* trips across rows;
|
||||
* - blobs encrypted with the SAME binary2 layout the server writes
|
||||
* (`[0x02][keyIdLen][keyId][iv 12][tag 16][ciphertext]`, AES-256-GCM
|
||||
* under the env key), so the serve route decrypts and previews them
|
||||
* like real uploads.
|
||||
*
|
||||
* Corpus (mirrors the plan's §1A doctor-flow seed): 60 documents across
|
||||
* 3 years, one IllnessEpisode "Knie", an MRI report (IMAGING, "MRT Knie",
|
||||
* last autumn, linked to Knie) plus two adjacent image documents on the
|
||||
* 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 pg from "pg";
|
||||
|
||||
import { E2E_USER } from "./global-setup";
|
||||
|
||||
// ─── binary2 codec (mirrors src/lib/crypto.ts encryptBytes) ────────────────
|
||||
|
||||
function resolveActiveKey(): { id: string; key: Buffer } {
|
||||
const mapRaw = process.env.ENCRYPTION_KEYS;
|
||||
if (mapRaw) {
|
||||
const map = JSON.parse(mapRaw) as Record<string, string>;
|
||||
const id =
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID ?? Object.keys(map)[0] ?? "v1";
|
||||
const hex = map[id];
|
||||
if (!hex) {
|
||||
throw new Error(`[vault-fixture] key id '${id}' not in ENCRYPTION_KEYS`);
|
||||
}
|
||||
return { id, key: Buffer.from(hex, "hex") };
|
||||
}
|
||||
const legacy = process.env.ENCRYPTION_KEY;
|
||||
if (!legacy) {
|
||||
throw new Error(
|
||||
"[vault-fixture] neither ENCRYPTION_KEYS nor ENCRYPTION_KEY is set",
|
||||
);
|
||||
}
|
||||
return { id: "v1", key: Buffer.from(legacy, "hex") };
|
||||
}
|
||||
|
||||
function encryptBinary2(plaintext: Buffer): Buffer {
|
||||
const { id, key } = resolveActiveKey();
|
||||
const keyId = Buffer.from(id, "ascii");
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x02, keyId.byteLength]),
|
||||
keyId,
|
||||
iv,
|
||||
tag,
|
||||
ct,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Deterministic tiny documents ───────────────────────────────────────────
|
||||
|
||||
/** Minimal single-page PDF; `marker` lands in a comment so every doc's bytes
|
||||
* (and therefore sha256) are unique. */
|
||||
function tinyPdf(marker: string): Buffer {
|
||||
return Buffer.from(
|
||||
`%PDF-1.4\n%${marker}\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n` +
|
||||
`2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n` +
|
||||
`3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n` +
|
||||
`xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\n%%EOF\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/** 1×1 PNG + a trailing marker (decoders ignore bytes past IEND). */
|
||||
function tinyPng(marker: string): Buffer {
|
||||
return Buffer.concat([PNG_1X1, Buffer.from(marker, "utf8")]);
|
||||
}
|
||||
|
||||
// ─── Fixture identity ───────────────────────────────────────────────────────
|
||||
|
||||
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
|
||||
export const MRT_DOC_ID = "e2evaultmrt0000000000001";
|
||||
/** Filing date of the MRT report — "last autumn" relative to the fixture. */
|
||||
const MRT_DOC_DATE = "2025-10-14";
|
||||
|
||||
const FIXTURE_KINDS = [
|
||||
"DOCTOR_REPORT",
|
||||
"LAB_RESULT",
|
||||
"PRESCRIPTION",
|
||||
"IMAGING",
|
||||
"OTHER",
|
||||
"DISCHARGE_LETTER",
|
||||
"INSURANCE",
|
||||
] as const;
|
||||
|
||||
interface DocRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
documentDate: string; // YYYY-MM-DD
|
||||
bytes: Buffer;
|
||||
}
|
||||
|
||||
async function getUserId(pool: pg.Pool): Promise<string> {
|
||||
const res = await pool.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
[E2E_USER.username],
|
||||
);
|
||||
const id = res.rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
"[vault-fixture] e2e user not seeded — global-setup must run first",
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function upsertDocs(
|
||||
pool: pg.Pool,
|
||||
userId: string,
|
||||
docs: DocRow[],
|
||||
): Promise<void> {
|
||||
for (const doc of docs) {
|
||||
const encrypted = encryptBinary2(doc.bytes);
|
||||
const sha = createHash("sha256").update(doc.bytes).digest("hex");
|
||||
await pool.query(
|
||||
`INSERT INTO inbound_documents
|
||||
(id, user_id, kind, title, filename, mime_type, byte_size,
|
||||
content_encrypted, content_codec, content_sha256, status,
|
||||
document_date, created_at, updated_at, deleted_at)
|
||||
VALUES ($1, $2, $3::inbound_document_kind, $4, $5, $6, $7,
|
||||
$8, 'binary2', $9, 'STORED',
|
||||
$10::timestamp, NOW(), NOW(), NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
kind = EXCLUDED.kind,
|
||||
title = EXCLUDED.title,
|
||||
document_date = EXCLUDED.document_date,
|
||||
status = 'STORED',
|
||||
deleted_at = NULL,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
doc.id,
|
||||
userId,
|
||||
doc.kind,
|
||||
doc.title,
|
||||
doc.filename,
|
||||
doc.mimeType,
|
||||
doc.bytes.byteLength,
|
||||
encrypted,
|
||||
sha,
|
||||
`${doc.documentDate}T00:00:00Z`,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* upserts.
|
||||
*/
|
||||
export async function ensureVaultFixture(): 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);
|
||||
|
||||
// Opt-in module: merge, never overwrite other module preferences.
|
||||
await pool.query(
|
||||
`UPDATE users
|
||||
SET module_preferences_json =
|
||||
COALESCE(module_preferences_json, '{}'::jsonb)
|
||||
|| '{"inboundDocuments": true}'::jsonb
|
||||
WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
// The "Knie" condition episode the MRT report files under.
|
||||
await pool.query(
|
||||
`INSERT INTO illness_episodes
|
||||
(id, user_id, label, type, lifecycle, onset_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`,
|
||||
[KNIE_EPISODE_ID, userId],
|
||||
);
|
||||
|
||||
// 60 background documents spread over 36 months (newest ~current).
|
||||
const docs: DocRow[] = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const monthsBack = Math.floor((i * 36) / 60);
|
||||
const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25)));
|
||||
const id = `e2evaultdoc${String(i).padStart(12, "0")}`;
|
||||
docs.push({
|
||||
id,
|
||||
kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length],
|
||||
title: `Befund ${String(i + 1).padStart(2, "0")}`,
|
||||
filename: `befund-${i + 1}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
documentDate: anchor.toISOString().slice(0, 10),
|
||||
bytes: tinyPdf(id),
|
||||
});
|
||||
}
|
||||
// The MRT report + two adjacent images, same date, all linked to Knie.
|
||||
docs.push({
|
||||
id: MRT_DOC_ID,
|
||||
kind: "IMAGING",
|
||||
title: "MRT Knie",
|
||||
filename: "mrt-knie.pdf",
|
||||
mimeType: "application/pdf",
|
||||
documentDate: MRT_DOC_DATE,
|
||||
bytes: tinyPdf(MRT_DOC_ID),
|
||||
});
|
||||
for (const n of [1, 2]) {
|
||||
docs.push({
|
||||
id: `e2evaultimg000000000000${n}`,
|
||||
kind: "IMAGING",
|
||||
title: `MRT Aufnahme ${n}`,
|
||||
filename: `mrt-aufnahme-${n}.png`,
|
||||
mimeType: "image/png",
|
||||
documentDate: MRT_DOC_DATE,
|
||||
bytes: tinyPng(`e2evaultimg${n}`),
|
||||
});
|
||||
}
|
||||
await upsertDocs(pool, userId, docs);
|
||||
|
||||
// Condition links for the three MRT-day documents.
|
||||
const linked = [
|
||||
MRT_DOC_ID,
|
||||
"e2evaultimg0000000000001",
|
||||
"e2evaultimg0000000000002",
|
||||
];
|
||||
for (const [i, docId] of linked.entries()) {
|
||||
await pool.query(
|
||||
`INSERT INTO document_condition_links
|
||||
(id, document_id, episode_id, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (document_id, episode_id) DO NOTHING`,
|
||||
[`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId],
|
||||
);
|
||||
}
|
||||
} 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
|
||||
* isolates exactly this namespace). Idempotent; re-runs reset kind/date and
|
||||
* clear tombstones so a previous run's mutations never leak into this one.
|
||||
*/
|
||||
export async function seedNamespaceDocs(
|
||||
prefix: string,
|
||||
count: number,
|
||||
): 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);
|
||||
const docs: DocRow[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = `e2e${prefix}${String(i).padStart(8, "0")}`;
|
||||
docs.push({
|
||||
id,
|
||||
kind: "OTHER",
|
||||
title: `${prefix} ${String(i + 1).padStart(3, "0")}`,
|
||||
filename: `${prefix}-${i + 1}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
// One shared filing date: the timeline then orders the namespace by
|
||||
// the id tiebreak (desc), so `${prefix} <count>` renders first and
|
||||
// `${prefix} 001` last — a deterministic range-selection span.
|
||||
documentDate: "2026-05-01",
|
||||
bytes: tinyPdf(id),
|
||||
});
|
||||
}
|
||||
await upsertDocs(pool, userId, docs);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user