Merge remote-tracking branch 'origin/main' into wip/v140-I

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 22:16:29 +02:00
commit ef0028c020
45 files changed
+3161 -1010

No files matched your search

@@ -3958,3 +3958,55 @@ describe("the account's own settings survive a real restore", () => {
).toEqual([]);
});
});
describe("a document kind added after the backup format", () => {
it("restores a sick note as a sick note", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const documentBytes = encryptBytes(Buffer.from("sick note fixture"));
const contentEncrypted = new Uint8Array(
new ArrayBuffer(documentBytes.byteLength),
);
contentEncrypted.set(documentBytes);
await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "SICK_NOTE",
title: "Certificate of incapacity",
mimeType: "application/pdf",
byteSize: documentBytes.byteLength,
contentEncrypted,
contentCodec: "binary2",
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.inboundDocument.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(restored.kind).toBe("SICK_NOTE");
expect(restored.title).toBe("Certificate of incapacity");
});
});
+400 -1
View File
@@ -16,6 +16,13 @@
* anything reaches a model.
* 6. The reaper fails a run no worker took (and hands its reservation back)
* and deletes a finished run an hour after it ended.
* 8. A stored-text extraction stages its facts from the worker, and a
* review finished while the read ran is never overwritten: staging
* re-checks the document under a row lock and fails the run instead.
* 7. Summary and suggestion runs (0370) round-trip the routes' own bodies; a
* summary that is to be stored marks the document PENDING while queued,
* READY when it lands and UNAVAILABLE when the run fails; a transient one
* leaves the state alone and never attaches to a stored one.
*/
import { Buffer } from "node:buffer";
@@ -68,6 +75,18 @@ vi.mock("@/lib/documents/describe", async (importOriginal) => ({
transcribeDocument: vi.fn(async () => ({
text: "Entlassbrief Leukozyten erhoeht",
})),
runDocumentSummary: vi.fn(async () => ({
summary: "A hospital discharge letter.",
blocked: null,
})),
}));
vi.mock("@/lib/documents/assist", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/documents/assist")>()),
runDocumentAssist: vi.fn(async () => ({
title: "Discharge letter",
kind: "DISCHARGE_LETTER",
documentDate: "2027-01-10",
})),
}));
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
@@ -77,6 +96,29 @@ vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
rows: [],
})),
}));
vi.mock("@/lib/documents/extract", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/documents/extract")>()),
runInboundExtraction: vi.fn(async () => ({
reportDate: "2027-01-10",
kind: "OTHER",
providerType: "anthropic",
facts: [
{
factType: "OBSERVATION",
confidence: 0.95,
needsReview: false,
data: { label: "Hemoglobin", value: 13.9, unit: "g/dL" },
provenance: {
sourceText: "Hemoglobin 13.9 g/dL",
anchored: true,
sourceOffset: 0,
page: null,
confidence: 0.95,
},
},
],
})),
}));
vi.mock("@/lib/documents/auto-stage-labs", () => ({
maybeAutoStageLabFacts: vi.fn(async () => ({
staged: false,
@@ -84,7 +126,12 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({
})),
}));
import { transcribeDocument } from "@/lib/documents/describe";
import { runDocumentAssist } from "@/lib/documents/assist";
import {
runDocumentSummary,
transcribeDocument,
} from "@/lib/documents/describe";
import { runInboundExtraction } from "@/lib/documents/extract";
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
const PNG = Buffer.from(
@@ -186,6 +233,40 @@ async function postLabScanText(text: string) {
return { status: res.status, body: (await res.json()) as Envelope };
}
async function postDocumentAi(
documentId: string,
path: string,
prefer: boolean,
) {
const route = path.startsWith("summary")
? await import("@/app/api/documents/inbound/[id]/summary/route")
: await import("@/app/api/documents/inbound/[id]/suggest/route");
const { NextRequest } = await import("next/server");
const res = await route.POST(
new NextRequest(
`http://localhost/api/documents/inbound/${documentId}/${path}`,
{
method: "POST",
headers: prefer ? { prefer: "respond-async" } : {},
},
) as never,
{ params: Promise.resolve({ id: documentId }) } as never,
);
return {
status: res.status,
headers: res.headers,
body: (await res.json()) as Envelope,
};
}
async function summaryState(documentId: string) {
const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({
where: { id: documentId },
select: { summaryState: true, summaryEncrypted: true },
});
return { state: row.summaryState, stored: row.summaryEncrypted !== null };
}
async function poll(runId: string) {
const { GET } = await import("@/app/api/ai-runs/[id]/route");
const res = await GET(
@@ -211,6 +292,9 @@ beforeEach(async () => {
send.mockClear();
vi.mocked(transcribeDocument).mockClear();
vi.mocked(runOcrExtraction).mockClear();
vi.mocked(runDocumentSummary).mockClear();
vi.mocked(runDocumentAssist).mockClear();
vi.mocked(runInboundExtraction).mockClear();
});
describe("Read with AI in the background", () => {
@@ -449,3 +533,318 @@ describe("the reaper", () => {
).toBeNull();
});
});
describe("a summary in the background", () => {
it("marks a stored summary PENDING while queued and READY when it lands", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
const queued = await postDocumentAi(
document.id,
"summary?mode=summary&persist=true",
true,
);
expect(queued.status).toBe(202);
expect(queued.headers.get("preference-applied")).toBe("respond-async");
const { runId } = queued.body.data as { runId: string };
expect(runDocumentSummary).not.toHaveBeenCalled();
expect(await summaryState(document.id)).toEqual({
state: "PENDING",
stored: false,
});
// A second press for the same stored summary attaches; a transient
// summary of the same document is its own run.
const again = await postDocumentAi(
document.id,
"summary?mode=summary&persist=true",
true,
);
expect((again.body.data as { runId: string }).runId).toBe(runId);
const transient = await postDocumentAi(
document.id,
"summary?mode=summary",
true,
);
expect(transient.status).toBe(202);
expect((transient.body.data as { runId: string }).runId).not.toBe(runId);
expect(await work(runId)).toBe("succeeded");
expect(runDocumentSummary).toHaveBeenCalledOnce();
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "SUCCEEDED",
kind: "DOCUMENT_SUMMARY",
documentId: document.id,
result: {
summary: "A hospital discharge letter.",
persistence: "stored",
},
});
expect(await summaryState(document.id)).toEqual({
state: "READY",
stored: true,
});
});
it("marks a stored summary UNAVAILABLE when its run fails", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
vi.mocked(runDocumentSummary).mockRejectedValueOnce(
new Error("provider down"),
);
const queued = await postDocumentAi(
document.id,
"summary?mode=summary&persist=true",
true,
);
const { runId } = queued.body.data as { runId: string };
expect(await work(runId)).toBe("failed");
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "FAILED",
error: { code: "documents.inbound.extractFailed", status: 502 },
});
expect(await summaryState(document.id)).toEqual({
state: "UNAVAILABLE",
stored: false,
});
});
it("leaves the document's summary state alone for a transient summary", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
vi.mocked(runDocumentSummary).mockRejectedValueOnce(
new Error("provider down"),
);
const queued = await postDocumentAi(
document.id,
"summary?mode=summary",
true,
);
const { runId } = queued.body.data as { runId: string };
expect(await summaryState(document.id)).toEqual({
state: "NONE",
stored: false,
});
expect(await work(runId)).toBe("failed");
expect(await summaryState(document.id)).toEqual({
state: "NONE",
stored: false,
});
});
it("answers synchronously, exactly as before, without the header", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
const res = await postDocumentAi(document.id, "summary?mode=text", false);
expect(res.status).toBe(200);
expect(res.headers.get("preference-applied")).toBeNull();
expect(res.body.data).toEqual({ text: "Entlassbrief Leukozyten erhoeht" });
expect(send).not.toHaveBeenCalled();
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
});
});
describe("filing suggestions in the background", () => {
it("queues, reads in the worker, writes nothing to the document", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await storeDocument(user.id);
await signIn(user.id);
const queued = await postDocumentAi(document.id, "suggest", true);
expect(queued.status).toBe(202);
const { runId } = queued.body.data as { runId: string };
expect(await work(runId)).toBe("succeeded");
expect(runDocumentAssist).toHaveBeenCalledOnce();
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "SUCCEEDED",
kind: "DOCUMENT_SUGGEST",
result: {
suggestions: {
title: "Discharge letter",
kind: "DISCHARGE_LETTER",
documentDate: "2027-01-10",
},
},
});
const after = await getPrismaClient().inboundDocument.findUniqueOrThrow({
where: { id: document.id },
});
expect(after.kind).toBe("OTHER");
expect(after.title).toBeNull();
});
it("is a 404 for another account's document and its run", async () => {
const owner = await makeUser();
await grantConsent(owner.id);
const document = await storeDocument(owner.id);
await signIn(owner.id);
const queued = await postDocumentAi(document.id, "suggest", true);
const { runId } = queued.body.data as { runId: string };
const other = await makeUser();
await grantConsent(other.id);
await signIn(other.id);
const foreignPost = await postDocumentAi(document.id, "suggest", true);
expect(foreignPost.status).toBe(404);
expect(foreignPost.body.meta?.errorCode).toBe("documents.inbound.notFound");
const foreignPoll = await poll(runId);
expect(foreignPoll.status).toBe(404);
expect(foreignPoll.body.meta?.errorCode).toBe("aiRuns.notFound");
});
});
async function postStoredExtract(documentId: string, prefer: boolean) {
const { POST } =
await import("@/app/api/documents/inbound/[id]/extract/route");
const { NextRequest } = await import("next/server");
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (prefer) headers.prefer = "respond-async";
const res = await POST(
new NextRequest(
`http://localhost/api/documents/inbound/${documentId}/extract`,
{ method: "POST", headers, body: JSON.stringify({ mode: "stored" }) },
) as never,
{ params: Promise.resolve({ id: documentId }) } as never,
);
return { status: res.status, body: (await res.json()) as Envelope };
}
async function indexedDocument(userId: string) {
const document = await storeDocument(userId);
const { upsertContentIndex } = await import("@/lib/documents/content-index");
await upsertContentIndex({
userId,
documentId: document.id,
text: "Hemoglobin 13.9 g/dL reference range 12-16",
source: "text-ocr",
providerType: null,
});
return document;
}
describe("fact extraction in the background", () => {
it("stages the facts in the worker and serves the count", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await indexedDocument(user.id);
await signIn(user.id);
const queued = await postStoredExtract(document.id, true);
expect(queued.status).toBe(202);
const { runId } = queued.body.data as { runId: string };
const stored = await getPrismaClient().documentAiRun.findUniqueOrThrow({
where: { id: runId },
});
// The worker reads the content index itself; nothing is sealed.
expect(stored.inputEncrypted).toBeNull();
expect(runInboundExtraction).not.toHaveBeenCalled();
expect(await work(runId)).toBe("succeeded");
expect(runInboundExtraction).toHaveBeenCalledWith(
expect.objectContaining({
ocrText: expect.stringContaining("Hemoglobin 13.9"),
}),
);
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "SUCCEEDED",
kind: "DOCUMENT_EXTRACT",
result: {
documentId: document.id,
factsStaged: 1,
status: "EXTRACTED",
},
});
const facts = await getPrismaClient().extractedFact.findMany({
where: { documentId: document.id },
});
expect(facts.map((f) => f.status)).toEqual(["PENDING"]);
});
it("never replaces a review that was finished while the read ran", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await indexedDocument(user.id);
await signIn(user.id);
const queued = await postStoredExtract(document.id, true);
const { runId } = queued.body.data as { runId: string };
// The person approves a fact from an earlier staging before the worker
// gets to the run.
const { encryptFactData, encryptFactProvenance } =
await import("@/lib/documents/store");
const approved = await getPrismaClient().extractedFact.create({
data: {
documentId: document.id,
userId: user.id,
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.9,
needsReview: false,
dataEncrypted: encryptFactData({
label: "Glucose",
value: 95,
unit: "mg/dL",
} as never),
provenanceEncrypted: encryptFactProvenance({
sourceText: "Glucose 95",
anchored: true,
sourceOffset: 0,
page: null,
confidence: 0.9,
} as never),
},
});
expect(await work(runId)).toBe("failed");
const done = await poll(runId);
expect(done.body.data).toMatchObject({
status: "FAILED",
error: {
code: "documents.inbound.alreadyPartlyConfirmed",
status: 409,
},
});
const facts = await getPrismaClient().extractedFact.findMany({
where: { documentId: document.id },
});
expect(facts.map((f) => [f.id, f.status])).toEqual([
[approved.id, "APPROVED"],
]);
});
it("answers with the document detail, exactly as before, without the header", async () => {
const user = await makeUser();
await grantConsent(user.id);
const document = await indexedDocument(user.id);
await signIn(user.id);
const res = await postStoredExtract(document.id, false);
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
id: document.id,
status: "EXTRACTED",
});
expect((res.body.data as { facts: unknown[] }).facts).toHaveLength(1);
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
});
});
+39
View File
@@ -735,3 +735,42 @@ describe("document vault — bulk + usage", () => {
]);
});
});
describe("document vault — the sick note kind", () => {
it("is accepted at upload and on edit, and filters like every other kind", async () => {
await seedVaultUser("vault-sick-note");
const { post, get, patch } = await routes();
const uploaded = await post(
uploadRequest(PDF_SMALL, "au-bescheinigung.pdf", { kind: "SICK_NOTE" }),
);
expect(uploaded.status).toBe(201);
expect((await uploaded.json()).data.kind).toBe("SICK_NOTE");
const other = await post(uploadRequest(PNG_1X1, "scan.png"));
expect(other.status).toBe(201);
const otherDoc = (await other.json()).data;
expect(otherDoc.kind).toBe("OTHER");
const edited = await patch(
jsonRequest(
`http://localhost/api/documents/inbound/${otherDoc.id}`,
"PATCH",
{ kind: "SICK_NOTE" },
),
ctx(otherDoc.id),
);
expect(edited.status).toBe(200);
expect((await edited.json()).data.kind).toBe("SICK_NOTE");
const sickNotes = await get(
new Request("http://localhost/api/documents/inbound?kind=SICK_NOTE"),
);
expect(sickNotes.status).toBe(200);
expect((await sickNotes.json()).data.documents).toHaveLength(2);
const others = await get(
new Request("http://localhost/api/documents/inbound?kind=OTHER"),
);
expect((await others.json()).data.documents).toHaveLength(0);
});
});