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

203 lines
8.3 KiB
TypeScript

import assert from "node:assert/strict";
import { access, lstat, mkdir, mkdtemp, readFile, stat, symlink, utimes } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { OcrClient, OcrClientError } from "../../src/modules/ocr/client.js";
import {
resolveArtifactPath,
stageOcrArtifacts,
sweepOrphanArtifacts
} from "../../src/modules/ocr/artifacts.js";
import { canonicalJson, hashOrderedPairs, sha256Hex } from "../../src/shared/utils/ids.js";
const document = Buffer.from("%PDF-1.4\nunit-7\n%%EOF\n");
const documentSha256 = sha256Hex(document);
const jobId = "ocr_job-7";
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function ack(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
jobId,
status: "queued",
documentSha256,
requestedPages: [1, 3],
configVersion: "ocr-v1",
createdAt: "2026-09-14T10:00:00.000Z",
...overrides
};
}
function result(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
schemaVersion: "1",
jobId,
documentSha256,
engine: {
name: "paddleocr",
version: "3.4.0",
runtime: "paddlepaddle-3.2.2",
device: "cpu",
configVersion: "ocr-v1",
dpi: 200
},
pages: [1, 3].map((page) => ({
page,
width: 1700,
height: 2200,
processingMs: 25,
text: `page ${page}`,
metrics: {
lineCount: 1,
nonWhitespaceCharacters: 5,
medianConfidence: 0.95,
p10Confidence: 0.95,
lowConfidenceLineRatio: 0
},
lines: [{ lineId: `p${page}-l1`, text: `page ${page}`, confidence: 0.95, bbox: [1, 2, 3, 4] }]
})),
...overrides
};
}
test("runtime retry stub preserves identity, exact attempts, backoff, and result integrity", async () => {
const calls: Array<{ url: string; key: string | null }> = [];
const delays: number[] = [];
const responses: Array<Response | Error> = [
new TypeError("connection reset"),
jsonResponse(503, { detail: { code: "ENGINE_UNAVAILABLE" } }),
jsonResponse(202, ack()),
jsonResponse(200, result())
];
const client = new OcrClient({
baseUrl: "http://ocr.internal:8000/",
token: "internal-test-token",
fetch: (async (input, init) => {
calls.push({ url: String(input), key: new Headers(init?.headers).get("Idempotency-Key") });
const response = responses.shift();
if (response instanceof Error) throw response;
return response as Response;
}) as typeof fetch,
sleep: async (milliseconds) => { delays.push(milliseconds); }
});
const submitted = await client.submit(document, { documentSha256, pages: [1, 3] });
const completed = await client.getResult(jobId, { documentSha256, pages: [1, 3] });
assert.equal(submitted.jobId, jobId);
assert.deepEqual(delays, [2_000, 4_000]);
assert.equal(calls.filter(({ url }) => url.endsWith("/v1/jobs")).length, 3);
assert.equal(new Set(calls.slice(0, 3).map(({ key }) => key)).size, 1);
assert.equal(calls[0]?.key, `${documentSha256}:ocr-v1:${sha256Hex("[1,3]")}`);
assert.deepEqual(completed.pages.map(({ page }) => page), [1, 3]);
});
test("queue pressure and deterministic failures are surfaced without retries", async () => {
for (const [status, retryable] of [[429, true], [422, false]] as const) {
let attempts = 0;
const client = new OcrClient({
baseUrl: "http://ocr.internal:8000",
token: "token",
fetch: (async () => {
attempts += 1;
return jsonResponse(status, { detail: { code: status === 429 ? "QUEUE_FULL" : "UNSUPPORTED_PDF" } });
}) as typeof fetch,
sleep: async () => { throw new Error("must not sleep"); }
});
await assert.rejects(
client.submit(document, { documentSha256, pages: [1, 3] }),
(error: unknown) => error instanceof OcrClientError && error.status === status && error.retryable === retryable
);
assert.equal(attempts, 1);
}
});
test("strict validation rejects mismatched acknowledgements and result contracts", async () => {
for (const response of [
jsonResponse(202, ack({ requestedPages: [3, 1] })),
jsonResponse(200, result({ schemaVersion: "2" })),
jsonResponse(200, result({ pages: [result().pages as unknown] }))
]) {
const client = new OcrClient({
baseUrl: "http://ocr.internal:8000",
token: "token",
fetch: (async () => response) as typeof fetch,
sleep: async () => undefined
});
const operation = response.status === 202
? client.submit(document, { documentSha256, pages: [1, 3] })
: client.getResult(jobId, { documentSha256, pages: [1, 3] });
await assert.rejects(operation, /OCR response integrity validation failed/);
}
});
test("polling uses bounded exponential backoff until a terminal status", async () => {
const delays: number[] = [];
const states = ["queued", "running", "succeeded"] as const;
const client = new OcrClient({
baseUrl: "http://ocr.internal:8000",
token: "token",
fetch: (async () => jsonResponse(200, {
jobId,
status: states.shift(),
completedPages: states.length === 0 ? 2 : 0,
totalPages: 2,
error: null
})) as typeof fetch,
sleep: async (milliseconds) => { delays.push(milliseconds); }
});
assert.equal((await client.pollUntilTerminal(jobId)).status, "succeeded");
assert.deepEqual(delays, [2_000, 4_000]);
});
test("artifact staging writes private originals and a verifiable canonical manifest", async (context) => {
const rootDirectory = await mkdtemp(path.join(os.tmpdir(), "rag-ocr-artifacts-"));
context.after(async () => { await import("node:fs/promises").then(({ rm }) => rm(rootDirectory, { recursive: true, force: true })); });
const versionId = "11111111-1111-4111-8111-111111111111";
const staged = await stageOcrArtifacts({
rootDirectory,
versionId,
createdAt: "2026-09-14T10:00:00.000Z",
documents: [
{ documentId: "doc:source:b", documentKey: "b.pdf", bytes: Buffer.from("%PDF-b") },
{ documentId: "doc:source:a", documentKey: "a.pdf", bytes: Buffer.from("%PDF-a") }
]
});
const persisted = JSON.parse(await readFile(staged.manifestPath, "utf8"));
assert.equal(staged.originalManifestHash, hashOrderedPairs([["b.pdf", sha256Hex("%PDF-b")], ["a.pdf", sha256Hex("%PDF-a")]]));
assert.equal(staged.manifestSha256, sha256Hex(canonicalJson(persisted)));
assert.deepEqual(persisted.documents.map((entry: { documentKey: string }) => entry.documentKey), ["a.pdf", "b.pdf"]);
for (const entry of persisted.documents) {
const originalPath = resolveArtifactPath(staged.versionDirectory, entry.originalPath);
assert.equal((await stat(originalPath)).mode & 0o777, 0o600);
assert.equal(sha256Hex(await readFile(originalPath)), entry.originalSha256);
}
assert.equal((await stat(staged.manifestPath)).mode & 0o777, 0o600);
});
test("safe resolution blocks traversal and orphan sweep preserves retained, recent, and non-directory entries", async (context) => {
const rootDirectory = await mkdtemp(path.join(os.tmpdir(), "rag-ocr-sweep-"));
context.after(async () => { await import("node:fs/promises").then(({ rm }) => rm(rootDirectory, { recursive: true, force: true })); });
const retained = "22222222-2222-4222-8222-222222222222";
const orphan = "33333333-3333-4333-8333-333333333333";
const recent = "44444444-4444-4444-8444-444444444444";
await Promise.all([retained, orphan, recent].map((id) => mkdir(path.join(rootDirectory, id))));
await utimes(path.join(rootDirectory, orphan), new Date(0), new Date(0));
await symlink(path.join(rootDirectory, orphan), path.join(rootDirectory, "55555555-5555-4555-8555-555555555555"));
assert.throws(() => resolveArtifactPath(path.join(rootDirectory, retained), "../manifest.json"), /escapes version directory/);
assert.deepEqual(await sweepOrphanArtifacts({
rootDirectory,
retainedVersionIds: new Set([retained]),
olderThan: new Date("2026-09-14T09:00:00.000Z")
}), [orphan]);
await assert.rejects(access(path.join(rootDirectory, orphan)));
await Promise.all([retained, recent].map((id) => access(path.join(rootDirectory, id))));
assert.equal((await lstat(path.join(rootDirectory, "55555555-5555-4555-8555-555555555555"))).isSymbolicLink(), true);
});