mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* Integration tests for the ocr-pdf tool (/api/v1/tools/pdf/ocr-pdf).
|
||||
*
|
||||
* The OCR bundle (PaddleOCR / Tesseract) is 3-4 GB and not installed locally
|
||||
* or in any verification environment this wave. The 501 gate is always hit.
|
||||
* The bundle-gated happy path lives in a skipped describe for future
|
||||
* in-container-after-install runs. The 501 contract + python-side conventions
|
||||
* carry the verification.
|
||||
* Fast PDF OCR is built in. Balanced and Best are optional accurate-pack
|
||||
* capabilities and are rejected before enqueue when no healthy v3 runtime is
|
||||
* active.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
@@ -19,6 +17,27 @@ import {
|
||||
|
||||
const PDF = readFixture(fixtures.document.pdf3);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
enqueueToolJob: vi.fn(),
|
||||
getOcrRuntimeCapability: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@snapotter/ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@snapotter/ai")>();
|
||||
return {
|
||||
...actual,
|
||||
getOcrRuntimeCapability: mocks.getOcrRuntimeCapability,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../apps/api/src/jobs/enqueue.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../apps/api/src/jobs/enqueue.js")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueToolJob: mocks.enqueueToolJob,
|
||||
};
|
||||
});
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
@@ -33,10 +52,19 @@ afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("ocr-pdf", () => {
|
||||
// -- 501 gate (always fires locally: OCR bundle never installed) --
|
||||
beforeEach(() => {
|
||||
mocks.enqueueToolJob.mockReset();
|
||||
mocks.enqueueToolJob.mockResolvedValue(undefined);
|
||||
mocks.getOcrRuntimeCapability.mockReset();
|
||||
mocks.getOcrRuntimeCapability.mockReturnValue({
|
||||
available: false,
|
||||
qualities: [],
|
||||
providers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 501 FEATURE_NOT_INSTALLED when bundle is absent", async () => {
|
||||
describe("ocr-pdf", () => {
|
||||
it("enqueues built-in Fast OCR when the accurate pack is absent", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
@@ -44,7 +72,70 @@ describe("ocr-pdf", () => {
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
{ name: "settings", content: JSON.stringify({ quality: "fast", pages: "1" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pdf/ocr-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
expect(json.async).toBe(true);
|
||||
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolId: "ocr-pdf",
|
||||
pool: "ai",
|
||||
settings: expect.objectContaining({ quality: "fast", pages: "1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to Fast when the accurate pack is absent", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ pages: "1" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pdf/ocr-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({ quality: "fast", pages: "1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the legacy paddleocr engine to Balanced like batch and pipeline ingress", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ engine: "paddleocr", pages: "1" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
@@ -58,11 +149,40 @@ describe("ocr-pdf", () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("ocr");
|
||||
expect(json.featureName).toBe("OCR");
|
||||
expect(json.estimatedSize).toBeDefined();
|
||||
expect(JSON.parse(res.body)).toMatchObject({
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: "ocr",
|
||||
requestedQuality: "balanced",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 501 FEATURE_NOT_INSTALLED for an unavailable accurate tier", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ quality: "best" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pdf/ocr-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
expect(JSON.parse(res.body)).toMatchObject({
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: "ocr",
|
||||
requestedQuality: "best",
|
||||
});
|
||||
});
|
||||
|
||||
// -- Auth gate --
|
||||
@@ -88,9 +208,7 @@ describe("ocr-pdf", () => {
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
// -- Validation (501 fires before settings parse, so bad quality also 501s) --
|
||||
|
||||
it("returns 501 even with invalid quality (gate fires first)", async () => {
|
||||
it("validates settings before checking the optional pack", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
@@ -114,46 +232,9 @@ describe("ocr-pdf", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
// 501 because the bundle gate fires before settings validation
|
||||
expect(res.statusCode).toBe(501);
|
||||
expect(res.statusCode).toBe(400);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
// -- Bundle-gated happy path (skipped: OCR bundle is 3-4 GB) --
|
||||
|
||||
// The OCR bundle is not installed in any verification environment this wave.
|
||||
// These tests exist for future in-container runs after bundle install.
|
||||
// The 501 contract + python-side conventions carry the verification.
|
||||
describe.skip("with ocr bundle installed", () => {
|
||||
it("extracts text from PDF (202 + async)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test-3page.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ quality: "fast", pages: "1" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pdf/ocr-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
expect(json.async).toBe(true);
|
||||
}, 300_000);
|
||||
expect(json.error).toBe("Invalid settings");
|
||||
expect(mocks.getOcrRuntimeCapability).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user