mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
252 lines
8.9 KiB
TypeScript
252 lines
8.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
/**
|
|
* The auto-summary background job's gating. Pins: the opt-in (`documentsAutoAiRead`)
|
|
* is the trigger — OFF → strictly no summary (no provider call, no write); ON +
|
|
* a configured provider → the summary is generated once and persisted ENCRYPTED
|
|
* (write scoped to `summaryEncrypted: null` so a re-run is a no-op); an already-
|
|
* summarised document short-circuits before the opt-in is even read; no provider
|
|
* configured → a graceful no-op; a provider error refunds the budget and persists
|
|
* nothing.
|
|
*/
|
|
|
|
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
inboundDocument: {
|
|
findFirst: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
// The job reads the owner's locale to pick the outbound screen's banks.
|
|
user: { findUnique: vi.fn() },
|
|
},
|
|
}));
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
|
vi.mock("@/lib/documents/document-settings", () => ({
|
|
documentAutoReadEnabled: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/documents/provider-order", () => ({
|
|
resolveDocumentVisionProvider: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/documents/ai-route-support", () => ({
|
|
loadOwnedDocument: vi.fn(),
|
|
prepareVisionInput: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/documents/describe", () => ({
|
|
runDocumentSummary: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/documents/store", () => ({
|
|
encryptDocumentSummary: vi.fn(() => new Uint8Array([9, 9, 9])),
|
|
}));
|
|
vi.mock("@/lib/ai/consent-guard", () => {
|
|
class ConsentRequiredError extends Error {}
|
|
return {
|
|
ConsentRequiredError,
|
|
assertDocumentEgressConsent: vi.fn(),
|
|
};
|
|
});
|
|
vi.mock("@/lib/ai/coach/budget", () => ({
|
|
buildDateKey: vi.fn(() => "2026-07-17"),
|
|
reserveBudget: vi.fn(),
|
|
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
|
resolveDailyCap: vi.fn(() => 1000),
|
|
}));
|
|
vi.mock("@/lib/ai/ai-budgets", () => ({
|
|
AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 } },
|
|
}));
|
|
|
|
import { runDocumentSummaryJob } from "../document-summary";
|
|
import { prisma } from "@/lib/db";
|
|
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
|
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
|
import {
|
|
loadOwnedDocument,
|
|
prepareVisionInput,
|
|
} from "@/lib/documents/ai-route-support";
|
|
import { runDocumentSummary } from "@/lib/documents/describe";
|
|
import { encryptDocumentSummary } from "@/lib/documents/store";
|
|
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
|
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
|
|
|
const DOC = {
|
|
id: "doc-1",
|
|
kind: "OTHER",
|
|
contentEncrypted: new Uint8Array([1, 2, 3]),
|
|
contentCodec: "binary2",
|
|
mimeType: "application/pdf",
|
|
status: "STORED",
|
|
};
|
|
|
|
/** An EXTERNAL (Anthropic) document pick — egress, so opt-in-gated. */
|
|
const PICK = {
|
|
chain: [{ providerType: "anthropic", instance: {} }],
|
|
pick: {
|
|
entry: { providerType: "anthropic", instance: {} },
|
|
providerType: "anthropic",
|
|
pdfSupported: true,
|
|
},
|
|
};
|
|
|
|
function visionOk() {
|
|
vi.mocked(prepareVisionInput).mockResolvedValue({
|
|
ok: true,
|
|
images: [],
|
|
documents: [{ mediaType: "application/pdf", dataBase64: "AA==" }],
|
|
} as never);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Default: a live, not-yet-summarised document.
|
|
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
|
id: "doc-1",
|
|
summaryEncrypted: null,
|
|
} as never);
|
|
vi.mocked(prisma.inboundDocument.updateMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
locale: "en",
|
|
} as never);
|
|
vi.mocked(documentAutoReadEnabled).mockResolvedValue(true);
|
|
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
|
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
|
|
vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never);
|
|
visionOk();
|
|
vi.mocked(runDocumentSummary).mockResolvedValue({
|
|
summary: "A lab report from a clinic listing routine blood values.",
|
|
blocked: null,
|
|
} as never);
|
|
vi.mocked(reserveBudget).mockResolvedValue({
|
|
allowed: true,
|
|
reserved: 5,
|
|
} as never);
|
|
});
|
|
|
|
describe("runDocumentSummaryJob — gating", () => {
|
|
it("does NOTHING when the opt-in is OFF (no provider call, no write)", async () => {
|
|
vi.mocked(documentAutoReadEnabled).mockResolvedValue(false);
|
|
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
|
|
expect(runDocumentSummary).not.toHaveBeenCalled();
|
|
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("generates and PERSISTS the summary encrypted when opt-in is ON + a provider is configured", async () => {
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
expect(runDocumentSummary).toHaveBeenCalledTimes(1);
|
|
expect(encryptDocumentSummary).toHaveBeenCalledWith(
|
|
"A lab report from a clinic listing routine blood values.",
|
|
);
|
|
// Budget reserved then reconciled at the full reserved amount (charged).
|
|
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 5, 5, "2026-07-17");
|
|
// Write is scoped to the still-null column so a re-run cannot clobber it.
|
|
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: "doc-1",
|
|
userId: "user-1",
|
|
summaryEncrypted: null,
|
|
}),
|
|
data: expect.objectContaining({
|
|
summaryEncrypted: expect.any(Uint8Array),
|
|
summaryGeneratedAt: expect.any(Date),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("generates the background summary in the owner's selected locale", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
locale: "de",
|
|
} as never);
|
|
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
expect(runDocumentSummary).toHaveBeenCalledWith(
|
|
expect.objectContaining({ locale: "de" }),
|
|
);
|
|
});
|
|
|
|
it("SKIPS a document that already has a summary (before reading the opt-in)", async () => {
|
|
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
|
id: "doc-1",
|
|
summaryEncrypted: new Uint8Array([1]),
|
|
} as never);
|
|
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
expect(documentAutoReadEnabled).not.toHaveBeenCalled();
|
|
expect(runDocumentSummary).not.toHaveBeenCalled();
|
|
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("is a graceful no-op when NO provider is configured", async () => {
|
|
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
|
chain: [],
|
|
pick: null,
|
|
} as never);
|
|
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
expect(reserveBudget).not.toHaveBeenCalled();
|
|
expect(runDocumentSummary).not.toHaveBeenCalled();
|
|
// No summary is written — but the attempt is RECORDED. Leaving the row
|
|
// untouched is what made the detail view claim "being generated" forever.
|
|
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }),
|
|
);
|
|
});
|
|
|
|
it("refunds the budget and records UNAVAILABLE when the provider call throws", async () => {
|
|
vi.mocked(runDocumentSummary).mockRejectedValueOnce(new Error("boom"));
|
|
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
// Reservation refunded to zero spend; no summary written, state recorded.
|
|
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 5, 0, "2026-07-17");
|
|
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }),
|
|
);
|
|
});
|
|
|
|
it("records WITHHELD — not silence — when the safety screen blocks the summary", async () => {
|
|
vi.mocked(runDocumentSummary).mockResolvedValueOnce({
|
|
summary: "",
|
|
blocked: "dose_directive",
|
|
} as never);
|
|
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
// The blocked prose NEVER lands. Only the state does, and it is not
|
|
// terminal — the guard refuses to overwrite READY, nothing else.
|
|
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "doc-1",
|
|
userId: "user-1",
|
|
deletedAt: null,
|
|
summaryState: { not: "READY" },
|
|
},
|
|
data: { summaryState: "WITHHELD" },
|
|
});
|
|
const calls = vi.mocked(prisma.inboundDocument.updateMany).mock.calls;
|
|
for (const [arg] of calls) {
|
|
expect(arg.data).not.toHaveProperty("summaryEncrypted");
|
|
}
|
|
});
|
|
|
|
it("marks a stored summary READY so the view serves it from storage", async () => {
|
|
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
|
|
|
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ summaryEncrypted: null }),
|
|
data: expect.objectContaining({ summaryState: "READY" }),
|
|
}),
|
|
);
|
|
});
|
|
});
|