rag-service/tests/ocr/review.test.ts

185 lines
13 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "../../src/app.js";
import { env } from "../../src/config/env.js";
import { CatalogError } from "../../src/modules/catalog/errors.js";
import { OcrIndexingService, type ApprovedOcrCandidate, type OcrIndexingStore } from "../../src/modules/ocr/indexing.js";
import { OcrReviewService, type OcrReviewCandidate } from "../../src/modules/ocr/review.js";
import { sha256Hex } from "../../src/shared/utils/ids.js";
function candidate(state: OcrReviewCandidate["state"] = "review_required"): OcrReviewCandidate {
const lines = ["CBGO4a", "FATo7"].map((text, index) => ({
lineId: `line-${index + 1}`, text, confidence: 0.7, bbox: [0, index * 10, 50, index * 10 + 8] as [number, number, number, number], lineSha256: sha256Hex(text)
}));
return {
versionId: "version-2", sourceId: "source-1", state, candidateSha256: "candidate-hash",
baseActiveVersionId: "version-1", currentActiveVersionId: "version-1", activateRequested: true,
processingFingerprint: "fingerprint", metadataHash: "metadata", documents: [{ documentId: "document-1", pages: [{
page: 1, imageUrl: "/private/page-1.png", nativeText: "", ocr: { text: "CBGO4a\nFATo7", lines },
candidateText: "CBGO4a\nFATo7", differences: ["native text is empty"], risks: ["CBGO4a", "FATo7"]
}] }]
};
}
test("review HTTP rejects unauthorized and premature access without exposing artifacts", async (context) => {
const previous = { token: env.lifecycleAdminToken, enabled: env.ocrIngestEnabled };
Object.assign(env, { lifecycleAdminToken: "review-token", ocrIngestEnabled: true });
context.after(() => Object.assign(env, { lifecycleAdminToken: previous.token, ocrIngestEnabled: previous.enabled }));
let value = candidate();
let reads = 0;
const review = new OcrReviewService({ async loadCandidate() { reads += 1; return value; }, async commitApproval() { throw new Error("not called"); } });
const server = createApp({ reviewService: review, startReconciler: false }).listen(0);
context.after(() => server.close());
const address = server.address();
assert.ok(address && typeof address === "object");
const url = `http://127.0.0.1:${address.port}/ingestions/version-2/review`;
const unauthorized = await fetch(url);
assert.equal(unauthorized.status, 401);
assert.equal((await fetch(url.replace("/review", "/approve"), { method: "POST" })).status, 401);
assert.equal(reads, 0);
value = candidate("indexing");
const premature = await fetch(url, { headers: { authorization: "Bearer review-token" } });
assert.equal(premature.status, 409);
assert.deepEqual(await premature.json(), { ok: false, error: "Version is not awaiting OCR review", code: "INVALID_VERSION_STATE" });
});
test("review exposes audit detail and commits current corrections as one immutable set", async () => {
const commits: unknown[] = [];
const service = new OcrReviewService({ async loadCandidate() { return candidate(); }, async commitApproval(value) { commits.push(value); } });
const review = await service.view("version-2");
assert.deepEqual(review.documents[0]?.pages[0]?.ocr.lines.map(({ text, confidence, bbox }) => [text, confidence, bbox]), [
["CBGO4a", 0.7, [0, 0, 50, 8]], ["FATo7", 0.7, [0, 10, 50, 18]]
]);
assert.deepEqual(review.documents[0]?.pages[0]?.risks, ["CBGO4a", "FATo7"]);
const approved = await service.approve("version-2", {
candidateSha256: "candidate-hash", expectedActiveVersionId: "version-1", reviewedBy: "admin",
corrections: [
{ documentId: "document-1", page: 1, lineId: "line-1", expectedLineSha256: sha256Hex("CBGO4a"), replacementText: "CBG04a" },
{ documentId: "document-1", page: 1, lineId: "line-2", expectedLineSha256: sha256Hex("FATo7"), replacementText: "FAT07" }
]
});
assert.equal(commits.length, 1);
assert.deepEqual((commits[0] as { corrections: Array<{ replacementText: string }> }).corrections.map(({ replacementText }) => replacementText), ["CBG04a", "FAT07"]);
assert.equal(approved.reviewedText, "CBG04a\nFAT07");
assert.equal(approved.reviewedTextSha256, sha256Hex("CBG04a\nFAT07"));
});
test("stale or duplicate corrections conflict before any correction or transition", async () => {
let commits = 0;
const service = new OcrReviewService({ async loadCandidate() { return candidate(); }, async commitApproval() { commits += 1; } });
const base = { candidateSha256: "stale", expectedActiveVersionId: "version-1", reviewedBy: "admin", corrections: [] };
await assert.rejects(service.approve("version-2", base), (error) => error instanceof CatalogError && error.statusCode === 409);
const duplicate = { ...base, candidateSha256: "candidate-hash", corrections: Array(2).fill({
documentId: "document-1", page: 1, lineId: "line-1", expectedLineSha256: sha256Hex("CBGO4a"), replacementText: "CBG04a"
}) };
await assert.rejects(service.approve("version-2", duplicate), (error) => error instanceof CatalogError && error.code === "CORRECTION_CONFLICT");
await assert.rejects(service.approve("version-2", { ...base, candidateSha256: "candidate-hash", expectedActiveVersionId: "version-old" }),
(error) => error instanceof CatalogError && error.code === "ACTIVE_VERSION_CHANGED");
await assert.rejects(service.approve("version-2", { ...base, candidateSha256: "candidate-hash", corrections: [{
documentId: "document-1", page: 1, lineId: "line-1", expectedLineSha256: sha256Hex("stale"), replacementText: "CBG04a"
}] }), (error) => error instanceof CatalogError && error.code === "CORRECTION_CONFLICT");
assert.equal(commits, 0);
});
function approved(activateRequested: boolean, state: ApprovedOcrCandidate["state"] = "indexing"): ApprovedOcrCandidate {
return { versionId: "version-2", sourceId: "source-1", state, activateRequested, expectedActiveVersionId: "version-1", reviewedText: "reviewed", reviewedTextSha256: sha256Hex("reviewed"), processingFingerprint: "fingerprint", metadataHash: "metadata" };
}
test("indexing activates only when requested and leaves a new candidate ready on an activation race", async () => {
const calls: string[] = [];
const store: OcrIndexingStore = {
async findReusableVersion() { return undefined; }, async indexReviewed() { calls.push("embed"); return 1; },
async markReady() { calls.push("ready"); }, async settleReusable() { throw new Error("not reusable"); },
async activateVersion() { calls.push("activate"); return "version-2"; }
};
const service = new OcrIndexingService(store);
assert.deepEqual(await service.index(approved(true)), { versionId: "version-2", state: "active", activated: true, activatedVersionId: "version-2" });
assert.deepEqual(calls.splice(0), ["embed", "ready", "activate"]);
assert.deepEqual(await service.index(approved(false)), { versionId: "version-2", state: "ready", activated: false });
assert.deepEqual(calls.splice(0), ["embed", "ready"]);
store.activateVersion = async () => { throw new CatalogError("race", 409, "ACTIVE_VERSION_PRECONDITION_FAILED"); };
await assert.rejects(service.index(approved(true)), (error) => error instanceof CatalogError && error.code === "ACTIVE_VERSION_CHANGED");
assert.deepEqual(calls, ["embed", "ready"]);
});
test("reusable and rejected candidates create no embeddings and reusable activation obeys CAS intent", async () => {
const calls: string[] = [];
const store: OcrIndexingStore = {
async findReusableVersion() { return { versionId: "version-existing" }; }, async indexReviewed() { calls.push("embed"); return 1; },
async markReady() { calls.push("ready"); }, async settleReusable(_candidate, _reusable, activate) { calls.push(`settle:${activate}`); return activate; },
async activateVersion() { throw new Error("new activation must not run"); }
};
const service = new OcrIndexingService(store);
assert.deepEqual(await service.index(approved(true)), { versionId: "version-2", state: "rejected", activated: true, activatedVersionId: "version-existing", errorCode: "DUPLICATE_REUSABLE_VERSION" });
assert.deepEqual(await service.index(approved(false)), { versionId: "version-2", state: "rejected", activated: false, errorCode: "DUPLICATE_REUSABLE_VERSION" });
store.settleReusable = async () => { throw new CatalogError("race", 409, "ACTIVE_VERSION_PRECONDITION_FAILED"); };
await assert.rejects(service.index(approved(true)), (error) => error instanceof CatalogError && error.code === "ACTIVE_VERSION_CHANGED");
await assert.rejects(service.index(approved(true, "rejected")), (error) => error instanceof CatalogError && error.code === "INVALID_VERSION_STATE");
assert.deepEqual(calls, ["settle:true", "settle:false"]);
});
test("authenticated rejection is durable, conflict-safe, and never indexes or activates", async (context) => {
const previous = { token: env.lifecycleAdminToken, enabled: env.ocrIngestEnabled };
Object.assign(env, { lifecycleAdminToken: "review-token", ocrIngestEnabled: true });
context.after(() => Object.assign(env, { lifecycleAdminToken: previous.token, ocrIngestEnabled: previous.enabled }));
let value = candidate();
let reads = 0;
const audits: Array<{ reviewedBy: string; reason: string }> = [];
const review = new OcrReviewService({
async loadCandidate() { reads += 1; return value; },
async commitApproval() { throw new Error("approval must not run"); },
async commitRejection({ reviewedBy, reason }) {
audits.push({ reviewedBy, reason });
value = { ...value, state: "rejected" };
}
});
let indexingCalls = 0;
const catalog = { async getIngestionStatus() {
return { versionId: value.versionId, state: value.state, phase: value.state, activated: false, error: value.state === "rejected" ? { code: "OCR_REJECTED", message: audits[0]?.reason, retryable: false } : null };
} };
const server = createApp({ catalog: catalog as never, reviewService: review, indexingService: { async index() { indexingCalls += 1; throw new Error("indexing must not run"); } }, startReconciler: false }).listen(0);
context.after(() => server.close());
const address = server.address();
assert.ok(address && typeof address === "object");
const url = `http://127.0.0.1:${address.port}/ingestions/version-2`;
const unauthorized = await fetch(`${url}/reject`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ candidateSha256: "candidate-hash", reviewedBy: "admin", reason: "Unreadable code" }) });
assert.equal(unauthorized.status, 401);
assert.equal(reads, 0);
const invalid = await fetch(`${url}/reject`, { method: "POST", headers: { authorization: "Bearer review-token", "content-type": "application/json" }, body: "{}" });
assert.equal(invalid.status, 400);
assert.deepEqual(await invalid.json(), { ok: false, error: "Candidate hash, reviewer, and rejection reason are required", code: "INVALID_REJECTION" });
assert.equal(reads, 0);
const stale = await fetch(`${url}/reject`, { method: "POST", headers: { authorization: "Bearer review-token", "content-type": "application/json" }, body: JSON.stringify({ candidateSha256: "stale", reviewedBy: "admin", reason: "Unreadable code" }) });
assert.equal(stale.status, 409);
assert.equal(audits.length, 0);
const rejected = await fetch(`${url}/reject`, { method: "POST", headers: { authorization: "Bearer review-token", "content-type": "application/json" }, body: JSON.stringify({ candidateSha256: "candidate-hash", reviewedBy: "admin", reason: "Unreadable code" }) });
assert.equal(rejected.status, 200);
assert.deepEqual(await rejected.json(), { versionId: "version-2", state: "rejected", activated: false });
assert.deepEqual(audits, [{ reviewedBy: "admin", reason: "Unreadable code" }]);
const status = await fetch(url, { headers: { authorization: "Bearer review-token" } });
assert.equal(status.status, 200);
assert.deepEqual(await status.json(), { versionId: "version-2", state: "rejected", phase: "rejected", activated: false, error: { code: "OCR_REJECTED", message: "Unreadable code", retryable: false } });
const repeated = await fetch(`${url}/reject`, { method: "POST", headers: { authorization: "Bearer review-token", "content-type": "application/json" }, body: JSON.stringify({ candidateSha256: "candidate-hash", reviewedBy: "admin", reason: "Again" }) });
assert.equal(repeated.status, 409);
assert.equal(indexingCalls, 0);
assert.equal(audits.length, 1);
});
test("playground serves the authenticated OCR review controls and audit fields", async (context) => {
const server = createApp({ startReconciler: false }).listen(0);
context.after(() => server.close());
const address = server.address();
assert.ok(address && typeof address === "object");
const base = `http://127.0.0.1:${address.port}`;
const [html, script, styles] = await Promise.all([
fetch(`${base}/playground`).then((response) => response.text()),
fetch(`${base}/playground/app.js`).then((response) => response.text()),
fetch(`${base}/playground/styles.css`).then((response) => response.text())
]);
for (const marker of ["data-tab=\"review\"", "reviewVersionId", "reviewToken", "loadReviewButton", "approveReviewButton", "rejectReviewButton", "reviewCandidate"]) assert.match(html, new RegExp(marker));
for (const marker of ["Authorization", "/review", "/approve", "/reject", "candidateSha256", "expectedLineSha256", "imageUrl", "nativeText", "confidence", "bbox", "differences", "risks"]) assert.match(script, new RegExp(marker));
assert.match(styles, /\.review-page/);
});