const ExcelJS = require("exceljs"); const Fastify = require("fastify"); const exportsRoutes = require("../../../src/routes/exports"); const { AppError } = require("../../../src/utils/AppError"); const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; function buildPayslipRow() { return { id: "payslip-1", employee_id: "employee-1", pay_month: new Date("2026-04-01T00:00:00.000Z"), status: "issued", gross_amount: "10000.00", deduction_amount: "600.00", net_amount: "9400.00", issued_at: new Date("2026-04-30T09:00:00.000Z"), created_at: new Date("2026-04-30T08:00:00.000Z"), created_by: ADMIN_ID, updated_at: new Date("2026-04-30T09:00:00.000Z"), updated_by: ADMIN_ID, }; } async function buildApp({ actor = { id: ADMIN_ID, permissionCodes: ["payroll.manage"] }, } = {}) { const app = Fastify(); const salaryItems = [ { id: "salary-item-1", name: "基本工资", item_type: "earning", sort_order: 10, enabled: true, }, { id: "salary-item-2", name: "社保扣除", item_type: "deduction", sort_order: 20, enabled: false, }, ]; const payslips = [buildPayslipRow()]; const payslipItems = [ { id: "item-1", payslip_id: "payslip-1", salary_item_id: "salary-item-1", amount: "10000.00", salary_item: salaryItems[0], }, { id: "item-2", payslip_id: "payslip-1", salary_item_id: "salary-item-2", amount: "600.00", salary_item: salaryItems[1], }, ]; app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; const codes = actor.permissionCodes ?? []; if (!codes.includes("*") && !codes.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("hasPermission", (employee, permissionCode) => { const codes = employee.permissionCodes ?? []; return codes.includes("*") || codes.includes(permissionCode); }); app.decorate("prisma", { payslip: { findMany: vi.fn(async () => payslips.map((item) => ({ ...item, employee: { id: "employee-1", employee_no: "E001", name: "Alice", title: "专员", department: { id: "dept-1", name: "行政部" }, }, items: payslipItems.filter((row) => row.payslip_id === item.id), })), ), }, auditLog: { create: vi.fn().mockResolvedValue({}), }, }); 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(exportsRoutes, { prefix: "/api" }); return { app }; } describe("payroll export routes", () => { it("exports monthly payslips in csv and xlsx formats", async () => { const { app } = await buildApp(); const csvResponse = await app.inject({ method: "GET", url: "/api/exports/payslips?month=2026-04&format=csv", }); expect(csvResponse.statusCode).toBe(200); expect(csvResponse.headers["content-type"]).toContain("text/csv"); const csvBody = csvResponse.body; expect(csvBody).toContain("工号,姓名,部门,基本工资,社保扣除,应发合计,应扣合计,实发合计"); expect(csvBody).toContain("E001,Alice,行政部,10000,600,10000,600,9400"); const xlsxResponse = await app.inject({ method: "GET", url: "/api/exports/payslips?month=2026-04&format=xlsx", }); expect(xlsxResponse.statusCode).toBe(200); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(Buffer.from(xlsxResponse.rawPayload)); const sheet = workbook.getWorksheet("payslips"); expect(sheet.getRow(1).values.slice(1, 9)).toEqual([ "工号", "姓名", "部门", "基本工资", "社保扣除", "应发合计", "应扣合计", "实发合计", ]); await app.close(); }); it("rejects non-admin exports", async () => { const { app } = await buildApp({ actor: { id: "employee-1", permissionCodes: ["employee"], }, }); const response = await app.inject({ method: "GET", url: "/api/exports/payslips?month=2026-04&format=csv", }); expect(response.statusCode).toBe(403); await app.close(); }); });