const Fastify = require("fastify"); const reimbursementTemplatesRoutes = require("../../../src/routes/reimbursementTemplates"); const { AppError } = require("../../../src/utils/AppError"); const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const TEMPLATE_ID = "11111111-1111-4111-8111-111111111111"; const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222"; const DISABLED_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333"; function cloneTemplate(item) { return { ...item, items: Array.isArray(item.items) ? item.items.map((entry) => ({ ...entry })) : [], created_at: new Date(item.created_at), updated_at: new Date(item.updated_at), }; } function buildTemplate(overrides = {}) { return { id: TEMPLATE_ID, name: "国内差旅报销", description: "机票、火车票与大巴车票占位模板", items: [ { expenseTypeId: EXPENSE_TYPE_ID, description: "默认交通费", defaultAmount: "88.80", }, ], sort_order: 10, enabled: true, created_at: new Date("2026-04-08T12:00:00.000Z"), updated_at: new Date("2026-04-08T12:00:00.000Z"), ...overrides, }; } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, permissionCodes: ["system.settings"], }; const state = { templates: (options.templates ?? [buildTemplate()]).map((item) => cloneTemplate(item)), expenseTypes: options.expenseTypes ?? [ { id: EXPENSE_TYPE_ID, code: "travel_airfare", enabled: true, }, { id: DISABLED_EXPENSE_TYPE_ID, code: "transport", enabled: false, }, ], auditLogs: [], }; function buildPrismaLike(stateRef) { return { reimbursementTemplate: { findMany: vi.fn(async ({ where, orderBy } = {}) => { let items = stateRef.templates.filter((item) => { if (typeof where?.enabled === "boolean" && item.enabled !== where.enabled) { return false; } return true; }); if (Array.isArray(orderBy)) { items = items.sort((left, right) => { if (left.sort_order !== right.sort_order) { return left.sort_order - right.sort_order; } return left.name.localeCompare(right.name, "zh-CN"); }); } return items.map((item) => cloneTemplate(item)); }), findUnique: vi.fn(async ({ where }) => { const found = stateRef.templates.find((item) => item.id === where.id) ?? null; return found ? cloneTemplate(found) : null; }), create: vi.fn(async ({ data }) => { const record = buildTemplate({ id: `66666666-6666-4666-8666-${String(stateRef.templates.length + 1).padStart(12, "0")}`, name: data.name, description: data.description ?? null, items: data.items ?? [], sort_order: data.sort_order ?? 0, enabled: data.enabled ?? true, created_at: data.created_at, updated_at: data.updated_at, }); stateRef.templates.push(record); return cloneTemplate(record); }), update: vi.fn(async ({ where, data }) => { const index = stateRef.templates.findIndex((item) => item.id === where.id); if (index < 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const next = { ...stateRef.templates[index], ...data, }; stateRef.templates[index] = next; return cloneTemplate(next); }), findFirst: vi.fn(async ({ where }) => { const found = stateRef.templates.find((item) => item.id === where.id && item.enabled === where.enabled) ?? null; return found ? cloneTemplate(found) : null; }), }, expenseType: { findMany: vi.fn(async ({ where }) => { const ids = where?.id?.in ?? []; return stateRef.expenseTypes.filter( (item) => ids.includes(item.id) && item.enabled === where.enabled, ); }), }, auditLog: { create: vi.fn(async ({ data }) => { stateRef.auditLogs.push(data); return { id: `audit-${stateRef.auditLogs.length}` }; }), }, }; } const prisma = buildPrismaLike(state); prisma.$transaction = vi.fn(async (callback) => { const txState = { templates: state.templates.map((item) => cloneTemplate(item)), expenseTypes: state.expenseTypes.map((item) => ({ ...item })), auditLogs: state.auditLogs.map((item) => ({ ...item })), }; const tx = buildPrismaLike(txState); const result = await callback(tx); state.templates = txState.templates; state.expenseTypes = txState.expenseTypes; state.auditLogs = txState.auditLogs; return result; }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; const permissionCodes = actor.permissionCodes ?? []; if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("prisma", 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(reimbursementTemplatesRoutes, { prefix: "/api" }); return { app, state, prisma }; } describe("reimbursement template routes", () => { it("allows authenticated users to list templates with enabled filter", async () => { const { app } = await buildApp({ actor: { id: ACTOR_ID, permissionCodes: [], }, templates: [ buildTemplate(), buildTemplate({ id: "44444444-4444-4444-8444-444444444444", name: "停用模板", enabled: false, }), ], }); const response = await app.inject({ method: "GET", url: "/api/reimbursement-templates?enabled=true", }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ enabled: true, items: [ expect.objectContaining({ id: TEMPLATE_ID, name: "国内差旅报销", enabled: true, }), ], }); await app.close(); }); it("creates, updates and soft deletes a template with audit logs", async () => { const { app, state } = await buildApp(); const createResponse = await app.inject({ method: "POST", url: "/api/reimbursement-templates", payload: { name: "办公用品报销", description: "默认办公用品采购占位", items: [ { expenseTypeId: EXPENSE_TYPE_ID, description: "办公耗材", defaultAmount: "123.40", }, ], sortOrder: 5, enabled: true, }, }); expect(createResponse.statusCode).toBe(200); const createdId = createResponse.json().data.id; expect(state.auditLogs).toHaveLength(1); expect(state.auditLogs[0].action).toBe("REIMBURSEMENT_TEMPLATE_CREATED"); const updateResponse = await app.inject({ method: "PATCH", url: `/api/reimbursement-templates/${createdId}`, payload: { name: "办公用品报销模板", enabled: false, }, }); expect(updateResponse.statusCode).toBe(200); expect(updateResponse.json().data).toEqual( expect.objectContaining({ id: createdId, name: "办公用品报销模板", enabled: false, }), ); const deleteResponse = await app.inject({ method: "DELETE", url: `/api/reimbursement-templates/${createdId}`, }); expect(deleteResponse.statusCode).toBe(200); expect(deleteResponse.json().data).toEqual( expect.objectContaining({ id: createdId, enabled: false, }), ); expect(state.auditLogs.map((item) => item.action)).toEqual([ "REIMBURSEMENT_TEMPLATE_CREATED", "REIMBURSEMENT_TEMPLATE_UPDATED", ]); await app.close(); }); it("rejects invalid item schema and disabled expense types", async () => { const { app } = await buildApp(); const invalidSchemaResponse = await app.inject({ method: "POST", url: "/api/reimbursement-templates", payload: { name: "坏模板", items: [ { expenseTypeId: EXPENSE_TYPE_ID, description: "无效金额", defaultAmount: 18.8, }, ], }, }); expect(invalidSchemaResponse.statusCode).toBe(400); const invalidExpenseTypeResponse = await app.inject({ method: "POST", url: "/api/reimbursement-templates", payload: { name: "禁用费用类型模板", items: [ { expenseTypeId: DISABLED_EXPENSE_TYPE_ID, description: "旧费用类型", defaultAmount: "9.90", }, ], }, }); expect(invalidExpenseTypeResponse.statusCode).toBe(409); expect(invalidExpenseTypeResponse.json().error).toEqual( expect.objectContaining({ code: "VALID_001", details: expect.objectContaining({ items: [ expect.objectContaining({ expenseTypeId: DISABLED_EXPENSE_TYPE_ID, reason: "expense_type_not_found_or_disabled", }), ], }), }), ); await app.close(); }); it("blocks management actions without system.settings", async () => { const { app } = await buildApp({ actor: { id: ACTOR_ID, permissionCodes: [], }, }); const response = await app.inject({ method: "POST", url: "/api/reimbursement-templates", payload: { name: "无权限模板", }, }); expect(response.statusCode).toBe(403); await app.close(); }); });