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