const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const { registerInvoicePoolReadRoutes } = require("../../../src/routes/invoicePool/read"); const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; const OWNER_ID = "22222222-2222-4222-8222-222222222222"; const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333"; function buildApp({ permissionCodes = ["invoice.view_own"], employeeId = OWNER_ID, reimbursement = { id: REIMBURSEMENT_ID, owner_id: OWNER_ID, submitter_id: SUBMITTER_ID, status: "draft", }, bindableItems = [], } = {}) { const prisma = { reimbursementForm: { findUnique: vi.fn(async ({ where }) => { if (where.id !== REIMBURSEMENT_ID) { return null; } return reimbursement; }), }, invoicePool: { findMany: vi.fn(async ({ where, take }) => bindableItems .filter((item) => { if (item.owner_employee_id !== where.owner_employee_id) { return false; } if (item.status !== where.status) { return false; } if (item.linked_reimbursement_id !== where.linked_reimbursement_id) { return false; } if (!where.OR?.length) { return true; } const haystacks = [item.invoice_code, item.invoice_number, item.seller_name] .filter(Boolean) .map((value) => String(value).toLowerCase()); return where.OR.some((condition) => { const [field, matcher] = Object.entries(condition)[0]; const needle = String(matcher.contains).toLowerCase(); const value = item[field] ? String(item[field]).toLowerCase() : ""; return value.includes(needle) || haystacks.some((entry) => entry.includes(needle)); }); }) .sort((left, right) => right.created_at.getTime() - left.created_at.getTime()) .slice(0, take), ), }, }; const app = Fastify(); app.decorate("prisma", prisma); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: employeeId, permissionCodes, roleCodes: [], }; }); app.decorate( "hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.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: {} }, }); }); return app.register(registerInvoicePoolReadRoutes, { prefix: "/api" }).then(() => ({ app, prisma })); } describe("GET /api/invoice-pool/bindable", () => { it("returns bindable invoices for reimbursement submitter and uses reimbursement owner for filtering", async () => { const bindableItems = [ { id: "invoice-2", owner_employee_id: OWNER_ID, status: "available", linked_reimbursement_id: null, invoice_code: "FP002", invoice_number: "0002", invoice_date: new Date("2026-04-08T00:00:00.000Z"), seller_name: "上海供应商", total_amount: "88.88", original_name: "receipt-2.pdf", created_at: new Date("2026-04-09T09:00:00.000Z"), }, { id: "invoice-1", owner_employee_id: OWNER_ID, status: "available", linked_reimbursement_id: null, invoice_code: "FP001", invoice_number: "0001", invoice_date: new Date("2026-04-07T00:00:00.000Z"), seller_name: "北京供应商", total_amount: "66.66", original_name: "receipt-1.pdf", created_at: new Date("2026-04-08T09:00:00.000Z"), }, ]; const { app, prisma } = await buildApp({ employeeId: SUBMITTER_ID, bindableItems, }); const response = await app.inject({ method: "GET", url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}&limit=1`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items).toHaveLength(1); expect(response.json().data.items[0]).toMatchObject({ id: "invoice-2", invoiceCode: "FP002", sellerName: "上海供应商", totalAmount: "88.88", }); expect(prisma.invoicePool.findMany).toHaveBeenCalledWith( expect.objectContaining({ take: 1, where: expect.objectContaining({ owner_employee_id: OWNER_ID, status: "available", linked_reimbursement_id: null, }), }), ); await app.close(); }); it("rejects requests without invoice.view_own permission", async () => { const { app } = await buildApp({ permissionCodes: [] }); const response = await app.inject({ method: "GET", url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); await app.close(); }); it("rejects reimbursements that are not owned or submitted by current user", async () => { const { app } = await buildApp({ employeeId: "44444444-4444-4444-8444-444444444444" }); const response = await app.inject({ method: "GET", url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); await app.close(); }); it("rejects reimbursements not in draft or rejected status", async () => { const { app } = await buildApp({ reimbursement: { id: REIMBURSEMENT_ID, owner_id: OWNER_ID, submitter_id: SUBMITTER_ID, status: "reviewing", }, }); const response = await app.inject({ method: "GET", url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind" }); await app.close(); }); it("applies fuzzy search and returns empty results when nothing matches", async () => { const { app } = await buildApp({ bindableItems: [ { id: "invoice-1", owner_employee_id: OWNER_ID, status: "available", linked_reimbursement_id: null, invoice_code: "A100", invoice_number: "001", invoice_date: new Date("2026-04-07T00:00:00.000Z"), seller_name: "北京供应商", total_amount: "66.66", original_name: "receipt-1.pdf", created_at: new Date("2026-04-08T09:00:00.000Z"), }, ], }); const response = await app.inject({ method: "GET", url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}&search=%E4%B8%8A%E6%B5%B7`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items).toEqual([]); await app.close(); }); });