const Fastify = require("fastify"); const reimbursementRoutes = require("../../../src/routes/reimbursements"); const { AppError } = require("../../../src/utils/AppError"); const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, roleCodes: ["manager"], permissionCodes: ["expense.view_all"], }; const queryRaw = options.queryRaw ?? vi .fn() .mockResolvedValueOnce([ { status: "draft", count: 2, total_amount: "100.00" }, { status: "paid", count: 1, total_amount: "80.00" }, ]) .mockResolvedValueOnce([{ avg_processing_days: 3.5 }]); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("hasPermission", (employee, permissionCode) => { const permissionCodes = employee.permissionCodes ?? []; return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; if (!app.hasPermission(actor, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("config", { appTimezone: "Asia/Shanghai" }); app.decorate("prisma", { $queryRaw: queryRaw, }); app.setErrorHandler((error, request, reply) => { reply.status(error.statusCode || 500).send({ requestId: request.id, error: { code: error.code || "SYS_001", message: error.message, details: error.details ?? null, }, }); }); await app.register(reimbursementRoutes, { prefix: "/api" }); return { app, queryRaw }; } describe("reimbursements stats routes", () => { it("returns summary with filters and zero-filled status buckets", async () => { const { app, queryRaw } = await buildApp(); const response = await app.inject({ method: "GET", url: `/api/reimbursements/stats/summary?dateFrom=2026-01-01&dateTo=2026-03-31&departmentId=${DEPARTMENT_ID}`, }); expect(response.statusCode).toBe(200); expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw.mock.calls[0][0].values).toEqual( expect.arrayContaining([ new Date("2026-01-01T00:00:00.000Z"), new Date("2026-03-31T00:00:00.000Z"), DEPARTMENT_ID, ACTOR_ID, ]), ); expect(response.json().data).toEqual({ totalCount: 3, totalAmount: "180.00", byStatus: { draft: { count: 2, totalAmount: "100.00" }, reviewing: { count: 0, totalAmount: "0.00" }, approved: { count: 0, totalAmount: "0.00" }, paid: { count: 1, totalAmount: "80.00" }, rejected: { count: 0, totalAmount: "0.00" }, cancelled: { count: 0, totalAmount: "0.00" }, }, avgProcessingDays: 3.5, }); await app.close(); }); it("returns empty summary when there is no matching data", async () => { const { app } = await buildApp({ queryRaw: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([{ avg_processing_days: null }]), }); const response = await app.inject({ method: "GET", url: "/api/reimbursements/stats/summary", }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ totalCount: 0, totalAmount: "0.00", avgProcessingDays: null, byStatus: { draft: { count: 0, totalAmount: "0.00" }, reviewing: { count: 0, totalAmount: "0.00" }, approved: { count: 0, totalAmount: "0.00" }, paid: { count: 0, totalAmount: "0.00" }, rejected: { count: 0, totalAmount: "0.00" }, cancelled: { count: 0, totalAmount: "0.00" }, }, }); await app.close(); }); it("returns monthly trends with count and paid metrics", async () => { const { app } = await buildApp({ queryRaw: vi.fn().mockResolvedValue([ { period_start: new Date("2026-02-01T00:00:00.000Z"), count: 4, amount: "220.00", paid_count: 2, paid_amount: "120.00", }, ]), }); const response = await app.inject({ method: "GET", url: "/api/reimbursements/stats/trend?granularity=month", }); expect(response.statusCode).toBe(200); expect(response.json().data.items).toEqual([ { period: "2026-02", count: 4, amount: "220.00", paidCount: 2, paidAmount: "120.00", }, ]); await app.close(); }); it("returns top expense types and approval bottlenecks", async () => { const queryRaw = vi .fn() .mockResolvedValueOnce([ { expense_type_id: "type-1", expense_type_name: "差旅交通", count: 5, total_amount: "520.00", }, ]) .mockResolvedValueOnce([ { assignee_id: null, assignee_role: "manager", assignee_name: null, pending_count: 3, oldest_pending_days: 5, }, ]); const { app } = await buildApp({ queryRaw }); const topResponse = await app.inject({ method: "GET", url: "/api/reimbursements/stats/top-expense-types?limit=5", }); const bottleneckResponse = await app.inject({ method: "GET", url: "/api/reimbursements/stats/approval-bottleneck", }); expect(topResponse.statusCode).toBe(200); expect(topResponse.json().data.items).toEqual([ { expenseTypeId: "type-1", expenseTypeName: "差旅交通", count: 5, totalAmount: "520.00", }, ]); expect(bottleneckResponse.statusCode).toBe(200); expect(bottleneckResponse.json().data.items).toEqual([ { assigneeId: null, assigneeName: "主管", pendingCount: 3, oldestPendingDays: 5, }, ]); await app.close(); }); it("rejects users without expense.view_all permission", async () => { const { app } = await buildApp({ actor: { id: ACTOR_ID, roleCodes: ["employee"], permissionCodes: ["expense.view_own"], }, queryRaw: vi.fn(), }); const response = await app.inject({ method: "GET", url: "/api/reimbursements/stats/summary", }); expect(response.statusCode).toBe(403); await app.close(); }); });