test(documents): cover content index, assist/summary/index routes, backfill

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).
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 18:29:02 +02:00
1 parent 5a0fd47eb5
commit b210c743e9
6 files changed
+778

No files matched your search

@@ -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();
});
});
@@ -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);
});
});
@@ -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();
});
});