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:
FilipBl4gojevic
2026-08-10 16:40:00 +02:00
parent d5349057b0
commit 069edcb076
24 changed files with 3081 additions and 11 deletions

View 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/);
});
});

View 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);
});
});

View 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");
});
});

View 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" });
});
});