文件
hyb-oa/server/tests/unit/routes/budget-control.errors.test.js
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

138 行
9.4 KiB
JavaScript

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();
});
});