文件
hyb-oa/server/tests/unit/routes/travel-request.errors.test.js
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

169 行
19 KiB
JavaScript

const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const feeControl = require("../../../src/services/feeControl");
const workflow = require("../../../src/utils/workflow");
const budget = require("../../../src/services/budgetPool");
const purchaseBudget = require("../../../src/services/purchaseBudget");
const notifications = require("../../../src/services/notifications");
const email = require("../../../src/services/email");
const dispatcher = require("../../../src/services/notificationDispatcher");
const mocks = {};
const overrides = [
[feeControl, "resolveEntityPrecheck"], [feeControl, "createRiskFlagsFromPrecheck"],
[workflow, "parseWorkflowSteps"], [workflow, "resolveActionableStep"],
[budget, "resolveApplicableBudgetPoolByContext"], [purchaseBudget, "reserveBudgetForEntity"], [purchaseBudget, "releaseReservedBudgetIfAny"],
[notifications, "resolveRecipientsForAssignee"], [notifications, "createNotificationsWithRecords"],
[email, "sendNotificationEmailSafe"], [dispatcher, "flushNotificationDispatchTasks"],
];
const saved = overrides.map(([object, key]) => object[key]);
for (const [object, key] of overrides) object[key] = mocks[key] = vi.fn();
const routePath = require.resolve("../../../src/routes/travelRequests");
const savedRoute = require.cache[routePath]; delete require.cache[routePath];
const routes = require(routePath);
// The route captures its direct collaborators; restore shared modules immediately.
overrides.forEach(([object, key], index) => { object[key] = saved[index]; });
afterAll(() => { if (savedRoute) require.cache[routePath] = savedRoute; else delete require.cache[routePath]; });
const ID = "11111111-1111-4111-8111-111111111111";
const EMPLOYEE = "22222222-2222-4222-8222-222222222222";
const COMPANY = "33333333-3333-4333-8333-333333333333";
const CENTER = "44444444-4444-4444-8444-444444444444";
const PROJECT = "55555555-5555-4555-8555-555555555555";
const segment = { segmentRole: "outbound", transportType: "train", fromCity: "合成甲地", toCity: "合成乙地", departAt: "2026-01-01T01:00:00Z", arriveAt: "2026-01-01T02:00:00Z" };
function payload() { return { purpose: "合成差旅", tripType: "domestic", startDate: "2026-01-01", endDate: "2026-01-02", legalEntityId: COMPANY, costCenterId: CENTER, estimatedAmount: "12.50", segments: [{ ...segment }] }; }
const apps = [];
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
beforeEach(() => {
Object.values(mocks).forEach((mock) => mock.mockReset());
mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "pass", blockingReasons: [], riskFlags: [] });
mocks.createRiskFlagsFromPrecheck.mockResolvedValue([]);
mocks.parseWorkflowSteps.mockReturnValue([{ id: "step-1" }]);
mocks.resolveActionableStep.mockResolvedValue({ actionable: { step: { id: "step-1" }, assigneeId: PROJECT, assigneeRole: null }, skipped: [] });
mocks.resolveApplicableBudgetPoolByContext.mockResolvedValue({ id: CENTER });
mocks.reserveBudgetForEntity.mockResolvedValue({ reserved: true }); mocks.releaseReservedBudgetIfAny.mockResolvedValue({ released: true });
mocks.resolveRecipientsForAssignee.mockResolvedValue([]); mocks.createNotificationsWithRecords.mockResolvedValue([]);
mocks.sendNotificationEmailSafe.mockResolvedValue({}); mocks.flushNotificationDispatchTasks.mockResolvedValue({});
});
async function buildApp({ allowed = true, actorId = EMPLOYEE, gateEnabled = true } = {}) {
const app = Fastify(); apps.push(app);
const current = { id: ID, request_no: "SYNTHETIC", applicant_employee_id: EMPLOYEE, submitter_id: null, legal_entity_id: COMPANY, cost_center_id: CENTER, start_date: new Date("2026-01-01T00:00:00Z"), end_date: new Date("2026-01-02T00:00:00Z"), estimated_amount: "12.50", status: "draft", segments: [{ ...segment }], travel_orders: [], workflow_instances: [] };
const company = { id: COMPANY, enabled: true, archived_at: null };
const center = { id: CENTER, legal_entity_id: COMPANY, enabled: true, archived_at: null };
const prisma = {
travelRequest: { findUnique: vi.fn(async () => current), findMany: vi.fn(async () => [current]), count: vi.fn().mockResolvedValue(1), create: vi.fn(async ({ data }) => ({ ...current, ...data, segments: data.segments.create })), update: vi.fn(async ({ data }) => Object.assign(current, data)) },
legalEntity: { findUnique: vi.fn(async () => company) }, costCenter: { findUnique: vi.fn(async () => center) },
travelRequestSegment: { deleteMany: vi.fn().mockResolvedValue({}), createMany: vi.fn().mockResolvedValue({}) },
featureFlag: { findUnique: vi.fn(async ({ where }) => ({ flag_key: where.flag_key, override_mode: "force_on", rollout_type: "global", department_ids: [] })) },
workflowDefinition: { findFirst: vi.fn().mockResolvedValue({ id: PROJECT, definition_json: {} }) },
workflowInstance: { create: vi.fn().mockResolvedValue({ id: PROJECT }), findFirst: vi.fn().mockResolvedValue(null), update: vi.fn().mockResolvedValue({}) },
workflowTask: { create: vi.fn().mockResolvedValue({ id: PROJECT, assignee_id: PROJECT }), updateMany: vi.fn().mockResolvedValue({}) },
workflowHistory: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
entityRiskFlag: { findMany: vi.fn().mockResolvedValue([]) },
$queryRaw: vi.fn(async () => [{ id: ID }]),
};
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", projectLinkingEnabled: gateEnabled });
app.decorate("authenticate", async (request) => { request.employee = { id: actorId, permissionCodes: allowed ? ["travel.create", "travel.submit", "travel.view_own"] : [] }; });
app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes(permission));
app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permission)) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); });
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code ?? "SYS_001", details: error.details ?? {} } }));
await app.register(routes, { prefix: "/api" }); return { app, prisma, current, company, center };
}
function noWrites(prisma) {
for (const method of [prisma.travelRequest.create, prisma.travelRequest.update, prisma.travelRequestSegment.deleteMany, prisma.travelRequestSegment.createMany, prisma.workflowInstance.create, prisma.workflowInstance.update, prisma.workflowTask.create, prisma.workflowTask.updateMany, prisma.workflowHistory.create, prisma.auditLog.create]) expect(method).not.toHaveBeenCalled();
for (const key of ["reserveBudgetForEntity", "releaseReservedBudgetIfAny", "createNotificationsWithRecords", "sendNotificationEmailSafe", "flushNotificationDispatchTasks"]) expect(mocks[key]).not.toHaveBeenCalled();
}
function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); }
function req(method, suffix = "", body) { return { method, url: `/api/travel-requests${suffix}`, ...(body ? { payload: body } : {}) }; }
it("regression: link-project rejects malformed id as 400 before reading or writing", async () => {
const { app, prisma } = await buildApp(); const response = await app.inject(req("POST", "/bad/link-project", { projectId: PROJECT })); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ id: "invalid_uuid" }); expect(prisma.travelRequest.findUnique).not.toHaveBeenCalled(); noWrites(prisma);
});
it.each([PROJECT, null])("regression: link-project reaches valid binding or clearing for %s", async (projectId) => {
const { app, prisma } = await buildApp(); const response = await app.inject(req("POST", `/${ID}/link-project`, { projectId })); expect(response.statusCode).toBe(200); expect(response.json().data.item.projectId).toBe(projectId);
expect(prisma.travelRequest.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: ID }, data: { project_id: projectId, updated_by: EMPLOYEE } }));
});
it.each([
["GET", "?page=0", undefined, "query_invalid"], ["POST", "", {}, "create_input_invalid"],
["PATCH", `/${ID}`, { purpose: "" }, "update_input_invalid"], ["POST", `/${ID}/link-project`, { projectId: "bad" }, "project_link_input_invalid"],
])("explains %s %s invalid schema before business access", async (method, suffix, body, reason) => {
const { app, prisma } = await buildApp(); const response = await app.inject(req(method, suffix, body)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `travel_request.${reason}`, fieldErrors: expect.any(Object) }); expect(prisma.travelRequest.findUnique).not.toHaveBeenCalled(); expect(prisma.travelRequest.findMany).not.toHaveBeenCalled(); noWrites(prisma);
});
it.each([["GET", ""], ["PATCH", ""], ["POST", "/precheck"], ["POST", "/submit"], ["POST", "/withdraw"], ["GET", "/payment-track"]])("explains invalid request id in %s %s", async (method, suffix) => {
const { app, prisma } = await buildApp(); expectError(await app.inject(req(method, `/bad${suffix}`, method === "PATCH" ? { purpose: "合成" } : undefined)), 400, "travel_request.id_invalid", { id: "invalid_uuid" }); noWrites(prisma); expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it.each(["POST", "PATCH"].flatMap((method) => ["time", "segment-range", "date-range", "overflow"].map((state) => [method, state])))("preserves %s %s validation", async (method, state) => {
const { app, prisma } = await buildApp(); const body = payload(); let reason; let details;
if (state === "time") { body.segments[0].departAt = "invalid-time"; reason = "segment_time_invalid"; details = { "segments.0.departAt": "invalid_datetime" }; }
if (state === "segment-range") { body.segments[0].arriveAt = "2026-01-01T00:00:00Z"; reason = "segment_time_reversed"; details = { "segments.0.arriveAt": "must_be_after_departAt" }; }
if (state === "date-range") { body.endDate = "2025-12-31"; reason = "date_range_reversed"; details = { endDate: "must_be_after_startDate" }; }
if (state === "overflow") { body.estimatedAmount = "9".repeat(400); reason = "amount_invalid"; details = { estimatedAmount: "invalid_decimal" }; }
expectError(await app.inject(req(method, method === "PATCH" ? `/${ID}` : "", body)), 400, `travel_request.${reason}`, details); noWrites(prisma);
});
it.each(["POST", "PATCH"].flatMap((method) => ["company-missing", "company-disabled", "company-archived", "center-missing", "center-disabled", "center-archived", "company-mismatch"].map((state) => [method, state])))("preserves %s financial reference %s", async (method, state) => {
const { app, prisma, company, center } = await buildApp();
if (state === "company-missing") prisma.legalEntity.findUnique.mockResolvedValue(null); if (state === "company-disabled") company.enabled = false; if (state === "company-archived") company.archived_at = new Date();
if (state === "center-missing") prisma.costCenter.findUnique.mockResolvedValue(null); if (state === "center-disabled") center.enabled = false; if (state === "center-archived") center.archived_at = new Date(); if (state === "company-mismatch") center.legal_entity_id = PROJECT;
const mismatch = state === "company-mismatch"; const companyError = state.startsWith("company-") && !mismatch;
expectError(await app.inject(req(method, method === "PATCH" ? `/${ID}` : "", method === "POST" ? payload() : { purpose: "合成修改" })), 409, `travel_request.${mismatch ? "cost_center_company_mismatch" : companyError ? "company_unavailable" : "cost_center_unavailable"}`, { [companyError ? "legalEntityId" : "costCenterId"]: mismatch ? "legal_entity_mismatch" : "not_found_or_disabled" }); noWrites(prisma);
});
it("preserves empty patch refusal before locking", async () => {
const { app, prisma } = await buildApp(); expectError(await app.inject(req("PATCH", `/${ID}`, {})), 400, "travel_request.update_empty", { body: "empty_patch" }); expect(prisma.$queryRaw).not.toHaveBeenCalled(); noWrites(prisma);
});
it.each([["PATCH", "", "approved", "only_draft_editable"], ["POST", "/submit", "reviewing", "only_draft_submittable"], ["POST", "/withdraw", "draft", "only_reviewing_withdrawable"]])("preserves %s %s state guard", async (method, suffix, status, reason) => {
const { app, prisma, current } = await buildApp(); current.status = status;
expectError(await app.inject(req(method, `/${ID}${suffix}`, method === "PATCH" ? { purpose: "合成" } : undefined)), 409, `travel_request.${reason}`, { status: reason }); noWrites(prisma); expect(mocks.resolveEntityPrecheck).not.toHaveBeenCalled();
});
it("requires segments before precheck or reservation", async () => {
const { app, prisma, current } = await buildApp(); current.segments = []; expectError(await app.inject(req("POST", `/${ID}/submit`)), 409, "travel_request.segments_required", { segments: "required" }); expect(mocks.resolveEntityPrecheck).not.toHaveBeenCalled(); noWrites(prisma);
});
it("preserves blocking precheck details with no later risk/approval writes", async () => {
const { app, prisma } = await buildApp(); const reasons = [{ code: "synthetic_budget_block", message: "合成预检" }]; mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "block", blockingReasons: reasons });
expectError(await app.inject(req("POST", `/${ID}/submit`)), 409, "travel_request.precheck_blocked", { precheck: reasons }); noWrites(prisma); expect(mocks.createRiskFlagsFromPrecheck).not.toHaveBeenCalled(); expect(prisma.workflowDefinition.findFirst).not.toHaveBeenCalled();
});
it.each(["missing", "no-approver"])("preserves workflow %s refusal after precheck", async (state) => {
const { app, prisma } = await buildApp(); if (state === "missing") prisma.workflowDefinition.findFirst.mockResolvedValue(null); else mocks.resolveActionableStep.mockResolvedValue({ actionable: null, skipped: [] });
expectError(await app.inject(req("POST", `/${ID}/submit`)), 409, `travel_request.${state === "missing" ? "workflow_missing" : "approver_unavailable"}`, { workflow: state === "missing" ? "missing_definition" : "no_actionable_approver" });
expect(mocks.createRiskFlagsFromPrecheck).toHaveBeenCalledTimes(1); noWrites(prisma); expect(mocks.resolveApplicableBudgetPoolByContext.mock.calls[0]).toStrictEqual([prisma, { legalEntityId: COMPANY, costCenterId: CENTER, effectiveDate: new Date("2026-01-01T00:00:00Z") }, { forUpdate: true }]);
});
it("preserves project linking gate before input/read/write", async () => {
const { app, prisma } = await buildApp({ gateEnabled: false }); expectError(await app.inject(req("POST", "/bad/link-project", {})), 409, "travel_request.project_linking_disabled", { featureFlag: "project_linking_v1_disabled", reason: "env_hard_off" }); noWrites(prisma); expect(prisma.travelRequest.findUnique).not.toHaveBeenCalled();
});
it.each(["missing", "outsider"])("preserves link-project %s guard after id fix", async (state) => {
const { app, prisma } = await buildApp({ actorId: state === "outsider" ? PROJECT : EMPLOYEE }); if (state === "missing") prisma.travelRequest.findUnique.mockResolvedValue(null);
const response = await app.inject(req("POST", `/${ID}/link-project`, { projectId: PROJECT })); expect(response.statusCode).toBe(state === "missing" ? 404 : 403); expect(response.json().error.code).toBe(state === "missing" ? "SYS_404" : "PERM_001"); noWrites(prisma);
});
it.each([["PATCH", ""], ["POST", "/submit"], ["POST", "/withdraw"]])("preserves applicant-only %s %s refusal", async (method, suffix) => {
const { app, prisma } = await buildApp({ actorId: PROJECT }); const response = await app.inject(req(method, `/${ID}${suffix}`, method === "PATCH" ? { purpose: "合成" } : undefined)); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noWrites(prisma);
});
it("preserves legal create, zero amount and equal segment timestamps", async () => {
const { app, prisma } = await buildApp(); const body = payload(); body.estimatedAmount = "0.00"; body.segments[0].arriveAt = body.segments[0].departAt;
const response = await app.inject(req("POST", "", body)); expect(response.statusCode).toBe(200); expect(prisma.travelRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ estimated_amount: "0.00", segments: { create: [expect.objectContaining({ depart_at: new Date(segment.departAt), arrive_at: new Date(segment.departAt) })] } }) })); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
});
it("preserves partial date patch against retained boundary and valid segment replacement", async () => {
const { app, prisma } = await buildApp(); expectError(await app.inject(req("PATCH", `/${ID}`, { startDate: "2026-01-03" })), 400, "travel_request.date_range_reversed", { endDate: "must_be_after_startDate" }); noWrites(prisma);
const response = await app.inject(req("PATCH", `/${ID}`, { segments: [{ ...segment }] })); expect(response.statusCode).toBe(200); expect(prisma.travelRequestSegment.deleteMany).toHaveBeenCalledTimes(1); expect(prisma.travelRequestSegment.createMany).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
});
it("keeps travel submit on the global pool when a legacy department pool also exists", async () => {
const { app, prisma, current } = await buildApp();
current.department_id = PROJECT;
const departmentPool = { id: COMPANY, department_id: PROJECT };
const globalPool = { id: CENTER, department_id: null, legal_entity_id: null, cost_center_id: null };
// Use the actual selector so an accidentally forwarded department changes the result.
mocks.resolveApplicableBudgetPoolByContext.mockImplementation(async (_tx, context) =>
budget.selectBudgetPoolCandidate([departmentPool, globalPool], context));
const response = await app.inject(req("POST", `/${ID}/submit`));
expect(response.statusCode).toBe(200);
expect(mocks.resolveApplicableBudgetPoolByContext.mock.calls[0]).toStrictEqual([
prisma, { legalEntityId: COMPANY, costCenterId: CENTER, effectiveDate: current.start_date }, { forUpdate: true },
]);
expect(mocks.resolveEntityPrecheck).toHaveBeenCalledWith(expect.objectContaining({ prisma, budgetPoolForWrite: globalPool }));
expect(mocks.reserveBudgetForEntity).toHaveBeenCalledWith(prisma, expect.objectContaining({ budgetPoolId: globalPool.id }));
expect(current.budget_pool_id).toBe(globalPool.id);
});
it("preserves submit success using local collaborator stubs with no external dispatch", async () => {
const { app, prisma } = await buildApp(); const response = await app.inject(req("POST", `/${ID}/submit`)); expect(response.statusCode).toBe(200); expect(response.json().data.status).toBe("reviewing"); expect(prisma.workflowInstance.create).toHaveBeenCalledTimes(1); expect(prisma.workflowTask.create).toHaveBeenCalledTimes(1); expect(mocks.reserveBudgetForEntity).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); expect(mocks.flushNotificationDispatchTasks).toHaveBeenCalledTimes(1);
});
it("preserves valid withdrawal and budget release using a local stub", async () => {
const { app, prisma, current } = await buildApp(); current.status = "reviewing"; const response = await app.inject(req("POST", `/${ID}/withdraw`)); expect(response.statusCode).toBe(200); expect(prisma.travelRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: "draft" }) })); expect(mocks.releaseReservedBudgetIfAny).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
});