const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const rulesRoutes = require("../../../src/routes/budgetControlRules"); const budgetsRoutes = require("../../../src/routes/budgets"); const { resolveBudgetScope, selectBudgetPoolCandidate, resolveApplicableBudgetPoolByContext } = require("../../../src/services/budgetPool"); const ID = "11111111-1111-4111-8111-111111111111"; const OTHER_ID = "22222222-2222-4222-8222-222222222222"; const input = { name: "合成控制规则", entityType: "reimbursement", controlMode: "warn" }; const apps = []; afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); async function buildApp(allowed = true) { const app = Fastify(); apps.push(app); const prisma = { budgetControlRule: { findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue({ id: ID, ...input }), create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ id: ID, ...data })), }, budgetPool: { create: vi.fn() }, department: { findUnique: vi.fn() }, legalEntity: { findUnique: vi.fn() }, costCenter: { findUnique: vi.fn() }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; app.decorate("prisma", prisma); app.decorate("requirePermission", () => async (request) => { request.employee = { id: OTHER_ID }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); await app.register(rulesRoutes, { prefix: "/api" }); await app.register(budgetsRoutes, { prefix: "/api" }); return { app, prisma }; } describe("shared budget matching reasons", () => { it.each([ [{ costCenterId: ID }, 400, "budget.company_required", { costCenterId: "legalEntityId_required" }], [{ legalEntityId: ID }, 400, "budget.cost_center_required", { costCenterId: "required_with_legalEntityId" }], [{ departmentId: ID, legalEntityId: ID }, 409, "budget.scope_mixed", { scope: "mixed_dimensions_not_allowed" }], ])("rejects scope %j with unchanged details", async (scope, statusCode, reasonCode, details) => { expect(() => resolveBudgetScope(scope)).toThrowError(expect.objectContaining({ code: "VALID_001", statusCode, details: { ...details, reasonCode } })); const { app, prisma } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { code: "SYNTHETIC", name: "合成预算", periodStart: "2024-02-29", periodEnd: "2024-03-31", allocatedAmount: "100.00", ...scope } }); expect(response.statusCode).toBe(statusCode); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); for (const model of ["department", "legalEntity", "costCenter"]) expect(prisma[model].findUnique).not.toHaveBeenCalled(); expect(prisma.budgetPool.create).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); it("keeps the company guard for selection callers", () => { expect(() => selectBudgetPoolCandidate([], { costCenterId: ID })).toThrowError(expect.objectContaining({ statusCode: 400, details: { costCenterId: "legalEntityId_required", reasonCode: "budget.company_required" } })); }); it("explains ambiguous scoped pools without changing query bounds", async () => { const records = [ID, OTHER_ID].map((id) => ({ id, legal_entity_id: ID, cost_center_id: ID, department_id: null })); const tx = { budgetPool: { findMany: vi.fn().mockResolvedValue(records) } }; const effectiveDate = new Date("2024-02-29T00:00:00Z"); await expect(resolveApplicableBudgetPoolByContext(tx, { legalEntityId: ID, costCenterId: ID, effectiveDate })).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budget: "budget_pool_ambiguous", scopeType: "cost_center_scoped", reasonCode: "budget.pool_ambiguous" } }); expect(tx.budgetPool.findMany).toHaveBeenCalledWith({ where: { status: "active", archived_at: null, period_start: { lte: effectiveDate }, period_end: { gte: effectiveDate } }, orderBy: [{ created_at: "asc" }, { code: "asc" }] }); }); it.each([null, "2024-02-29", new Date(NaN)])("rejects invalid effective dates before querying", async (effectiveDate) => { const tx = { budgetPool: { findMany: vi.fn() } }; await expect(resolveApplicableBudgetPoolByContext(tx, { effectiveDate })).rejects.toMatchObject({ statusCode: 400, details: { effectiveDate: "invalid_date", reasonCode: "budget.effective_date_invalid" } }); expect(tx.budgetPool.findMany).not.toHaveBeenCalled(); }); it("preserves first-match legacy fallbacks and no-match null", () => { const global = [{ id: ID }, { id: OTHER_ID }]; const departments = [{ id: ID, department_id: ID }, { id: OTHER_ID, department_id: ID }]; expect(selectBudgetPoolCandidate(global)).toBe(global[0]); expect(selectBudgetPoolCandidate(departments, { departmentId: ID })).toBe(departments[0]); expect(selectBudgetPoolCandidate(departments, { departmentId: OTHER_ID })).toBeNull(); }); }); describe("budget control rules error contracts", () => { it.each([ ["GET", "/budget-control-rules?enabled=invalid", undefined, "budget_rule.query_invalid"], ["POST", "/budget-control-rules", { ...input, controlMode: "invalid" }, "budget_rule.create_input_invalid"], ["PATCH", `/budget-control-rules/${ID}`, {}, "budget_rule.update_input_invalid"], ])("validates %s %s before business access", async (method, path, payload, reasonCode) => { const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); expect(response.statusCode).toBe(400); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode, formErrors: expect.any(Array), fieldErrors: expect.any(Object) } }); for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); it.each(["PATCH", "POST"])("validates the rule id for %s", async (method) => { const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: `/api/budget-control-rules/bad${method === "POST" ? "/archive" : ""}`, ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "budget_rule.id_invalid" }); expect(prisma.budgetControlRule.findUnique).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); it.each([ ["POST", { ...input, costCenterId: ID }, "budget_rule.company_required", { costCenterId: "legalEntityId_required" }], ["PATCH", { costCenterId: ID }, "budget_rule.company_required", { costCenterId: "legalEntityId_required" }], ["POST", { ...input, entityType: "purchase_request", expenseTypeId: ID }, "budget_rule.expense_type_unsupported", { expenseTypeId: "only_reimbursement_supported" }], ["PATCH", { expenseTypeId: ID }, "budget_rule.expense_type_unsupported", { expenseTypeId: "only_reimbursement_supported" }], ])("preserves body-scoped rejection for %s %j", async (method, payload, reasonCode, details) => { const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: `/api/budget-control-rules${method === "PATCH" ? `/${ID}` : ""}`, payload }); expect(response.statusCode).toBe(409); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); it("preserves successful create defaults, paired patch and archive", async () => { const { app, prisma } = await buildApp(); expect((await app.inject({ method: "POST", url: "/api/budget-control-rules", payload: input })).statusCode).toBe(201); expect(prisma.budgetControlRule.create.mock.calls[0][0].data).toMatchObject({ priority: 100, enabled: true, control_mode: "warn" }); expect((await app.inject({ method: "PATCH", url: `/api/budget-control-rules/${ID}`, payload: { entityType: "reimbursement", expenseTypeId: ID, legalEntityId: ID, costCenterId: ID } })).statusCode).toBe(200); expect((await app.inject({ method: "POST", url: `/api/budget-control-rules/${ID}/archive` })).statusCode).toBe(200); expect(prisma.auditLog.create).toHaveBeenCalledTimes(3); }); it("preserves a missing rule as 404", async () => { const { app, prisma } = await buildApp(); prisma.budgetControlRule.findUnique.mockResolvedValue(null); const response = await app.inject({ method: "PATCH", url: `/api/budget-control-rules/${ID}`, payload: { name: "合成" } }); expect(response.statusCode).toBe(404); expect(prisma.budgetControlRule.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); it("preserves permission rejection before validation or writes", async () => { const { app, prisma } = await buildApp(false); expect((await app.inject({ method: "POST", url: "/api/budget-control-rules", payload: {} })).statusCode).toBe(403); for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); });