From 394828449a64b02ee1b6cd6729983976428c9224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 22:07:28 +0100 Subject: [PATCH] test(documents): read unrenderable PDFs through the routes against Postgres --- .../document-unrenderable-pdf.test.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/integration/document-unrenderable-pdf.test.ts diff --git a/tests/integration/document-unrenderable-pdf.test.ts b/tests/integration/document-unrenderable-pdf.test.ts new file mode 100644 index 000000000..ade4a3f18 --- /dev/null +++ b/tests/integration/document-unrenderable-pdf.test.ts @@ -0,0 +1,230 @@ +/** + * #1311 — a stored PDF on a server whose CPU cannot run the page renderer + * (closed here with `NATIVE_CANVAS=off`), for a provider that only takes + * images, through the real routes against a real Postgres. + * + * What only this file can prove: the route, the provider pick, the stored + * (encrypted) original, the real reader child and the content-index write + * meet as the unit tests assume. A text-layer PDF is summarised from its text + * and indexed as a local text read; a scan answers the honest error and + * records why on the document. + */ +import { Buffer } from "node:buffer"; + +import { jsPDF } from "jspdf"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; + +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), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); +vi.mock("@/lib/documents/describe", async (importOriginal) => ({ + ...(await importOriginal()), + runDocumentSummary: vi.fn(async () => ({ + summary: "A blood count from October.", + blocked: null, + })), +})); +vi.mock("@/lib/documents/auto-stage-labs", () => ({ + maybeAutoStageLabFacts: vi.fn(async () => ({ + staged: false, + reason: "not-lab", + })), +})); + +import { runDocumentSummary } from "@/lib/documents/describe"; +import { __resetNativeCanvasSupportForTests } from "@/lib/documents/native-canvas-support"; + +const LAB_LINE = "Haemoglobin 14.2 g/dl 13.5 - 17.5"; +const PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +function textLayerPdf(): Buffer { + const doc = new jsPDF(); + doc.text(LAB_LINE, 10, 15); + doc.text("Leukocytes 6.1 /nl 4.0 - 10.0", 10, 23); + return Buffer.from(doc.output("arraybuffer")); +} + +function scannedPdf(): Buffer { + const doc = new jsPDF(); + doc.addImage(`data:image/png;base64,${PNG}`, "PNG", 10, 10, 180, 250); + return Buffer.from(doc.output("arraybuffer")); +} + +let counter = 0; + +async function signedInUser() { + const suffix = `u-${counter++}`; + const user = await getPrismaClient().user.create({ + data: { + username: `pdf-${suffix}`, + email: `pdf-${suffix}@example.test`, + role: "USER", + timezone: "UTC", + locale: "en", + onboardingCompletedAt: new Date(), + aiProvider: "OPENAI", + aiModel: "gpt-4o", + aiOpenaiKeyEncrypted: encrypt("sk-test-openai-0001"), + modulePreferencesJson: { inboundDocuments: true, labs: true }, + }, + }); + await getPrismaClient().consentReceipt.create({ + data: { + userId: user.id, + kind: "ai_extraction", + artefact: "test", + signedAt: new Date(), + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +async function storePdf(userId: string, bytes: Buffer) { + const { encryptDocumentContent } = await import("@/lib/documents/store"); + const { content, codec } = encryptDocumentContent(bytes); + return getPrismaClient().inboundDocument.create({ + data: { + userId, + kind: "OTHER", + filename: "report.pdf", + mimeType: "application/pdf", + byteSize: bytes.byteLength, + status: "STORED", + contentEncrypted: content, + contentCodec: codec, + }, + }); +} + +async function post(documentId: string, path: "summary" | "index") { + const route = + path === "summary" + ? await import("@/app/api/documents/inbound/[id]/summary/route") + : await import("@/app/api/documents/inbound/[id]/index/route"); + const { NextRequest } = await import("next/server"); + const res = await route.POST( + new NextRequest( + `http://localhost/api/documents/inbound/${documentId}/${path}`, + { method: "POST" }, + ) as never, + { params: Promise.resolve({ id: documentId }) } as never, + ); + return { + status: res.status, + body: (await res.json()) as { + data: unknown; + error: string | null; + meta?: Record; + }, + }; +} + +beforeAll(() => { + process.env.NATIVE_CANVAS = "off"; + __resetNativeCanvasSupportForTests(); +}); +afterAll(() => { + delete process.env.NATIVE_CANVAS; + __resetNativeCanvasSupportForTests(); +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(runDocumentSummary).mockClear(); +}); + +describe("a PDF on a server that cannot render PDF pages", () => { + it("summarises a text-layer PDF from its text", async () => { + const user = await signedInUser(); + const document = await storePdf(user.id, textLayerPdf()); + + const res = await post(document.id, "summary"); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(runDocumentSummary).toHaveBeenCalledOnce(); + const input = vi.mocked(runDocumentSummary).mock.calls[0][0]; + expect(input.ocrText).toContain(LAB_LINE); + expect(input.images).toBeUndefined(); + expect(input.documents).toBeUndefined(); + }, 60_000); + + it("indexes a text-layer PDF from Read with AI as a local text read", async () => { + const user = await signedInUser(); + const document = await storePdf(user.id, textLayerPdf()); + + const res = await post(document.id, "index"); + expect(res.status, JSON.stringify(res.body)).toBe(200); + const index = await getPrismaClient().documentContentIndex.findUnique({ + where: { documentId: document.id }, + select: { source: true, providerType: true }, + }); + expect(index).toEqual({ source: "local-pdf", providerType: null }); + }, 60_000); + + it("answers a scan with pdfRendererUnavailable and records why", async () => { + const user = await signedInUser(); + const document = await storePdf(user.id, scannedPdf()); + + const summary = await post(document.id, "summary"); + expect(summary.status).toBe(422); + expect(summary.body.meta?.errorCode).toBe( + "documents.inbound.pdfRendererUnavailable", + ); + expect(runDocumentSummary).not.toHaveBeenCalled(); + + const index = await post(document.id, "index"); + expect(index.status).toBe(422); + expect(index.body.meta?.errorCode).toBe( + "documents.inbound.pdfRendererUnavailable", + ); + const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({ + where: { id: document.id }, + select: { lastIndexOutcome: true }, + }); + expect(row.lastIndexOutcome).toBe("pdf-needs-anthropic"); + }, 60_000); +});