203 lines
8.3 KiB
TypeScript
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);
|
|
});
|