文件
hyb-oa/server/tests/unit/routes/travel-execution.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

76 行
9.1 KiB
JavaScript

const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const paymentService = require("../../../src/services/travelPayments");
const ID = "11111111-1111-4111-8111-111111111111";
const imports = [["orders", "travel_order", "travelOrders"], ["authorizations", "travel_authorization", "travelAuthorizations"], ["transactions", "travel_transaction", "travelTransactions"]];
const apps = [];
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
async function buildApp({ enabled = true, allowed = true } = {}) {
const app = Fastify(); apps.push(app);
const current = { id: ID, status: "confirmed", amount: "12.50", currency: "CNY", payment_date: null, payment_voucher_no: null, payment_channel: null };
const prisma = {};
for (const name of ["employee", "travelRequest", "travelOrder", "cardAuthorization", "cardTransaction", "budgetLedgerEntry", "budgetPool", "auditLog", "integrationOutbox"]) prisma[name] = Object.fromEntries(["findMany", "findUnique", "findFirst", "count", "create", "upsert", "update"].map((method) => [method, vi.fn().mockResolvedValue(method === "findMany" ? [] : null)]));
prisma.$queryRaw = vi.fn(async () => [current]); prisma.$transaction = vi.fn(async (callback) => callback(prisma));
app.decorate("prisma", prisma); app.decorate("config", { travelSyncEnabled: enabled, erpSyncEnabled: false });
app.decorate("authenticate", async (request) => { request.employee = { id: ID }; });
app.decorate("requirePermission", () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); });
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
for (const [, , file] of imports) await app.register(require(`../../../src/routes/${file}`), { prefix: "/api" });
return { app, prisma, current };
}
function noWrites(prisma) { for (const model of Object.values(prisma)) for (const method of ["create", "upsert", "update"]) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); }
function noReads(prisma) { for (const model of Object.values(prisma)) for (const method of ["findMany", "findUnique", "findFirst", "count"]) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.$queryRaw).not.toHaveBeenCalled(); }
function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); }
for (const [kind, reason] of imports) {
it(`${kind} import keeps disabled guard before parsing`, async () => {
const { app, prisma } = await buildApp({ enabled: false }); expectError(await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: {} }), 409, "travel_import.sync_disabled", { integration: "travel_sync_disabled" }); noWrites(prisma); noReads(prisma);
});
it.each([0, 101])(`${kind} import explains list size %s before database access`, async (size) => {
const { app, prisma } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: { [kind]: Array.from({ length: size }, () => ({})) } }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `${reason}.import_input_invalid`, fieldErrors: { [kind]: expect.any(Array) } }); noWrites(prisma); noReads(prisma);
});
it(`${kind} import explains no normalized rows`, async () => {
const { app, prisma } = await buildApp(); expectError(await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: { [kind]: [{}] } }), 400, `${reason}.import_empty`, { [kind]: "empty_after_normalization" }); noWrites(prisma); noReads(prisma);
});
it(`${kind} import preserves permission before validation`, async () => {
const { app, prisma } = await buildApp({ allowed: false }); const response = await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: {} }); expect(response.statusCode).toBe(403); noWrites(prisma); noReads(prisma);
});
if (kind !== "orders") it(`${kind} list explains invalid query`, async () => {
const { app, prisma } = await buildApp(); const response = await app.inject({ method: "GET", url: `/api/travel/${kind}?page=0` }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(`${reason}.query_invalid`); noWrites(prisma); noReads(prisma);
});
}
it.each([
["/travel-orders/bad/mark-paid", {}, "travel_order.id_invalid", { id: "invalid_uuid" }],
["/travel/transactions/bad/freeze-suggestion", {}, "travel_transaction.id_invalid", { id: "invalid_uuid" }],
])("explains invalid id at %s", async (path, payload, reason, details) => {
const { app, prisma } = await buildApp(); expectError(await app.inject({ method: "POST", url: "/api" + path, payload }), 400, reason, details); noWrites(prisma); noReads(prisma);
});
it.each([
[`/travel-orders/${ID}/mark-paid`, { paymentDate: "2026-02-30" }, "travel_order.payment_input_invalid"],
[`/travel/transactions/${ID}/freeze-suggestion`, { reasonCode: "bad" }, "travel_transaction.freeze_input_invalid"],
])("explains invalid operation input at %s", async (path, payload, reason) => {
const { app, prisma } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api" + path, payload }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(reason); noWrites(prisma); noReads(prisma);
});
it.each(["status", "conflict", "settlement"])("keeps payment %s guard without writes or ERP outbox", async (state) => {
const { app, prisma, current } = await buildApp(); if (state === "status") current.status = "pending"; if (state === "conflict") { current.status = "paid"; current.payment_voucher_no = "SYNTHETIC"; }
const reason = { status: "invalid_pay_status", conflict: "already_paid_conflict", settlement: "settled_transaction_required" }[state];
const details = state === "status" ? { status: "invalid_pay_status" } : { payment: state === "conflict" ? "already_paid_conflict" : "missing_settled_transaction" };
expectError(await app.inject({ method: "POST", url: `/api/travel-orders/${ID}/mark-paid`, payload: {} }), 409, `travel_order.${reason}`, details); noWrites(prisma);
if (state !== "settlement") expect(prisma.cardTransaction.findFirst).not.toHaveBeenCalled();
});
function budgetContext() { return { $queryRaw: vi.fn().mockResolvedValue([]), budgetLedgerEntry: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}) }, budgetPool: { findUnique: vi.fn().mockResolvedValue({ id: ID, used_amount: "10.00" }), update: vi.fn().mockResolvedValue({}) } }; }
const budgetArgs = { budgetPoolId: ID, entityId: ID, actorId: ID, amount: "12.50", note: "synthetic" };
it.each(["authorizeBudgetForEntity", "reverseBudgetForEntity"])("%s preserves missing pool error after existing ledger attempt", async (method) => {
const tx = budgetContext(); tx.budgetPool.findUnique.mockResolvedValue(null);
await expect(paymentService[method](tx, budgetArgs)).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budgetPoolId: "not_found", reasonCode: "travel_payment.budget_pool_not_found" } });
expect(tx.budgetLedgerEntry.create).toHaveBeenCalledTimes(1); expect(tx.budgetLedgerEntry.create.mock.invocationCallOrder[0]).toBeLessThan(tx.budgetPool.findUnique.mock.invocationCallOrder[0]); expect(tx.budgetPool.update).not.toHaveBeenCalled();
});
it.each([["authorizeBudgetForEntity", "22.50"], ["reverseBudgetForEntity", "0.00"]])("%s preserves usage delta and nonnegative floor", async (method, amount) => {
const tx = budgetContext(); expect(await paymentService[method](tx, budgetArgs)).toMatchObject({ applied: true, duplicated: false }); expect(tx.budgetPool.update).toHaveBeenCalledWith({ where: { id: ID }, data: { used_amount: amount, updated_by: ID } });
});
it("settlement preserves ledger-only behavior without checking pool or changing usage", async () => {
const tx = budgetContext(); tx.budgetPool.findUnique.mockResolvedValue(null); expect(await paymentService.settleBudgetForEntity(tx, budgetArgs)).toMatchObject({ applied: true, settledAmount: "12.50" }); expect(tx.budgetLedgerEntry.create).toHaveBeenCalledTimes(1); expect(tx.budgetPool.findUnique).not.toHaveBeenCalled(); expect(tx.budgetPool.update).not.toHaveBeenCalled();
});
it.each([["authorizeBudgetForEntity", "authorize"], ["settleBudgetForEntity", "settle"], ["reverseBudgetForEntity", "reverse"]])("%s preserves repeat and zero-amount idempotency", async (method, kind) => {
const tx = budgetContext(); tx.budgetLedgerEntry.findMany.mockResolvedValue([{ budget_pool_id: ID, kind, amount: "12.50" }]); expect(await paymentService[method](tx, budgetArgs)).toMatchObject({ applied: false, duplicated: true }); expect(await paymentService[method](tx, { ...budgetArgs, amount: "0.00" })).toMatchObject({ applied: false }); expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled(); expect(tx.budgetPool.update).not.toHaveBeenCalled();
});