Files
SnapOtter/tests/integration/tools/image/ocr.test.ts
T
SnapOtterandGitHub 991c981529 fix: make OCR portable and reliable across AMD64 and ARM64 (#519)
* fix: make OCR portable and reliable

* fix: harden OCR installation portability

* fix: pin OCR partials across downloads

* fix: make OCR execution reliably asynchronous

* fix: harden OCR portability and docs routes

* fix: preserve decoder and docs safeguards
2026-07-15 03:34:24 +08:00

298 lines
9.5 KiB
TypeScript

/**
* Integration tests for the OCR AI tool (/api/v1/tools/image/ocr).
*
* OCR is always accepted asynchronously. Fast is built in; Balanced and Best
* return 501 when the optional accurate runtime is unavailable.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import { AcceptedJobTimeoutError, waitForAcceptedJobOrCancel } from "../../settle-job.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const PNG = readFixture(fixtures.image.base.png200);
const HEIC = readFixture(fixtures.image.base.heic200);
const TINY = readFixture(fixtures.image.edge.px1);
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function awaitOcrResult(
response: { statusCode: number; body: string },
options: { allowFailure?: boolean } = {},
): Promise<Record<string, unknown> | undefined> {
if (response.statusCode === 501) return undefined;
expect(response.statusCode).toBe(202);
const accepted = JSON.parse(response.body) as { jobId?: string; async?: boolean };
expect(accepted).toMatchObject({ jobId: expect.any(String), async: true });
try {
const result = await waitForAcceptedJobOrCancel(accepted.jobId as string, "ai", 25_000);
return (result?.resultPayload ?? {}) as Record<string, unknown>;
} catch (error) {
if (error instanceof AcceptedJobTimeoutError) throw error;
if (options.allowFailure) return undefined;
throw error;
}
}
describe("ocr", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("accepts built-in Fast OCR asynchronously", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ quality: "fast", language: "en" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
const result = await awaitOcrResult(res);
expect(result).toMatchObject({ requestedQuality: "fast", actualQuality: "fast" });
}, 60_000);
it("processes with default settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
const result = await awaitOcrResult(res);
expect(result?.text).toBeDefined();
expect(result?.engine).toBeDefined();
}, 60_000);
it("accepts quality=fast", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ quality: "fast", language: "en" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
await awaitOcrResult(res);
}, 60_000);
it("accepts quality=best or reports the optional runtime unavailable", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ quality: "best" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([202, 501]).toContain(res.statusCode);
await awaitOcrResult(res);
}, 60_000);
it("accepts explicit language and enhance=false", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ language: "en", enhance: false }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
await awaitOcrResult(res);
}, 60_000);
it("accepts the backward-compatible engine param", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ engine: "tesseract", language: "en" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
await awaitOcrResult(res);
}, 60_000);
it(
"handles HEIC input asynchronously",
{ timeout: 120_000 },
async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({ quality: "fast", language: "en" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
await awaitOcrResult(res);
},
60_000,
);
it("handles a 1x1 pixel input without leaking its async job", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({ quality: "fast", language: "en" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
await awaitOcrResult(res, { allowFailure: true });
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without a file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ quality: "fast" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "{{bad json}}" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid quality value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ quality: "ultra" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid language value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ language: "klingon" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/ocr",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});