146 lines
6.1 KiB
TypeScript
146 lines
6.1 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The OCR adapter against the fake platform of @conusai-kit/testing, through the real
|
|
// `@conusai-sdk/ocr` client (kit-platform's `DocumentReader`): the job is submitted, polled and read
|
|
// over HTTP exactly as the platform's async job API does it, so what is proven here is the adapter's
|
|
// calls and not a stub of them.
|
|
|
|
import { readFileSync } from "node:fs"
|
|
import { OcrJobFailed, sdkDocumentOcr } from "@app/modules/documents"
|
|
import { OcrError } from "@conusai-sdk/ocr"
|
|
import { afterEach, describe, expect, it } from "vitest"
|
|
import { closeApps, makeDocumentsApp, uploadAndProcess } from "./support/documents-app.js"
|
|
import { startOcr } from "./support/ocr.js"
|
|
|
|
const fixture = JSON.parse(
|
|
readFileSync(new URL("../modules/documents/__tests__/fixtures/ocr-document.json", import.meta.url), "utf8"),
|
|
) as Record<string, unknown>
|
|
|
|
const open: Array<{ close(): Promise<void> }> = []
|
|
afterEach(async () => {
|
|
await closeApps()
|
|
await Promise.all(open.splice(0).map((platform) => platform.close()))
|
|
})
|
|
|
|
async function ocr() {
|
|
const started = await startOcr(fixture)
|
|
open.push(started.platform)
|
|
return started
|
|
}
|
|
|
|
const stateOf = async (platform: { url: string }) =>
|
|
(await (await fetch(`${platform.url}/__e2e/state`)).json()) as { ocr: unknown[] }
|
|
|
|
describe("the OCR adapter on the fake platform", () => {
|
|
it("submits a document, polls its job and returns the document the platform read", async () => {
|
|
const { ocr: port } = await ocr()
|
|
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
|
filename: "a.pdf",
|
|
idempotencyKey: "k-1",
|
|
})
|
|
const read = await port.wait(jobId)
|
|
expect(read.engine.name).toBe("fixture-engine")
|
|
expect(read.pages).toHaveLength(3)
|
|
})
|
|
|
|
it("answers a repeated idempotency key with the same job, so a retried job pays once", async () => {
|
|
const { ocr: port, platform } = await ocr()
|
|
const bytes = new TextEncoder().encode("%PDF-1.4 same")
|
|
const first = await port.submit(bytes, { filename: "a.pdf", idempotencyKey: "k-same" })
|
|
const second = await port.submit(bytes, { filename: "a.pdf", idempotencyKey: "k-same" })
|
|
expect(second.jobId).toBe(first.jobId)
|
|
expect((await stateOf(platform)).ocr).toHaveLength(1)
|
|
})
|
|
|
|
it("reads a job the platform failed as permanent", async () => {
|
|
const { ocr: port, platform } = await ocr()
|
|
platform.ocr.failJobs(true)
|
|
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
|
filename: "a.pdf",
|
|
idempotencyKey: "k-f",
|
|
})
|
|
await expect(port.wait(jobId)).rejects.toBeInstanceOf(OcrJobFailed)
|
|
})
|
|
|
|
it("draws a page of a finished job through the platform's page route: bytes, a media type, the asked dpi", async () => {
|
|
const { ocr: port, platform } = await ocr()
|
|
platform.ocr.setPageImage(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 7]))
|
|
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
|
filename: "a.pdf",
|
|
idempotencyKey: "k-p",
|
|
})
|
|
await port.wait(jobId)
|
|
const image = await port.page(jobId, 2)
|
|
expect(image?.mediaType).toBe("image/png")
|
|
expect([...(image?.bytes ?? [])]).toEqual([0x89, 0x50, 0x4e, 0x47, 7])
|
|
const state = (await (await fetch(`${platform.url}/__e2e/state`)).json()) as {
|
|
ocrPages: Array<{ job: number; page: number; dpi: number }>
|
|
}
|
|
expect(state.ocrPages).toEqual([{ job: jobId, page: 2, dpi: 150 }])
|
|
})
|
|
|
|
it("reads a page the platform has no image of (a job it no longer holds, a page past the end) as no image", async () => {
|
|
const { ocr: port } = await ocr()
|
|
expect(await port.page(999, 1)).toBeUndefined()
|
|
const { jobId } = await port.submit(new TextEncoder().encode("%PDF-1.4"), {
|
|
filename: "a.pdf",
|
|
idempotencyKey: "k-q",
|
|
})
|
|
await port.wait(jobId)
|
|
// the fixture has three pages
|
|
expect(await port.page(jobId, 4)).toBeUndefined()
|
|
})
|
|
|
|
it("reads a failure of the page route that is not the page's as retryable", async () => {
|
|
const down = new OcrError("OCR request failed: network", { type: "network" })
|
|
const adapter = sdkDocumentOcr({
|
|
submit: async () => ({ id: 1 }) as never,
|
|
wait: async () => ({}) as never,
|
|
page: async () => Promise.reject(down),
|
|
})
|
|
await expect(adapter.page(1, 1)).rejects.toBe(down)
|
|
})
|
|
|
|
it("reads anything else as retryable: the error is thrown on, unchanged", async () => {
|
|
const transient = new OcrError("OCR request failed: network", { type: "network" })
|
|
const adapter = sdkDocumentOcr({
|
|
submit: async () => ({ id: 1 }) as never,
|
|
wait: async () => Promise.reject(transient),
|
|
page: async () => ({ bytes: new Uint8Array(), contentType: "image/png" }),
|
|
})
|
|
await expect(adapter.wait(1)).rejects.toBe(transient)
|
|
})
|
|
|
|
it("carries a whole upload through the worker over HTTP: the fixture becomes proposals", async () => {
|
|
const app = await makeDocumentsApp({ document: fixture })
|
|
const project = await app.seedProject("acme", "owner@acme.test")
|
|
const file = await uploadAndProcess(app, project)
|
|
const view = (await project.call.callByName("documents.document.get", {
|
|
...project.slugs,
|
|
fileId: file.fileId,
|
|
})) as {
|
|
document: { status: string; pending: number }
|
|
}
|
|
expect(view.document).toMatchObject({ status: "extracted", pending: 4 })
|
|
expect((await stateOf(app.platform)).ocr).toHaveLength(1)
|
|
})
|
|
|
|
it("marks a document extracted with nothing to review when the platform reads a page with no lines", async () => {
|
|
const app = await makeDocumentsApp({
|
|
document: {
|
|
...fixture,
|
|
page_count: 1,
|
|
pages: [{ index: 0, width: 10, height: 10, class: "empty", text: "", lines: [], blocks: [] }],
|
|
},
|
|
})
|
|
const project = await app.seedProject("acme", "owner@acme.test")
|
|
const file = await uploadAndProcess(app, project)
|
|
const view = (await project.call.callByName("documents.document.get", {
|
|
...project.slugs,
|
|
fileId: file.fileId,
|
|
})) as {
|
|
document: { status: string; pending: number }
|
|
}
|
|
expect(view.document).toMatchObject({ status: "extracted", pending: 0 })
|
|
})
|
|
})
|