const Fastify = require("fastify"); const payrollRoutes = require("../../../src/routes/payroll"); const { AppError } = require("../../../src/utils/AppError"); const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const EMPLOYEE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; function buildEmployee(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "E001", name: "Alice", title: "专员", status: "active", department: { id: "dept-1", name: "行政部" }, ...overrides, }; } function buildSalaryItem(overrides = {}) { return { id: "11111111-1111-4111-8111-111111111111", name: "基本工资", item_type: "earning", sort_order: 10, enabled: true, created_at: new Date("2026-03-29T08:00:00.000Z"), created_by: ADMIN_ID, updated_at: new Date("2026-03-29T08:00:00.000Z"), updated_by: ADMIN_ID, ...overrides, }; } function buildSalaryRow(overrides = {}) { return { id: "salary-1", employee_id: EMPLOYEE_ID, salary_item_id: "11111111-1111-4111-8111-111111111111", monthly_amount: "10000.00", effective_date: new Date("2026-01-01T00:00:00.000Z"), created_at: new Date("2026-01-01T00:00:00.000Z"), created_by: ADMIN_ID, updated_at: new Date("2026-01-01T00:00:00.000Z"), updated_by: ADMIN_ID, salary_item: buildSalaryItem(), ...overrides, }; } async function buildApp({ actor = { id: ADMIN_ID, permissionCodes: ["payroll.manage"] }, } = {}) { const app = Fastify(); const state = { employee: buildEmployee(), salaryItems: [ buildSalaryItem(), buildSalaryItem({ id: "22222222-2222-4222-8222-222222222222", name: "社保扣除", item_type: "deduction", sort_order: 20, }), ], salaryRows: [buildSalaryRow()], }; function attachSalaryItem(row) { return { ...row, salary_item: state.salaryItems.find((item) => item.id === row.salary_item_id), }; } const employeeSalary = { findMany: vi.fn(async ({ where }) => state.salaryRows .filter((item) => item.employee_id === where.employee_id) .map(attachSalaryItem), ), deleteMany: vi.fn(async ({ where }) => { const effectiveDate = where.effective_date.toISOString().slice(0, 10); state.salaryRows = state.salaryRows.filter( (item) => !(item.employee_id === where.employee_id && item.effective_date.toISOString().slice(0, 10) === effectiveDate), ); return { count: 1 }; }), createMany: vi.fn(async ({ data }) => { state.salaryRows.push( ...data.map((item, index) => ({ id: `salary-${state.salaryRows.length + index + 1}`, employee_id: item.employee_id, salary_item_id: item.salary_item_id, monthly_amount: item.monthly_amount, effective_date: item.effective_date, created_at: new Date("2026-03-29T08:00:00.000Z"), created_by: item.created_by, updated_at: new Date("2026-03-29T08:00:00.000Z"), updated_by: item.updated_by, })), ); return { count: data.length }; }), }; 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", { employee: { findUnique: vi.fn(async ({ where }) => (where.id === state.employee.id ? state.employee : null)), }, salaryItem: { findMany: vi.fn(async ({ where }) => state.salaryItems.filter((item) => where.id.in.includes(item.id))), findUnique: vi.fn(async ({ where }) => state.salaryItems.find((item) => item.id === where.id) ?? null), }, employeeSalary, auditLog: { create: vi.fn().mockResolvedValue({}), }, $transaction: vi.fn(async (callback) => callback(app.prisma)), }); 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(payrollRoutes, { prefix: "/api" }); return { app, state, employeeSalary }; } describe("payroll salary profile routes", () => { it("replaces salary snapshots for the same effective date and preserves history", async () => { const { app, state, employeeSalary } = await buildApp(); const putResponse = await app.inject({ method: "PUT", url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`, payload: { effectiveDate: "2026-04-01", items: [ { salaryItemId: state.salaryItems[0].id, monthlyAmount: 12000, }, { salaryItemId: state.salaryItems[1].id, monthlyAmount: 800, }, ], }, }); expect(putResponse.statusCode).toBe(200); expect(putResponse.json().data.snapshots[0]).toMatchObject({ effectiveDate: "2026-04-01", items: [ { salaryItemId: state.salaryItems[0].id, monthlyAmount: 12000 }, { salaryItemId: state.salaryItems[1].id, monthlyAmount: 800 }, ], }); expect(putResponse.json().data.snapshots[1]).toMatchObject({ effectiveDate: "2026-01-01", }); const replaceResponse = await app.inject({ method: "PUT", url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`, payload: { effectiveDate: "2026-04-01", items: [ { salaryItemId: state.salaryItems[0].id, monthlyAmount: 12500, }, ], }, }); expect(replaceResponse.statusCode).toBe(200); expect(replaceResponse.json().data.snapshots[0]).toMatchObject({ effectiveDate: "2026-04-01", items: [{ salaryItemId: state.salaryItems[0].id, monthlyAmount: 12500 }], }); expect(replaceResponse.json().data.snapshots).toHaveLength(2); const getResponse = await app.inject({ method: "GET", url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`, }); expect(getResponse.statusCode).toBe(200); expect(getResponse.json().data.snapshots).toHaveLength(2); expect(employeeSalary.deleteMany).toHaveBeenCalledTimes(2); expect(employeeSalary.createMany).toHaveBeenCalledTimes(2); await app.close(); }); it("rejects non-admin users", async () => { const { app } = await buildApp({ actor: { id: EMPLOYEE_ID, permissionCodes: ["employee"], }, }); const response = await app.inject({ method: "GET", url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`, }); expect(response.statusCode).toBe(403); await app.close(); }); });