const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const Fastify = require("fastify"); const multipart = require("@fastify/multipart"); const { AppError } = require("../../../src/utils/AppError"); const invoicePoolRoutes = require("../../../src/routes/invoicePool"); function buildMultipartRequest({ filename, contentType, content, fields = [] }) { const boundary = "----codex-invoice-upload-boundary"; const segments = [ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`, ), Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), Buffer.from("\r\n"), ]; for (const [fieldName, value] of fields) { segments.push( Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"\r\n\r\n${value}\r\n`, ), ); } segments.push(Buffer.from(`--${boundary}--\r\n`)); return { payload: Buffer.concat(segments), headers: { "content-type": `multipart/form-data; boundary=${boundary}`, }, }; } async function buildApp({ ocrProvider = "off", jobDriver = "cron", bossSend = null, invoiceConflicts = [], statusRows = [], permissionCodes = ["invoice.upload"], maxUploadSizeBytes = 5 * 1024 * 1024, } = {}) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r71-invoice-upload-")); const invoicePoolCreate = vi.fn(async ({ data }) => ({ id: "11111111-1111-4111-8111-111111111111", status: data.status, file_sha256: data.file_sha256, })); const invoicePoolFindMany = vi.fn(async (args) => { if (args?.where?.id?.in) { return statusRows; } return invoiceConflicts; }); const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); const prisma = { invoicePool: { findMany: invoicePoolFindMany, create: invoicePoolCreate, }, auditLog: { create: auditCreate, }, $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", storageDir: tempDir, maxUploadSizeBytes, invoiceArchiveUnusedDays: 30, ocrProvider, jobDriver, }); if (bossSend) { app.decorate("boss", { send: bossSend }); } app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: "actor-1", roleCodes: ["employee"], permissionCodes, }; }); app.decorate("hasPermission", (actor, permissionCode) => { return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); }); app.decorate("requirePermission", (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: "服务器内部错误", details: {} }, }); }); await app.register(multipart, { attachFieldsToBody: false, limits: { files: 1, fileSize: maxUploadSizeBytes }, }); await app.register(invoicePoolRoutes, { prefix: "/api" }); return { app, prisma, tempDir }; } describe("invoicePool upload route", () => { it("consumes the file stream before reading trailing multipart fields", async () => { const { app, prisma, tempDir } = await buildApp(); try { const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...buildMultipartRequest({ filename: "invoice.pdf", contentType: "application/pdf", content: "%PDF-1.4 invoice payload", fields: [["ocr_type", "vat"]], }).headers, }, payload: buildMultipartRequest({ filename: "invoice.pdf", contentType: "application/pdf", content: "%PDF-1.4 invoice payload", fields: [["ocr_type", "vat"]], }).payload, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ invoicePoolId: "11111111-1111-4111-8111-111111111111", status: "available", }), ); expect(prisma.invoicePool.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ owner_employee_id: "actor-1", original_name: "invoice.pdf", mime_type: "application/pdf", ocr_type: "vat", status: "available", }), }), ); const storedPath = prisma.invoicePool.create.mock.calls[0][0].data.storage_path; const storedBuffer = await fs.readFile(path.join(tempDir, storedPath)); expect(storedBuffer.toString("utf8")).toBe("%PDF-1.4 invoice payload"); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("puts VAT uploads into pending_ocr and enqueues a directed OCR job when OCR provider is enabled", async () => { const bossSend = vi.fn(async () => ({ id: "ocr-job-1" })); const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock", jobDriver: "pgboss", bossSend, }); try { const requestPayload = buildMultipartRequest({ filename: "invoice.pdf", contentType: "application/pdf", content: "%PDF-1.4 mock invoice", fields: [["ocr_type", "vat"]], }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ invoicePoolId: "11111111-1111-4111-8111-111111111111", status: "pending_ocr", }), ); expect(prisma.invoicePool.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ ocr_type: "vat", ocr_provider: "mock", status: "pending_ocr", }), }), ); expect(bossSend).toHaveBeenCalledWith("ocr.invoice", { invoiceId: "11111111-1111-4111-8111-111111111111", }); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("rejects image invoice uploads because formal invoice OCR only accepts PDF", async () => { const { app, prisma, tempDir } = await buildApp(); try { const requestPayload = buildMultipartRequest({ filename: "invoice.png", contentType: "image/png", content: "mock-image", fields: [["ocr_type", "vat"]], }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(400); expect(response.json().error).toEqual( expect.objectContaining({ code: "VALID_001", message: "请上传 PDF 发票文件", details: { file: "pdf_invoice_required" }, }), ); expect(prisma.invoicePool.create).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("rejects a PDF above the 7 MiB Tencent OCR input boundary before storage", async () => { const { app, prisma, tempDir } = await buildApp({ maxUploadSizeBytes: 8 * 1024 * 1024, }); try { const requestPayload = buildMultipartRequest({ filename: "invoice.pdf", contentType: "application/pdf", content: Buffer.alloc(7 * 1024 * 1024 + 1, 0x20), fields: [["ocr_type", "vat"]], }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(400); expect(response.json().error).toEqual( expect.objectContaining({ code: "VALID_001", message: "PDF 发票不能超过 7 MiB", details: { file: "ocr_pdf_too_large", maxBytes: 7 * 1024 * 1024, }, }), ); expect(prisma.invoicePool.create).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("reuses same-owner existing invoice instead of creating a duplicate file record", async () => { const existingInvoiceId = "22222222-2222-4222-8222-222222222222"; const { app, prisma, tempDir } = await buildApp({ invoiceConflicts: [ { id: existingInvoiceId, owner_employee_id: "actor-1", uploaded_by_employee_id: "actor-1", status: "available", archived_reason: null, linked_reimbursement_id: null, linked_item_id: null, travel_order_id: null, invoice_code: null, invoice_number: null, file_sha256: "unused-in-test", linked_reimbursement: null, }, ], }); try { const requestPayload = buildMultipartRequest({ filename: "same.pdf", contentType: "application/pdf", content: "%PDF-1.4 same invoice payload", fields: [["ocr_type", "vat"]], }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ invoicePoolId: existingInvoiceId, status: "available", uploadAction: "reuse_existing", unavailableReason: "duplicate_available_in_company", }), ); expect(prisma.invoicePool.create).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("blocks upload when the same company invoice has already been used", async () => { const { app, prisma, tempDir } = await buildApp({ invoiceConflicts: [ { id: "33333333-3333-4333-8333-333333333333", owner_employee_id: "actor-1", uploaded_by_employee_id: "actor-1", status: "bound", archived_reason: null, linked_reimbursement_id: "44444444-4444-4444-8444-444444444444", linked_item_id: "55555555-5555-4555-8555-555555555555", travel_order_id: null, invoice_code: null, invoice_number: null, file_sha256: "unused-in-test", linked_reimbursement: { form_number: "BX20260508001", status: "reviewing", owner_id: "actor-1", submitter_id: "actor-1", }, }, ], }); try { const requestPayload = buildMultipartRequest({ filename: "used.pdf", contentType: "application/pdf", content: "%PDF-1.4 used invoice payload", fields: [["ocr_type", "vat"]], }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ invoicePoolId: null, status: "bound", uploadAction: "blocked", unavailableReason: "used_by_company", unavailableMessage: "该发票已用于报销,不能重复添加", }), ); expect(prisma.invoicePool.create).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("returns readable invoice statuses for upload polling", async () => { const invoiceId = "66666666-6666-4666-8666-666666666666"; const { app, tempDir } = await buildApp({ permissionCodes: ["invoice.view_own"], statusRows: [ { id: invoiceId, status: "archived", archived_reason: "used_complete", owner_employee_id: "actor-1", uploaded_by_employee_id: "actor-1", linked_reimbursement_id: "77777777-7777-4777-8777-777777777777", linked_item_id: null, travel_order_id: null, invoice_code: "044001900111", invoice_number: "12345678", invoice_date: new Date("2026-05-08T00:00:00.000Z"), seller_name: "北京测试有限公司", total_amount: "1683.00", original_name: "invoice.pdf", ocr_attempts: 1, ocr_last_error: null, ocr_completed_at: new Date("2026-05-08T01:00:00.000Z"), updated_at: new Date("2026-05-08T01:01:00.000Z"), linked_reimbursement: { owner_id: "actor-1", submitter_id: "actor-1", }, }, ], }); try { const response = await app.inject({ method: "GET", url: `/api/invoice-pool/status?ids=${invoiceId}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ items: [ expect.objectContaining({ id: invoiceId, status: "archived", reason: "used_by_company", message: "该发票已用于报销,不能重复添加", totalAmount: "1683.00", }), ], missingIds: [], }); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); });