const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const { runOcrForInvoiceId, runOcrOnce } = require("../../../src/jobs/ocrJob"); function buildClaimedInvoice(overrides = {}) { return { id: "11111111-1111-4111-8111-111111111111", file_sha256: "sha256-1", owner_employee_id: "employee-owner-1", uploaded_by_employee_id: "employee-upload-1", ocr_provider: "mock", ocr_type: "vat", storage_path: "20260428/invoice.txt", mime_type: "text/plain", ocr_attempts: 0, ...overrides, }; } function buildPrisma(claimedInvoices, { cachedOcrResult = null, invoiceConflicts = [] } = {}) { return { $queryRaw: vi.fn(async () => claimedInvoices), ocrResult: { findUnique: vi.fn(async () => cachedOcrResult), create: vi.fn(async () => ({ id: "ocr-result-1" })), update: vi.fn(async ({ data }) => ({ id: cachedOcrResult?.id, ...data })), }, invoicePool: { findMany: vi.fn(async () => invoiceConflicts), findFirst: vi.fn(async () => null), update: vi.fn(async () => ({ id: "invoice-1" })), }, }; } describe("ocrJob quality gate", () => { let tempDir = ""; beforeEach(async () => { vi.clearAllMocks(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r73-ocr-job-")); await fs.mkdir(path.join(tempDir, "20260428"), { recursive: true }); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); it("marks invoice available when OCR returns all required VAT fields", async () => { await fs.writeFile( path.join(tempDir, "20260428/invoice.txt"), "MOCK_SUCCESS_R73", "utf8", ); const prisma = buildPrisma([buildClaimedInvoice()]); const logger = { warn: vi.fn(), error: vi.fn(), }; const result = await runOcrOnce( prisma, { storageDir: tempDir, ocrBatchSize: 1, ocrLockTtlSeconds: 300, ocrMaxAttempts: 3, ocrProvider: "mock", }, logger, "ocr-test-worker", ); expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 }); expect(prisma.invoicePool.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "11111111-1111-4111-8111-111111111111" }, data: expect.objectContaining({ status: "available", ocr_provider: "mock", ocr_last_error: null, }), }), ); expect(prisma.ocrResult.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ ocr_provider: "mock", }), }), ); }); it("processes a directed invoice and emits processed metadata", async () => { await fs.writeFile( path.join(tempDir, "20260428/invoice.txt"), "MOCK_DIRECTED_SUCCESS_R73", "utf8", ); const prisma = buildPrisma([buildClaimedInvoice()]); const logger = { warn: vi.fn(), error: vi.fn(), }; const onInvoiceProcessed = vi.fn(); const result = await runOcrForInvoiceId( prisma, { storageDir: tempDir, ocrBatchSize: 10, ocrLockTtlSeconds: 300, ocrMaxAttempts: 3, ocrProvider: "mock", }, logger, "11111111-1111-4111-8111-111111111111", "ocr-directed-worker", { onInvoiceProcessed }, ); expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 }); expect(onInvoiceProcessed).toHaveBeenCalledWith({ invoiceId: "11111111-1111-4111-8111-111111111111", status: "available", outcome: "available", ownerEmployeeId: "employee-owner-1", uploadedByEmployeeId: "employee-upload-1", }); }); it("marks invoice ocr_failed when required VAT fields are missing", async () => { await fs.writeFile( path.join(tempDir, "20260428/invoice.txt"), "MOCK_OCR_MISSING:invoiceCode,sellerName", "utf8", ); const prisma = buildPrisma([buildClaimedInvoice()]); const logger = { warn: vi.fn(), error: vi.fn(), }; const result = await runOcrOnce( prisma, { storageDir: tempDir, ocrBatchSize: 1, ocrLockTtlSeconds: 300, ocrMaxAttempts: 3, ocrProvider: "mock", }, logger, "ocr-test-worker", ); expect(result).toEqual({ claimed: 1, available: 0, duplicate: 0, failed: 1 }); expect(prisma.invoicePool.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "11111111-1111-4111-8111-111111111111" }, data: expect.objectContaining({ status: "ocr_failed", ocr_provider: "mock", ocr_last_error: "missing_required_fields:invoiceCode,sellerName", }), }), ); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ invoiceId: "11111111-1111-4111-8111-111111111111", qualityError: "missing_required_fields:invoiceCode,sellerName", }), "OCR invoice marked as failed for missing required fields", ); }); it("stores binary PDF mock OCR metadata without unsafe null marker text", async () => { const pdfPayload = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0x10, 0x20, 0x41]); await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), pdfPayload); const prisma = buildPrisma([ buildClaimedInvoice({ storage_path: "20260428/invoice.pdf", mime_type: "application/pdf", file_sha256: "sha256-pdf-binary", }), ]); const logger = { warn: vi.fn(), error: vi.fn(), }; const result = await runOcrOnce( prisma, { storageDir: tempDir, ocrBatchSize: 1, ocrLockTtlSeconds: 300, ocrMaxAttempts: 3, ocrProvider: "mock", }, logger, "ocr-test-worker", ); expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 }); const createdPayload = prisma.ocrResult.create.mock.calls[0][0].data.result_json; expect(createdPayload.raw).toEqual( expect.objectContaining({ provider: "mock", marker: null, sourceSha256: expect.any(String), }), ); expect(JSON.stringify(createdPayload)).not.toContain("\\u0000"); }); it("accepts a cached no-code electronic train ticket and checks number-only duplicates", async () => { await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), "%PDF-1.4 synthetic"); const cachedOcrResult = { id: "ocr-result-train", result_json: { documentSubtype: "ElectronicTrainTicketFull", documentCategory: "electronic_train_ticket", documentCount: 1, transportMode: "pdf_native", invoiceCode: null, invoiceNumber: "251234567890", invoiceDate: "2026-07-26", amount: "95.00", taxAmount: "5.00", totalAmount: "100.00", sellerName: null, buyerName: "合成公司", raw: { UserName: "SENSITIVE-CACHED-PASSENGER", UserID: "SENSITIVE-CACHED-ID", }, }, }; const prisma = buildPrisma( [ buildClaimedInvoice({ ocr_provider: "tencent", storage_path: "20260428/invoice.pdf", mime_type: "application/pdf", }), ], { cachedOcrResult }, ); const logger = { warn: vi.fn(), error: vi.fn() }; const result = await runOcrOnce( prisma, { storageDir: tempDir, ocrBatchSize: 1, ocrLockTtlSeconds: 300, ocrMaxAttempts: 3, ocrProvider: "tencent", }, logger, "ocr-test-worker", ); expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 }); expect(prisma.invoicePool.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "available", invoice_code: null, invoice_number: "251234567890", seller_name: null, buyer_name: "合成公司", }), }), ); expect(prisma.invoicePool.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ AND: expect.arrayContaining([ expect.objectContaining({ OR: expect.arrayContaining([ { invoice_code: null, invoice_number: "251234567890" }, ]), }), ]), }), }), ); expect(prisma.ocrResult.create).not.toHaveBeenCalled(); expect(prisma.ocrResult.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "ocr-result-train" }, data: expect.objectContaining({ result_json: expect.not.objectContaining({ raw: expect.anything() }), }), }), ); expect(JSON.stringify(prisma.ocrResult.update.mock.calls[0][0])).not.toContain( "SENSITIVE-CACHED", ); }); it("marks a cached multiple-invoice result as a quality failure without provider retry", async () => { await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), "%PDF-1.4 synthetic"); const prisma = buildPrisma( [ buildClaimedInvoice({ ocr_provider: "tencent", storage_path: "20260428/invoice.pdf", mime_type: "application/pdf", }), ], { cachedOcrResult: { id: "ocr-result-multiple", result_json: { documentSubtype: null, documentCategory: null, documentCount: 2, qualityError: "multiple_invoices_not_supported", transportMode: "pdf_native", }, }, }, ); const logger = { warn: vi.fn(), error: vi.fn() }; const result = await runOcrOnce( prisma, { storageDir: tempDir, ocrBatchSize: 1, ocrLockTtlSeconds: 300, ocrMaxAttempts: 3, ocrProvider: "tencent", }, logger, "ocr-test-worker", ); expect(result).toEqual({ claimed: 1, available: 0, duplicate: 0, failed: 1 }); expect(prisma.invoicePool.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "ocr_failed", ocr_last_error: "multiple_invoices_not_supported", ocr_attempts: { increment: 1 }, }), }), ); expect(logger.error).not.toHaveBeenCalled(); }); });