mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(ai): prove the extraction capabilities against Postgres with a model spy
Each refusal is checked twice, at the response and at a spy standing in for every model call, beside an all-open twin in which the spy is called. Two background cases run the index and summary job handlers directly and show that a job enqueued before document reading was turned off finds no provider when it runs.
This commit is contained in:
1 parent
005f645b8b
commit
660351be3a
1 file changed
+542
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* The extraction group's capabilities, through the real routes and the real
|
||||
* document pick against real Postgres, with a spy standing in for every model
|
||||
* call.
|
||||
*
|
||||
* The unit suites prove each gate is asked. What only this file can prove is
|
||||
* that the answer comes off the rows it should: the operator's switches on
|
||||
* `AppSettings`, the record's module preferences, the receipts on
|
||||
* `ConsentReceipt`, and the provider built from the person's own credential.
|
||||
* Every state is checked twice: the response a client sees, and the spy that
|
||||
* says whether anything reached a model. Each file-level state has an all-open
|
||||
* twin in which the spy IS called, so a spy that could not see a call would
|
||||
* turn those red.
|
||||
*
|
||||
* The two background paths at the end run the job handlers directly. They
|
||||
* prove the wire re-check in the document pick: a job enqueued before the
|
||||
* operator turned document reading off finds no provider when it runs, so
|
||||
* nothing is sent even though the job itself asks nothing of the capability.
|
||||
*
|
||||
* Mutation checks (recorded with the package): deleting the
|
||||
* `requireAiCapability("documentAi"` line from the suggest route leaves the
|
||||
* wire re-check to refuse, so the switch case still answers 403 but through
|
||||
* the pick; deleting the `atTheWire` re-check from `provider-order.ts` turns
|
||||
* both job cases and the receipt cases red; returning `ai_full` from the
|
||||
* auto-read mint turns the receipt-kind case red.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { 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),
|
||||
}));
|
||||
|
||||
// ── The spies: every place a model would be called ─────────────────────────
|
||||
|
||||
vi.mock("@/lib/documents/assist", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/documents/assist")>()),
|
||||
runDocumentAssist: vi.fn(async () => ({
|
||||
title: "Letter",
|
||||
kind: null,
|
||||
documentDate: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
|
||||
runOcrExtraction: vi.fn(async () => ({ rows: [], reportDate: null })),
|
||||
}));
|
||||
vi.mock("@/lib/documents/describe", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/documents/describe")>()),
|
||||
transcribeDocument: vi.fn(async () => ({ text: "Glucose 95 mg/dL" })),
|
||||
runDocumentSummary: vi.fn(async () => ({
|
||||
summary: "A laboratory report.",
|
||||
blocked: false,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/ai/provider-runner")>()),
|
||||
runRawCompletionWithFallback: vi.fn(async () => ({
|
||||
result: {
|
||||
content: JSON.stringify({ name: "Metformin" }),
|
||||
tokensUsed: 20,
|
||||
model: "m",
|
||||
},
|
||||
workingProvider: { providerType: "anthropic" },
|
||||
fallbackHops: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
import { runDocumentAssist } from "@/lib/documents/assist";
|
||||
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import {
|
||||
runDocumentSummary,
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
|
||||
|
||||
const SPIES = [
|
||||
runDocumentAssist,
|
||||
runOcrExtraction,
|
||||
transcribeDocument,
|
||||
runDocumentSummary,
|
||||
runRawCompletionWithFallback,
|
||||
] as const;
|
||||
|
||||
/** A 1x1 PNG, so the vision path has a real image to prepare. */
|
||||
const PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
let counter = 0;
|
||||
|
||||
/** A person on their own Anthropic key: every extraction leaves the machine. */
|
||||
async function makeUser(overrides: Record<string, unknown> = {}) {
|
||||
const suffix = `x-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `extract-${suffix}`,
|
||||
email: `extract-${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: "UTC",
|
||||
locale: "en",
|
||||
onboardingCompletedAt: new Date(),
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-sonnet-4-6",
|
||||
aiAnthropicKeyEncrypted: encrypt("sk-ant-integration-test"),
|
||||
labsLocalOcrEnabled: true,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function grantConsent(userId: string, kind: string) {
|
||||
await getPrismaClient().consentReceipt.create({
|
||||
data: { userId, kind, artefact: "test", signedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function setSwitches(values: Record<string, unknown>) {
|
||||
await getPrismaClient().appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", ...values },
|
||||
update: values,
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function storeDocument(userId: string) {
|
||||
const { encryptDocumentContent } = await import("@/lib/documents/store");
|
||||
const { content, codec } = encryptDocumentContent(PNG);
|
||||
return getPrismaClient().inboundDocument.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "OTHER",
|
||||
filename: "letter.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: PNG.byteLength,
|
||||
status: "STORED",
|
||||
contentEncrypted: content,
|
||||
contentCodec: codec,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function suggestText(documentId: string) {
|
||||
const { POST } = await import(
|
||||
"@/app/api/documents/inbound/[id]/suggest/route"
|
||||
);
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await POST(
|
||||
new NextRequest(
|
||||
`http://localhost/api/documents/inbound/${documentId}/suggest`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "text", text: "Discharge letter" }),
|
||||
},
|
||||
) as never,
|
||||
{ params: Promise.resolve({ id: documentId }) } as never,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
async function labsScanText() {
|
||||
const { POST } = await import("@/app/api/labs/ocr/extract/route");
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/labs/ocr/extract", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "text", text: "Glucose 95 mg/dL" }),
|
||||
}) as never,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
async function extractMedication() {
|
||||
const { POST } = await import("@/app/api/medications/extract/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/medications/extract", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: "Metformin 500 mg twice daily" }),
|
||||
}) as never,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
function expectNoModelCall() {
|
||||
for (const spy of SPIES) {
|
||||
expect(vi.mocked(spy)).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
counter = 0;
|
||||
for (const spy of SPIES) vi.mocked(spy).mockClear();
|
||||
});
|
||||
|
||||
describe("reading a stored document — POST /api/documents/inbound/[id]/suggest", () => {
|
||||
it("reads the document when every layer is open (the spy can see a call)", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(runDocumentAssist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses with the reading-documents code and sends nothing when the operator turned it off", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "documentAi",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("refuses under the master switch too", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({ assistantEnabled: false });
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta?.reason).toBe("operator_disabled");
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("asks for an extraction receipt before the document leaves for the person's own key", async () => {
|
||||
const user = await makeUser();
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "consent.ai.required",
|
||||
capability: "documentAi",
|
||||
reason: "consent_required",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("does not take the AI-analysis receipt as consent to read documents", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_insights_only");
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta?.errorCode).toBe("consent.ai.required");
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("keeps the vault's own code when no provider is configured", async () => {
|
||||
const user = await makeUser({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
});
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
capability: "documentAi",
|
||||
reason: "no_provider",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanning a lab report — POST /api/labs/ocr/extract", () => {
|
||||
it("scans when every layer is open (the spy can see a call)", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status } = await labsScanText();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(runOcrExtraction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops under the reading-documents switch", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await labsScanText();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "labsOcr",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("names the labs module when it is off for the record", async () => {
|
||||
const user = await makeUser({
|
||||
modulePreferencesJson: { labs: false },
|
||||
});
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await labsScanText();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "module.disabled",
|
||||
capability: "labsOcr",
|
||||
reason: "module_disabled",
|
||||
module: "labs",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("puts a lab report under the document consent rule", async () => {
|
||||
const user = await makeUser();
|
||||
// The snapshot-rule receipt that used to cover a scan no longer does: a
|
||||
// lab report is a document.
|
||||
await grantConsent(user.id, "ai_insights_only");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await labsScanText();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta?.errorCode).toBe("consent.ai.required");
|
||||
expectNoModelCall();
|
||||
});
|
||||
});
|
||||
|
||||
describe("turning medication text into a schedule — POST /api/medications/extract", () => {
|
||||
it("extracts when every layer is open (the spy can see a call)", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status } = await extractMedication();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops under the reading-documents switch, whatever the Coach switch says", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({
|
||||
assistantDocumentAiEnabled: false,
|
||||
assistantCoachEnabled: true,
|
||||
});
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await extractMedication();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "medicationExtract",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("keeps 503 for no provider and names it", async () => {
|
||||
const user = await makeUser({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
});
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await extractMedication();
|
||||
|
||||
expect(status).toBe(503);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
capability: "medicationExtract",
|
||||
reason: "no_provider",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
});
|
||||
|
||||
describe("document auto-read mints the extraction receipt", () => {
|
||||
it("writes ai_extraction, not ai_full", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const { PATCH } = await import(
|
||||
"@/app/api/auth/me/documents-auto-ai-read/route"
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
new Request("http://localhost/api/auth/me/documents-auto-ai-read", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentsAutoAiRead: true }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const receipts = await getPrismaClient().consentReceipt.findMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: { kind: true },
|
||||
});
|
||||
expect(receipts.map((r) => r.kind)).toEqual(["ai_extraction"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("background reads stop at the wire", () => {
|
||||
it("indexes through the text layer, not a model, once document reading is off", async () => {
|
||||
const user = await makeUser({ documentsAutoAiRead: true });
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
const { indexDocumentContent } = await import(
|
||||
"@/lib/documents/index-document"
|
||||
);
|
||||
|
||||
// Open: the provider path is taken (the spy can see a call).
|
||||
await indexDocumentContent(user.id, document.id);
|
||||
expect(transcribeDocument).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The operator turns document reading off; the same job runs again.
|
||||
vi.mocked(transcribeDocument).mockClear();
|
||||
await getPrismaClient().documentContentIndex.deleteMany({
|
||||
where: { documentId: document.id },
|
||||
});
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
await indexDocumentContent(user.id, document.id);
|
||||
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("leaves a summary job enqueued before the switch flipped with no provider to call", async () => {
|
||||
const user = await makeUser({ documentsAutoAiRead: true });
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
const { runDocumentSummaryJob } = await import(
|
||||
"@/lib/jobs/document-summary"
|
||||
);
|
||||
|
||||
await runDocumentSummaryJob({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expectNoModelCall();
|
||||
const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { summaryEncrypted: true },
|
||||
});
|
||||
expect(row.summaryEncrypted).toBeNull();
|
||||
});
|
||||
|
||||
it("summarises the same document with the switch on (the spy can see a call)", async () => {
|
||||
const user = await makeUser({ documentsAutoAiRead: true });
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
const { runDocumentSummaryJob } = await import(
|
||||
"@/lib/jobs/document-summary"
|
||||
);
|
||||
|
||||
await runDocumentSummaryJob({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(runDocumentSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user