mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user