From b210c743e9aada28617634ffdd869f020fdaa832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 18:29:02 +0200 Subject: [PATCH] test(documents): cover content index, assist/summary/index routes, backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1. Unit tests for the token scheme (whole-word match, stopword drop, de-accent, no plaintext at rest, subkey follows the active key), HKDF subkey derivation, the suggest route (drafts only, writes nothing, provider-gated), the summary route (session-only, budget+audit only), the index route (vision transcribe→upsert, text posts OCR text, provider/local-OCR gated), and the backfill (no-provider/no-consent no-op, indexes, budget-reached resume). --- .../[id]/index/__tests__/route.test.ts | 171 ++++++++++++++++++ .../[id]/suggest/__tests__/route.test.ts | 148 +++++++++++++++ .../[id]/summary/__tests__/route.test.ts | 153 ++++++++++++++++ .../crypto/__tests__/derive-subkey.test.ts | 50 +++++ .../documents/__tests__/content-index.test.ts | 128 +++++++++++++ .../document-content-index-backfill.test.ts | 128 +++++++++++++ 6 files changed, 778 insertions(+) create mode 100644 src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts create mode 100644 src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts create mode 100644 src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts create mode 100644 src/lib/crypto/__tests__/derive-subkey.test.ts create mode 100644 src/lib/documents/__tests__/content-index.test.ts create mode 100644 src/lib/jobs/__tests__/document-content-index-backfill.test.ts diff --git a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts new file mode 100644 index 000000000..e4e446a49 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Content-index route (Document vault P2). Pins: vision path transcribes then + * upserts the index; text path indexes posted OCR text with no provider; + * provider-gated on vision; only the index sibling is written (never facts). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn(), update: vi.fn() }, + extractedFact: { create: vi.fn() }, + documentContentIndex: { upsert: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/documents/store", () => ({ + decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])), +})); +vi.mock("@/lib/labs/ocr-upload", () => ({ + detectOcrMimeType: vi.fn(() => "image/png"), +})); +vi.mock("@/lib/documents/describe", () => ({ + transcribeDocument: vi.fn(), + DocumentDescribeError: class DocumentDescribeError extends Error {}, +})); +vi.mock("@/lib/documents/content-index", () => ({ + upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }), +})); +vi.mock("@/lib/labs/ocr-capability", () => ({ + resolveVisionProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-07"), + reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), + reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 1000), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { transcribeDocument } from "@/lib/documents/describe"; +import { upsertContentIndex } from "@/lib/documents/content-index"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); +const visionReq = (id: string) => + new NextRequest(new URL(`http://localhost/api/documents/inbound/${id}/index`), { + method: "POST", + }); +const textReq = (id: string, text: string) => + new NextRequest(new URL(`http://localhost/api/documents/inbound/${id}/index`), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "text", text }), + }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + kind: "OTHER", + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + mimeType: "image/png", + status: "STORED", + } as never); +}); + +describe("POST /api/documents/inbound/[id]/index", () => { + it("vision: transcribes then upserts the index, never facts", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [{ providerType: "anthropic", instance: {} }], + pick: { + entry: { providerType: "anthropic", instance: {} }, + providerType: "anthropic", + pdfSupported: true, + }, + } as never); + vi.mocked(transcribeDocument).mockResolvedValue({ + text: "hemoglobin 14.2 leukocytes normal", + } as never); + + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toMatchObject({ + documentId: "doc-1", + indexed: true, + tokenCount: 7, + }); + expect(upsertContentIndex).toHaveBeenCalledWith( + expect.objectContaining({ source: "vision", documentId: "doc-1" }), + ); + expect(prisma.extractedFact.create).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + }); + + it("vision: 422 without a provider", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + } as never); + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(422); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }); + + it("text: indexes posted OCR text with no provider egress", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + labsLocalOcrEnabled: true, + } as never); + + const res = await POST( + textReq("doc-1", "cholesterol total 190 mg dl") as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + expect(upsertContentIndex).toHaveBeenCalledWith( + expect.objectContaining({ source: "text-ocr", providerType: null }), + ); + // No provider was resolved on the text path. + expect(resolveVisionProvider).not.toHaveBeenCalled(); + }); + + it("text: 422 when local OCR is not enabled", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + labsLocalOcrEnabled: false, + } as never); + const res = await POST( + textReq("doc-1", "some text") as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(422); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts new file mode 100644 index 000000000..a3fc6963b --- /dev/null +++ b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Filing-metadata assist (Document vault P2). Pins P2-D2: suggestions ONLY — + * the route writes nothing (no fact staging, no status flip, no row update) — + * and 422s cleanly with no provider. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn(), update: vi.fn() }, + extractedFact: { deleteMany: vi.fn(), create: vi.fn(), count: vi.fn() }, + documentContentIndex: { upsert: vi.fn() }, + user: { findUnique: vi.fn() }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/documents/store", () => ({ + decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])), +})); +vi.mock("@/lib/labs/ocr-upload", () => ({ + detectOcrMimeType: vi.fn(() => "image/png"), +})); +vi.mock("@/lib/documents/assist", () => ({ + runDocumentAssist: vi.fn(), + DocumentAssistError: class DocumentAssistError extends Error {}, +})); +vi.mock("@/lib/labs/ocr-capability", () => ({ + resolveVisionProvider: vi.fn(), + resolveTextProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-07"), + reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), + reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 1000), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { runDocumentAssist } from "@/lib/documents/assist"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); +const visionReq = (id: string) => + new NextRequest(new URL(`http://localhost/api/documents/inbound/${id}/suggest`), { + method: "POST", + }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + kind: "OTHER", + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + mimeType: "image/png", + status: "STORED", + } as never); +}); + +describe("POST /api/documents/inbound/[id]/suggest", () => { + it("422s without a vision provider and writes nothing", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + } as never); + + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("documents.inbound.providerUnsupported"); + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + expect(prisma.extractedFact.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("returns drafts and never persists them", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [{ providerType: "anthropic", instance: {} }], + pick: { + entry: { providerType: "anthropic", instance: {} }, + providerType: "anthropic", + pdfSupported: true, + }, + } as never); + vi.mocked(runDocumentAssist).mockResolvedValue({ + title: "City Lab — Blood panel", + kind: "LAB_RESULT", + documentDate: "2026-06-01", + } as never); + + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.suggestions).toEqual({ + title: "City Lab — Blood panel", + kind: "LAB_RESULT", + documentDate: "2026-06-01", + }); + // Writes NOTHING (P2-D2): no fact staging, no status flip, no index write. + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + expect(prisma.extractedFact.deleteMany).not.toHaveBeenCalled(); + expect(prisma.extractedFact.create).not.toHaveBeenCalled(); + expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("404s an unknown document", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never); + const res = await POST(visionReq("nope") as never, ctx("nope") as never); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts new file mode 100644 index 000000000..3a6c1c8d6 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * On-demand summary / extracted text (Document vault P2). Pins P2-D4 / A3: + * session-only — the ONLY persistent side effects are the AI-budget ledger and + * the audit log; no row, index, or fact is written or mutated. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn(), update: vi.fn() }, + extractedFact: { create: vi.fn(), deleteMany: vi.fn() }, + documentContentIndex: { upsert: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/documents/store", () => ({ + decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])), +})); +vi.mock("@/lib/labs/ocr-upload", () => ({ + detectOcrMimeType: vi.fn(() => "image/png"), +})); +vi.mock("@/lib/documents/describe", () => ({ + runDocumentSummary: vi.fn(), + transcribeDocument: vi.fn(), + DocumentDescribeError: class DocumentDescribeError extends Error {}, +})); +vi.mock("@/lib/labs/ocr-capability", () => ({ + resolveVisionProvider: vi.fn(), + resolveTextProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-07"), + reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), + reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 1000), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { auditLog } from "@/lib/auth/audit"; +import { runDocumentSummary, transcribeDocument } from "@/lib/documents/describe"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); +const req = (id: string, mode?: string) => + new NextRequest( + new URL( + `http://localhost/api/documents/inbound/${id}/summary${mode ? `?mode=${mode}` : ""}`, + ), + { method: "POST" }, + ); + +function assertNoPersistence(): void { + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled(); + expect(prisma.extractedFact.create).not.toHaveBeenCalled(); + expect(prisma.extractedFact.deleteMany).not.toHaveBeenCalled(); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + kind: "OTHER", + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + mimeType: "image/png", + status: "STORED", + } as never); + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [{ providerType: "anthropic", instance: {} }], + pick: { + entry: { providerType: "anthropic", instance: {} }, + providerType: "anthropic", + pdfSupported: true, + }, + } as never); +}); + +describe("POST /api/documents/inbound/[id]/summary", () => { + it("mode=summary returns { summary } and persists only budget + audit", async () => { + vi.mocked(runDocumentSummary).mockResolvedValue({ + summary: "A blood panel from a lab.", + } as never); + + const res = await POST(req("doc-1", "summary") as never, ctx("doc-1") as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ summary: "A blood panel from a lab." }); + expect(auditLog).toHaveBeenCalledWith( + "documents.inbound.summary", + expect.anything(), + ); + assertNoPersistence(); + }); + + it("mode=text returns { text } session-only", async () => { + vi.mocked(transcribeDocument).mockResolvedValue({ + text: "raw transcribed body", + } as never); + + const res = await POST(req("doc-1", "text") as never, ctx("doc-1") as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ text: "raw transcribed body" }); + assertNoPersistence(); + }); + + it("422s without a provider", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + } as never); + const res = await POST(req("doc-1", "summary") as never, ctx("doc-1") as never); + expect(res.status).toBe(422); + assertNoPersistence(); + }); +}); diff --git a/src/lib/crypto/__tests__/derive-subkey.test.ts b/src/lib/crypto/__tests__/derive-subkey.test.ts new file mode 100644 index 000000000..efc07efc8 --- /dev/null +++ b/src/lib/crypto/__tests__/derive-subkey.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Buffer } from "node:buffer"; + +/** + * HKDF subkey derivation (P2-D7). The blind content index's HMAC key is derived + * this way; the invariants that matter: deterministic under a fixed active key, + * domain-separated by `info`, and following the active key on rotation. + */ +import { _resetCryptoCacheForTests, deriveSubkey } from "@/lib/crypto"; + +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_V2 = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +function useKey(hexKey: string): void { + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", hexKey); + _resetCryptoCacheForTests(); +} + +beforeEach(() => useKey(KEY_V1)); + +describe("deriveSubkey", () => { + it("returns 32 bytes", () => { + expect(deriveSubkey("healthlog:test").byteLength).toBe(32); + }); + + it("is deterministic under a fixed active key", () => { + expect(deriveSubkey("healthlog:test")).toEqual(deriveSubkey("healthlog:test")); + }); + + it("is domain-separated by info", () => { + expect(deriveSubkey("healthlog:a")).not.toEqual(deriveSubkey("healthlog:b")); + }); + + it("is never the raw master key", () => { + const master = Buffer.from(KEY_V1, "hex"); + expect(deriveSubkey("healthlog:test").equals(master)).toBe(false); + }); + + it("follows the active key on rotation", () => { + const under1 = deriveSubkey("healthlog:test"); + useKey(KEY_V2); + const under2 = deriveSubkey("healthlog:test"); + expect(under2).not.toEqual(under1); + }); +}); diff --git a/src/lib/documents/__tests__/content-index.test.ts b/src/lib/documents/__tests__/content-index.test.ts new file mode 100644 index 000000000..fbcc522e2 --- /dev/null +++ b/src/lib/documents/__tests__/content-index.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Buffer } from "node:buffer"; + +/** + * Blind content-search index (Document vault P2). + * + * Pins the token scheme's correctness (whole-word match, stopword drop, + * de-accent, length gate, cap) AND the security invariant A4: nothing readable + * at rest — the stored text column is ciphertext and the token array is opaque + * HMAC output, not plaintext. + */ + +// The upsert path touches prisma; the pure helpers do not, but the module +// imports @/lib/db at load, so mock it. +vi.mock("@/lib/db", () => ({ + prisma: { documentContentIndex: { upsert: vi.fn() } }, +})); + +import { _resetCryptoCacheForTests } from "@/lib/crypto"; +import { + CONTENT_TOKENIZER_VERSION, + decryptIndexText, + encryptIndexText, + hashQueryTokens, + normaliseIndexText, + tokenise, + tokeniseAndHash, +} from "../content-index"; + +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_V2 = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +function useKey(hexKey: string): void { + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", hexKey); + _resetCryptoCacheForTests(); +} + +beforeEach(() => { + useKey(KEY_V1); +}); + +describe("normaliseIndexText", () => { + it("lowercases and strips diacritics", () => { + expect(normaliseIndexText("Nürnberg ÄÖÜ Café")).toBe("nurnberg aou cafe"); + }); + + it("caps the stored text at the byte budget", () => { + const huge = "a".repeat(200_000); + expect(normaliseIndexText(huge).length).toBeLessThanOrEqual(64 * 1024); + }); +}); + +describe("tokenise", () => { + it("keeps content words, drops stopwords and short tokens", () => { + const tokens = tokenise("The patient has Diabetes and a high value"); + expect(tokens).toContain("patient"); + expect(tokens).toContain("diabetes"); + expect(tokens).toContain("high"); + expect(tokens).toContain("value"); + // stopwords + < 3 chars dropped + expect(tokens).not.toContain("the"); + expect(tokens).not.toContain("and"); + expect(tokens).not.toContain("has"); // length 3 but stopword + }); + + it("de-accents so an accented body word matches its plain query", () => { + expect(tokenise("Röntgenbefund")).toContain("rontgenbefund"); + }); + + it("dedupes repeated tokens", () => { + const tokens = tokenise("cholesterol cholesterol CHOLESTEROL"); + expect(tokens.filter((t) => t === "cholesterol")).toHaveLength(1); + }); +}); + +describe("tokeniseAndHash (blind index)", () => { + it("produces opaque hex tags, never the plaintext token", () => { + const hashes = tokeniseAndHash("hemoglobin"); + expect(hashes).toHaveLength(1); + expect(hashes[0]).toMatch(/^[0-9a-f]{16}$/u); + expect(hashes[0]).not.toContain("hemoglobin"); + }); + + it("is deterministic under a fixed key", () => { + expect(tokeniseAndHash("creatinine")).toEqual(tokeniseAndHash("creatinine")); + }); + + it("a query word hashes to the same tag as the body word (whole-word hit)", () => { + const body = tokeniseAndHash("The full blood count showed leukocytes"); + const query = hashQueryTokens("leukocytes"); + expect(query).toHaveLength(1); + expect(body).toContain(query[0]); + }); + + it("a substring is NOT a whole-word hit (honest limit)", () => { + const body = tokeniseAndHash("leukocytes"); + // "leuko" is a prefix, not a whole token → different tag, no overlap. + const query = hashQueryTokens("leuko"); + expect(body.some((h) => query.includes(h))).toBe(false); + }); + + it("changes when the master key rotates (subkey follows the active key)", () => { + const under1 = tokeniseAndHash("glucose"); + useKey(KEY_V2); + const under2 = tokeniseAndHash("glucose"); + expect(under2).not.toEqual(under1); + }); +}); + +describe("encryptIndexText (no plaintext at rest — A4)", () => { + it("round-trips and never stores the plaintext in the ciphertext bytes", () => { + const text = "confidential diagnosis text"; + const encrypted = encryptIndexText(text); + expect(Buffer.from(encrypted).toString("utf8")).not.toContain(text); + expect(decryptIndexText(encrypted)).toBe(text); + }); +}); + +describe("tokenizer version", () => { + it("is exported for re-index detection", () => { + expect(CONTENT_TOKENIZER_VERSION).toBe("1"); + }); +}); diff --git a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts new file mode 100644 index 000000000..5b2b25820 --- /dev/null +++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Content-index backfill (Document vault P2). Pins: no provider / no consent → + * clean no-op; indexes not-yet-indexed docs; stops when the budget is reached + * (resumable); skips are bounded by the MIME-filtered candidate set. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findMany: vi.fn(), findFirst: vi.fn() }, + documentContentIndex: { upsert: vi.fn() }, + }, +})); +vi.mock("@/lib/documents/store", () => ({ + decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])), +})); +vi.mock("@/lib/labs/ocr-upload", () => ({ + detectOcrMimeType: vi.fn(() => "image/png"), +})); +vi.mock("@/lib/documents/describe", () => ({ + transcribeDocument: vi.fn().mockResolvedValue({ text: "glucose 90" }), + DocumentDescribeError: class DocumentDescribeError extends Error {}, +})); +vi.mock("@/lib/documents/content-index", () => ({ + upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 3 }), +})); +vi.mock("@/lib/labs/ocr-capability", () => ({ + resolveVisionProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn().mockResolvedValue(undefined), + ConsentRequiredError: class ConsentRequiredError extends Error {}, +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-07-07"), + reserveBudget: vi.fn(), + reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 1000), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); + +import { runContentIndexBackfillForUser } from "../document-content-index-backfill"; +import { prisma } from "@/lib/db"; +import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { assertConsentForChain, ConsentRequiredError } from "@/lib/ai/consent-guard"; +import { reserveBudget } from "@/lib/ai/coach/budget"; +import { upsertContentIndex } from "@/lib/documents/content-index"; + +const PICK = { + chain: [{ providerType: "anthropic", instance: {} }], + pick: { + entry: { providerType: "anthropic", instance: {} }, + providerType: "anthropic", + pdfSupported: true, + }, +}; + +const doc = (id: string) => ({ + id, + kind: "OTHER", + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + mimeType: "image/png", + status: "STORED", +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, reserved: 1 } as never); + vi.mocked(prisma.inboundDocument.findFirst).mockImplementation( + async ({ where }: never) => doc((where as { id: string }).id) as never, + ); +}); + +describe("runContentIndexBackfillForUser", () => { + it("no-ops with no provider", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + } as never); + const result = await runContentIndexBackfillForUser("user-1"); + expect(result).toEqual({ indexed: 0, reason: "no-provider" }); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }); + + it("no-ops when consent is missing", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(assertConsentForChain).mockRejectedValueOnce( + new ConsentRequiredError("insights" as never), + ); + const result = await runContentIndexBackfillForUser("user-1"); + expect(result).toEqual({ indexed: 0, reason: "no-consent" }); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }); + + it("indexes the not-yet-indexed documents", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never); + // One short page (< PAGE_SIZE) — the walk breaks after it, so a single + // findMany return is enough (a trailing once would leak to the next test). + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + { id: "d2" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + expect(result).toEqual({ indexed: 2, reason: "ok" }); + expect(upsertContentIndex).toHaveBeenCalledTimes(2); + }); + + it("stops when the daily budget is reached (resumable)", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValueOnce([ + { id: "d1" }, + { id: "d2" }, + ] as never); + // First doc gets budget, second is denied → stop before indexing it. + vi.mocked(reserveBudget) + .mockReset() + .mockResolvedValueOnce({ allowed: true, reserved: 1 } as never) + .mockResolvedValueOnce({ allowed: false, reserved: 0 } as never) + .mockResolvedValue({ allowed: true, reserved: 1 } as never); + + const result = await runContentIndexBackfillForUser("user-1"); + expect(result).toEqual({ indexed: 1, reason: "budget-reached" }); + expect(upsertContentIndex).toHaveBeenCalledTimes(1); + }); +});