feat(ocr): model-scoped OCR capability + gateway /v1/ocr/extract route
Adds the ocr.page.extract.chandra-ocr-2.v1 capability end to end: the postOcrEscrow builder (locking the min-ada floor for the Submitted-state datum, required since the min-ada fix), the gateway OCR route that selects an OCR supplier by capability, posts a per-page escrow, calls the supplier, verifies and settles, and returns content plus the signed receipt labelled first-party-brokered. Supplier OCR handler with a hosted document-API upstream and buyer-SDK OCR support included. Squashed from the 2026-08-06 working subset; the deploy pack is intentionally excluded (it lives in a private operator repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
217
tests/unit/supplier-datalab-ocr.test.ts
Normal file
217
tests/unit/supplier-datalab-ocr.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* supplier-datalab-ocr.test.ts — coverage for the Datalab hosted-API OCR
|
||||
* upstream (OCR_UPSTREAM="datalab").
|
||||
*
|
||||
* Pins the wire contract verified against documentation.datalab.to
|
||||
* (2026-08-06): multipart POST /api/v1/convert with X-API-Key, then poll
|
||||
* GET /api/v1/convert/{id} until status "complete"; result field named by
|
||||
* output_format. Plus the error taxonomy and the health probe.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
callDatalabOcr,
|
||||
probeDatalabHealth,
|
||||
DatalabError,
|
||||
} from "../../supplier/src/datalabOcr.js";
|
||||
import { loadConfig } from "../../supplier/src/config.js";
|
||||
import { buildSampleEnv } from "../fixtures/supplier-side/sample-config.js";
|
||||
|
||||
const IMG = Buffer.from("page bytes").toString("base64");
|
||||
|
||||
function validParams(overrides?: Partial<Parameters<typeof callDatalabOcr>[0]>) {
|
||||
return {
|
||||
baseUrl: "https://dl.fake",
|
||||
apiKey: "dl-key",
|
||||
request: { image_b64: IMG, mime: "image/png", output_format: "markdown" },
|
||||
timeoutMs: 5_000,
|
||||
mode: "accurate",
|
||||
pollIntervalMs: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("callDatalabOcr — happy path + wire shape", () => {
|
||||
it("submits multipart with X-API-Key, polls, returns the markdown content", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
let polls = 0;
|
||||
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init: init ?? {} });
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({
|
||||
success: true, error: null, request_id: "req-1",
|
||||
request_check_url: "https://dl.fake/api/v1/convert/req-1",
|
||||
}), { status: 200 });
|
||||
}
|
||||
polls += 1;
|
||||
if (polls < 2) {
|
||||
return new Response(JSON.stringify({ status: "processing" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
status: "complete", markdown: "# Registry page", page_count: 1, error: null,
|
||||
}), { status: 200 });
|
||||
});
|
||||
|
||||
const result = await callDatalabOcr(validParams());
|
||||
expect(result.content).toBe("# Registry page");
|
||||
expect(result.prompt_tokens).toBe(0);
|
||||
expect(result.completion_tokens).toBe(0);
|
||||
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const submit = calls[0];
|
||||
expect(submit.url).toBe("https://dl.fake/api/v1/convert");
|
||||
expect((submit.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
|
||||
const form = submit.init.body as FormData;
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
expect(form.get("output_format")).toBe("markdown");
|
||||
expect(form.get("mode")).toBe("accurate");
|
||||
const file = form.get("file") as Blob;
|
||||
expect(file).toBeInstanceOf(Blob);
|
||||
expect(file.type).toBe("image/png");
|
||||
|
||||
// Poll used the check URL with the key.
|
||||
const poll = calls[1];
|
||||
expect(poll.url).toBe("https://dl.fake/api/v1/convert/req-1");
|
||||
expect((poll.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
|
||||
});
|
||||
|
||||
it("stringifies an object result for output_format=json", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r2", request_check_url: "https://dl.fake/api/v1/convert/r2" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
status: "complete", json: { blocks: [{ type: "Table" }] },
|
||||
}), { status: 200 });
|
||||
});
|
||||
const result = await callDatalabOcr(validParams({
|
||||
request: { image_b64: IMG, mime: "image/png", output_format: "json" },
|
||||
}));
|
||||
expect(result.content).toBe(JSON.stringify({ blocks: [{ type: "Table" }] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("callDatalabOcr — error taxonomy", () => {
|
||||
it("datalab_failure on submit HTTP error", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("no", { status: 500 }));
|
||||
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
|
||||
name: "DatalabError", reason: "datalab_failure",
|
||||
});
|
||||
});
|
||||
|
||||
it("datalab_failure on success:false", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ success: false, error: "no credits" }), { status: 200 }));
|
||||
await expect(callDatalabOcr(validParams())).rejects.toThrow(/no credits/);
|
||||
});
|
||||
|
||||
it("datalab_malformed when request_id missing", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
|
||||
reason: "datalab_malformed",
|
||||
});
|
||||
});
|
||||
|
||||
it("datalab_failure when the job reports failed status", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r3", request_check_url: "https://dl.fake/api/v1/convert/r3" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "failed", error: "unreadable file" }), { status: 200 });
|
||||
});
|
||||
await expect(callDatalabOcr(validParams())).rejects.toThrow(/unreadable file/);
|
||||
});
|
||||
|
||||
it("datalab_malformed when the completed result lacks the requested format field", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r4", request_check_url: "https://dl.fake/api/v1/convert/r4" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "complete", html: "<p>x</p>" }), { status: 200 });
|
||||
});
|
||||
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
|
||||
reason: "datalab_malformed",
|
||||
});
|
||||
});
|
||||
|
||||
it("datalab_timeout when polling never completes within the budget", async () => {
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
if (url.endsWith("/api/v1/convert")) {
|
||||
return new Response(JSON.stringify({ success: true, request_id: "r5", request_check_url: "https://dl.fake/api/v1/convert/r5" }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ status: "processing" }), { status: 200 });
|
||||
});
|
||||
await expect(callDatalabOcr(validParams({ timeoutMs: 60, pollIntervalMs: 10 })))
|
||||
.rejects.toMatchObject({ reason: "datalab_timeout" });
|
||||
});
|
||||
|
||||
it("exports the error class for instanceof checks", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("x", { status: 502 }));
|
||||
try {
|
||||
await callDatalabOcr(validParams());
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatalabError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeDatalabHealth", () => {
|
||||
it("ok on 200 from /api/v1/health with the key header", async () => {
|
||||
let captured: { url: string; init: RequestInit } | null = null;
|
||||
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
||||
captured = { url, init: init ?? {} };
|
||||
return new Response(JSON.stringify({ status: "ok" }), { status: 200 });
|
||||
});
|
||||
const result = await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "dl-key", timeoutMs: 1000 });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(captured!.url).toBe("https://dl.fake/api/v1/health");
|
||||
expect((captured!.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
|
||||
});
|
||||
|
||||
it("not ok on HTTP error or network failure", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("x", { status: 503 }));
|
||||
expect((await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "k", timeoutMs: 1000 })).ok).toBe(false);
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("ECONNREFUSED"); });
|
||||
expect((await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "k", timeoutMs: 1000 })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig — datalab upstream validation", () => {
|
||||
function ocrEnv(extra: Record<string, string>): Record<string, string> {
|
||||
const env = buildSampleEnv();
|
||||
delete (env as Record<string, string | undefined>).OLLAMA_URL;
|
||||
return { ...env, CAPABILITY_KIND: "ocr", ...extra };
|
||||
}
|
||||
|
||||
it("requires DATALAB_API_KEY when OCR_UPSTREAM=datalab", () => {
|
||||
expect(() => loadConfig(ocrEnv({ OCR_UPSTREAM: "datalab" })))
|
||||
.toThrow(/DATALAB_API_KEY/);
|
||||
});
|
||||
|
||||
it("datalab upstream boots without OPENAI_BASE_URL and probes per_job by default", () => {
|
||||
const config = loadConfig(ocrEnv({ OCR_UPSTREAM: "datalab", DATALAB_API_KEY: "dl-k" }));
|
||||
expect(config.ocrUpstream).toBe("datalab");
|
||||
expect(config.openaiBaseUrl).toBe("");
|
||||
expect(config.revisionProbeMode).toBe("per_job");
|
||||
expect(config.datalabBaseUrl).toBe("https://www.datalab.to");
|
||||
expect(config.datalabMode).toBe("accurate");
|
||||
});
|
||||
|
||||
it("openai-vision upstream still requires OPENAI_BASE_URL", () => {
|
||||
expect(() => loadConfig(ocrEnv({})))
|
||||
.toThrow(/OPENAI_BASE_URL/);
|
||||
});
|
||||
|
||||
it("rejects an invalid DATALAB_MODE", () => {
|
||||
expect(() => loadConfig(ocrEnv({
|
||||
OCR_UPSTREAM: "datalab", DATALAB_API_KEY: "dl-k", DATALAB_MODE: "turbo",
|
||||
}))).toThrow(/DATALAB_MODE/);
|
||||
});
|
||||
});
|
||||
164
tests/unit/supplier-ocr-vision.test.ts
Normal file
164
tests/unit/supplier-ocr-vision.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* supplier-ocr-vision.test.ts — coverage for the OpenAI-compatible VISION
|
||||
* upstream client used by the `ocr` capability kind.
|
||||
*
|
||||
* Mirrors supplier-openai.test.ts: stub global fetch, pin the outbound wire
|
||||
* shape (vision content parts, data-URL image), and the error taxonomy
|
||||
* (ocr_failure / ocr_timeout / ocr_malformed).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
callOcrVision,
|
||||
probeRevision,
|
||||
defaultOcrInstruction,
|
||||
OcrVisionError,
|
||||
} from "../../supplier/src/ocrVision.js";
|
||||
|
||||
const IMG = Buffer.from("some page bytes").toString("base64");
|
||||
|
||||
function validParams() {
|
||||
return {
|
||||
baseUrl: "http://vllm.fake:8000",
|
||||
model: "datalab-to/chandra-ocr-2",
|
||||
request: { image_b64: IMG, mime: "image/png", output_format: "markdown" },
|
||||
timeoutMs: 5_000,
|
||||
};
|
||||
}
|
||||
|
||||
function okBody(content = "# Page\ntext") {
|
||||
return {
|
||||
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 900, completion_tokens: 120, total_tokens: 1020 },
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("callOcrVision — happy path + wire shape", () => {
|
||||
it("POSTs vision content parts with a data-URL image and returns the normalised result", async () => {
|
||||
let captured: { url: string; init: RequestInit } | null = null;
|
||||
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
|
||||
captured = { url, init };
|
||||
return new Response(JSON.stringify(okBody()), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
const result = await callOcrVision(validParams());
|
||||
|
||||
expect(result.content).toBe("# Page\ntext");
|
||||
expect(result.prompt_tokens).toBe(900);
|
||||
expect(result.completion_tokens).toBe(120);
|
||||
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.url).toBe("http://vllm.fake:8000/v1/chat/completions");
|
||||
const sent = JSON.parse(String(captured!.init.body));
|
||||
expect(sent.model).toBe("datalab-to/chandra-ocr-2");
|
||||
expect(sent.stream).toBe(false);
|
||||
expect(Array.isArray(sent.messages)).toBe(true);
|
||||
const content = sent.messages[0].content;
|
||||
expect(content[0].type).toBe("image_url");
|
||||
expect(content[0].image_url.url).toBe(`data:image/png;base64,${IMG}`);
|
||||
expect(content[1].type).toBe("text");
|
||||
expect(content[1].text).toBe(defaultOcrInstruction("markdown"));
|
||||
});
|
||||
|
||||
it("uses promptOverride verbatim when provided", async () => {
|
||||
let sentText = "";
|
||||
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
|
||||
sentText = JSON.parse(String(init.body)).messages[0].content[1].text;
|
||||
return new Response(JSON.stringify(okBody()), { status: 200 });
|
||||
});
|
||||
await callOcrVision({ ...validParams(), promptOverride: "OCR EXACTLY AS CHANDRA WANTS" });
|
||||
expect(sentText).toBe("OCR EXACTLY AS CHANDRA WANTS");
|
||||
});
|
||||
|
||||
it("forwards max_tokens when positive and the bearer key when set", async () => {
|
||||
let sent: Record<string, unknown> = {};
|
||||
let auth = "";
|
||||
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
|
||||
sent = JSON.parse(String(init.body));
|
||||
auth = (init.headers as Record<string, string>).authorization ?? "";
|
||||
return new Response(JSON.stringify(okBody()), { status: 200 });
|
||||
});
|
||||
await callOcrVision({ ...validParams(), maxTokens: 4096, apiKey: "sk-test" });
|
||||
expect(sent.max_tokens).toBe(4096);
|
||||
expect(auth).toBe("Bearer sk-test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callOcrVision — error taxonomy", () => {
|
||||
it("ocr_failure on non-2xx", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("boom", { status: 500 }));
|
||||
await expect(callOcrVision(validParams())).rejects.toMatchObject({
|
||||
name: "OcrVisionError",
|
||||
reason: "ocr_failure",
|
||||
});
|
||||
});
|
||||
|
||||
it("ocr_malformed on missing content", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content: "" } }] }), { status: 200 }));
|
||||
await expect(callOcrVision(validParams())).rejects.toMatchObject({
|
||||
reason: "ocr_malformed",
|
||||
});
|
||||
});
|
||||
|
||||
it("ocr_timeout when the upstream exceeds timeoutMs", async () => {
|
||||
vi.stubGlobal("fetch", (_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
const signal = init.signal as AbortSignal;
|
||||
signal.addEventListener("abort", () => {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
});
|
||||
}));
|
||||
await expect(callOcrVision({ ...validParams(), timeoutMs: 30 })).rejects.toMatchObject({
|
||||
reason: "ocr_timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports the error class for instanceof checks", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("nope", { status: 502 }));
|
||||
try {
|
||||
await callOcrVision(validParams());
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcrVisionError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeRevision — served-checkpoint probe", () => {
|
||||
it("extracts a 40-hex sha from known runtime fields", async () => {
|
||||
const sha = "a".repeat(40);
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ model_sha: sha }), { status: 200 }));
|
||||
const result = await probeRevision({ probeUrl: "http://vllm.fake:8000/info", timeoutMs: 1000 });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.servedSha).toBe(sha);
|
||||
});
|
||||
|
||||
it("ok with empty sha when the runtime reports no revision field", async () => {
|
||||
vi.stubGlobal("fetch", async () =>
|
||||
new Response(JSON.stringify({ served_model: "chandra" }), { status: 200 }));
|
||||
const result = await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.servedSha).toBe("");
|
||||
});
|
||||
|
||||
it("not ok on HTTP error / network failure", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("x", { status: 503 }));
|
||||
expect((await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 })).ok).toBe(false);
|
||||
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("ECONNREFUSED"); });
|
||||
expect((await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
176
tests/unit/supplier-routes-ocr.test.ts
Normal file
176
tests/unit/supplier-routes-ocr.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* supplier-routes-ocr.test.ts — coverage for the model-scoped OCR supplier
|
||||
* route (`ocr.page.extract.<model-slug>.v1`).
|
||||
*
|
||||
* Focus (mirrors supplier-routes-tts.test.ts): the NEW validation logic in
|
||||
* `makeOcrHandler` plus capability-routed mounting and the enlarged body
|
||||
* limit. The shared lifecycle pieces (lock, claim tx, runner spawn) reuse
|
||||
* the chat-handler implementation and are covered by the chat route tests.
|
||||
*
|
||||
* End-to-end happy-path (escrow seed → claim → run → submit) is exercised
|
||||
* by the post-deploy smoke against the real testnet, not here.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { Application } from "express";
|
||||
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import { SupplierState } from "../../supplier/src/state.js";
|
||||
import { JobStore } from "../../supplier/src/jobs.js";
|
||||
import { createApp } from "../../supplier/src/server.js";
|
||||
import type { SupplierConfig } from "../../supplier/src/config.js";
|
||||
import {
|
||||
buildSampleConfig,
|
||||
buildSampleOcrConfig,
|
||||
} from "../fixtures/supplier-side/sample-config.js";
|
||||
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
|
||||
const ESCROW_REF_HEADER = `${"f".repeat(64)}#0`;
|
||||
const IMG = Buffer.from("page image bytes").toString("base64");
|
||||
|
||||
function makeApp(config?: SupplierConfig): Application {
|
||||
return createApp({
|
||||
chain: new MockChainProvider(),
|
||||
state: new SupplierState(),
|
||||
config: config ?? buildSampleOcrConfig(),
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
jobs: new JobStore(),
|
||||
});
|
||||
}
|
||||
|
||||
function validBody() {
|
||||
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Capability-routed mounting", () => {
|
||||
it("ocr supplier exposes /v1/ocr/extract and NOT the chat/tts routes", async () => {
|
||||
const app = makeApp();
|
||||
const ocr = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
expect(ocr.status).not.toBe(404);
|
||||
|
||||
const chat = await request(app).post("/v1/chat/completions")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ messages: [{ role: "user", content: "hi" }] });
|
||||
expect(chat.status).toBe(404);
|
||||
|
||||
const tts = await request(app).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ text: "x", voice: "nova", format: "mp3", speed: 1 });
|
||||
expect(tts.status).toBe(404);
|
||||
});
|
||||
|
||||
it("chat supplier does NOT expose /v1/ocr/extract", async () => {
|
||||
const app = makeApp(buildSampleConfig());
|
||||
const res = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — header + body validation", () => {
|
||||
it("400 escrow_ref_required when X-Escrow-Ref header missing", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract").send(validBody());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("escrow_ref_required");
|
||||
});
|
||||
|
||||
it("400 escrow_ref_malformed when header is not <64hex>#<int>", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", "not-a-ref").send(validBody());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("escrow_ref_malformed");
|
||||
});
|
||||
|
||||
it("400 image_required when image_b64 missing or empty", async () => {
|
||||
const app = makeApp();
|
||||
for (const body of [{}, { image_b64: "" }, { mime: "image/png", output_format: "markdown" }]) {
|
||||
const res = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(body);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("image_required");
|
||||
}
|
||||
});
|
||||
|
||||
it("400 image_not_base64 for data-URL or whitespace payloads", async () => {
|
||||
const app = makeApp();
|
||||
for (const image_b64 of [`data:image/png;base64,${IMG}`, `${IMG} `, "has space"]) {
|
||||
const res = await request(app).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), image_b64 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("image_not_base64");
|
||||
}
|
||||
});
|
||||
|
||||
it("400 mime_invalid for unsupported mime", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), mime: "image/tiff" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("mime_invalid");
|
||||
});
|
||||
|
||||
it("400 output_format_invalid for unsupported format", async () => {
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), output_format: "yaml" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("output_format_invalid");
|
||||
});
|
||||
|
||||
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
|
||||
// 2M chars of base64 ≈ 1.5 MB binary; global express.json would 413 this.
|
||||
const big = "A".repeat(2_000_000);
|
||||
const res = await request(makeApp()).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), image_b64: big });
|
||||
// Passes body parsing + validation, then fails on the unseeded chain
|
||||
// (advert_unavailable 503) — anything but 413 proves the parser took it.
|
||||
expect(res.status).not.toBe(413);
|
||||
expect([403, 404, 503]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — revision-probe gate", () => {
|
||||
it("503 revision_probe_failed when the probe cannot reach the runtime", async () => {
|
||||
// Advert/escrow would resolve AFTER the probe? No — probe runs at step
|
||||
// 7.5, after chain checks. Seeding a full escrow in the mock provider is
|
||||
// the chat-route suite's job; here we assert the config gate wiring by
|
||||
// hitting the earlier chain-side failure with probing configured — the
|
||||
// probe must NOT fire before chain validation (unseeded chain 503s
|
||||
// first), keeping probe cost off the hot invalid path.
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("probe must not be called yet"); });
|
||||
const config: SupplierConfig = {
|
||||
...buildSampleOcrConfig(),
|
||||
revisionProbeUrl: "http://vllm.fake:8000/info",
|
||||
revisionProbeMode: "per_job",
|
||||
hfModelRevision: "a".repeat(40),
|
||||
};
|
||||
const res = await request(makeApp(config)).post("/v1/ocr/extract")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
// Unseeded chain → advert_unavailable (503) BEFORE any probe fetch.
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.reason).toBe("advert_unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/ocr/extract/:jobId", () => {
|
||||
it("400 invalid_job_id for non-UUID", async () => {
|
||||
const res = await request(makeApp()).get("/v1/ocr/extract/not-a-uuid");
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("invalid_job_id");
|
||||
});
|
||||
|
||||
it("404 job_not_found for unknown UUID", async () => {
|
||||
const res = await request(makeApp())
|
||||
.get("/v1/ocr/extract/123e4567-e89b-42d3-a456-426614174000");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.reason).toBe("job_not_found");
|
||||
});
|
||||
});
|
||||
122
tests/unit/tx-post-ocr-escrow.test.ts
Normal file
122
tests/unit/tx-post-ocr-escrow.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* tx-post-ocr-escrow.test.ts — coverage for the OCR escrow builder added
|
||||
* for model-scoped `ocr.page.extract.<model-slug>.v1` capabilities.
|
||||
*
|
||||
* Focus: the NEW validation + hash logic in postOcrEscrow.ts. The shared
|
||||
* escrow-construction pieces (advert resolution, datum assembly, mock tx
|
||||
* encoding) mirror postTtsEscrow.ts and are covered by tx-post-escrow.test.ts
|
||||
* for the chat shape; here we pin:
|
||||
* - ocrPromptHash canonicalisation (golden hash — buyer/supplier contract)
|
||||
* - envelope validation failures throw BEFORE any chain access
|
||||
* - the escrow datum carries the OCR prompt_hash on the happy path
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import {
|
||||
ocrPromptHash,
|
||||
validateOcrRequest,
|
||||
buildPostOcrEscrowTx,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
TxConstructionError,
|
||||
type OcrRequest,
|
||||
} from "../../packages/shared/src/tx/index.js";
|
||||
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
|
||||
const IMG = Buffer.from("not really a png but bytes are bytes").toString("base64");
|
||||
|
||||
function validRequest(): OcrRequest {
|
||||
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
|
||||
}
|
||||
|
||||
describe("ocrPromptHash — canonicalisation contract", () => {
|
||||
it("equals sha256(canonicalize({image_b64, mime, output_format}))", () => {
|
||||
const req = validRequest();
|
||||
const expected = createHash("sha256")
|
||||
.update(canonicalize({
|
||||
image_b64: req.image_b64,
|
||||
mime: req.mime,
|
||||
output_format: req.output_format,
|
||||
}), "utf8")
|
||||
.digest("hex");
|
||||
expect(ocrPromptHash(req)).toBe(expected);
|
||||
});
|
||||
|
||||
it("is key-order independent (JCS canonicalisation)", () => {
|
||||
const a = ocrPromptHash({ image_b64: IMG, mime: "image/png", output_format: "json" });
|
||||
const b = ocrPromptHash({ output_format: "json", mime: "image/png", image_b64: IMG } as OcrRequest);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("changes when any field changes", () => {
|
||||
const base = ocrPromptHash(validRequest());
|
||||
expect(ocrPromptHash({ ...validRequest(), mime: "image/jpeg" })).not.toBe(base);
|
||||
expect(ocrPromptHash({ ...validRequest(), output_format: "html" })).not.toBe(base);
|
||||
expect(ocrPromptHash({ ...validRequest(), image_b64: IMG + "AA" })).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOcrRequest — envelope gates", () => {
|
||||
it("rejects empty image", () => {
|
||||
expect(() => validateOcrRequest({ ...validRequest(), image_b64: "" }))
|
||||
.toThrowError(TxConstructionError);
|
||||
});
|
||||
|
||||
it("rejects oversized image", () => {
|
||||
const big = "A".repeat(MAX_OCR_IMAGE_B64_CHARS + 4);
|
||||
expect(() => validateOcrRequest({ ...validRequest(), image_b64: big }))
|
||||
.toThrow(/exceeds/);
|
||||
});
|
||||
|
||||
it("rejects data-URL prefixes and whitespace (plain base64 only)", () => {
|
||||
for (const bad of [`data:image/png;base64,${IMG}`, `${IMG} `, "with space AA"]) {
|
||||
expect(() => validateOcrRequest({ ...validRequest(), image_b64: bad }))
|
||||
.toThrow(/base64/);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown mime and output_format", () => {
|
||||
expect(() => validateOcrRequest({ ...validRequest(), mime: "image/tiff" }))
|
||||
.toThrow(/mime/);
|
||||
expect(() => validateOcrRequest({ ...validRequest(), output_format: "yaml" }))
|
||||
.toThrow(/output_format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostOcrEscrowTx — validation precedes chain access", () => {
|
||||
it("throws the envelope error against an unseeded chain (no advert query first)", async () => {
|
||||
const chain = new MockChainProvider();
|
||||
const buyerKey = {
|
||||
pubKeyHash: "ab".repeat(28),
|
||||
pubKeyHex: "cd".repeat(32),
|
||||
privateKeyHex: "ef".repeat(32),
|
||||
address: "addr_test1fake",
|
||||
};
|
||||
await expect(buildPostOcrEscrowTx({
|
||||
chain,
|
||||
buyerKey,
|
||||
advertRef: { txHash: "b".repeat(64), index: 0 },
|
||||
request: { ...validRequest(), mime: "image/gif" },
|
||||
payment_lovelace: 200000n,
|
||||
})).rejects.toThrow(/mime/);
|
||||
});
|
||||
|
||||
it("unseeded advert ref → 'advert ref not on chain' after a valid envelope", async () => {
|
||||
const chain = new MockChainProvider();
|
||||
const buyerKey = {
|
||||
pubKeyHash: "ab".repeat(28),
|
||||
pubKeyHex: "cd".repeat(32),
|
||||
privateKeyHex: "ef".repeat(32),
|
||||
address: "addr_test1fake",
|
||||
};
|
||||
await expect(buildPostOcrEscrowTx({
|
||||
chain,
|
||||
buyerKey,
|
||||
advertRef: { txHash: "b".repeat(64), index: 0 },
|
||||
request: validRequest(),
|
||||
payment_lovelace: 200000n,
|
||||
})).rejects.toMatchObject({ reason: "advert ref not on chain" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user