const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const travelRequestsRoutes = require("../../../src/routes/travelRequests"); function buildTravelRequestRecord({ id = "11111111-1111-4111-8111-111111111111", applicantId = "employee-1", submitterId = null, status = "draft", } = {}) { const now = new Date("2026-03-10T00:00:00.000Z"); return { id, request_no: status === "draft" ? null : "CL-20260310-0001", applicant_employee_id: applicantId, submitter_id: submitterId, department_id: "dept-1", legal_entity_id: "legal-1", cost_center_id: "cc-1", budget_pool_id: null, purpose: "上海客户拜访", trip_type: "domestic", start_date: new Date("2026-03-15T00:00:00.000Z"), end_date: new Date("2026-03-17T00:00:00.000Z"), estimated_amount: "1280.00", status, created_at: now, updated_at: now, applicant: { id: applicantId, employee_no: "E001", name: "张三", }, submitter: submitterId ? { id: submitterId, employee_no: "E009", name: "李四", } : null, department: { id: "dept-1", name: "销售部" }, legal_entity: { id: "legal-1", code: "HY", name: "海音达" }, cost_center: { id: "cc-1", code: "CC001", name: "销售中心", legal_entity_id: "legal-1" }, budget_pool: null, segments: [ { id: "seg-1", sort_order: 1, segment_role: "outbound", transport_type: "flight", from_city: "深圳", to_city: "上海", depart_at: new Date("2026-03-15T01:00:00.000Z"), arrive_at: new Date("2026-03-15T03:30:00.000Z"), created_at: now, updated_at: now, }, ], travel_orders: [], workflow_instances: [], }; } function buildPrisma({ items = [], detail = null } = {}) { return { travelRequest: { findMany: vi.fn().mockResolvedValue(items), count: vi.fn().mockResolvedValue(items.length), findUnique: vi.fn().mockResolvedValue(detail), }, entityRiskFlag: { findMany: vi.fn().mockResolvedValue([]), }, }; } async function buildApp({ employee, prisma }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai" }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401, }); } request.employee = employee; }); app.decorate("hasPermission", (actor, permissionCode) => { return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); }); app.decorate("requirePermission", (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: permissionCode }, }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: "服务器内部错误", details: {} }, }); }); await app.register(travelRequestsRoutes, { prefix: "/api" }); return app; } describe("travelRequests routes", () => { it.each(["startDate", "endDate"])("rejects overflowing %s before the edit transaction", async (field) => { const prisma = { $transaction: vi.fn() }; const app = await buildApp({ employee: { id: "employee-1", permissionCodes: ["travel.create"] }, prisma }); try { const response = await app.inject({ method: "PATCH", url: "/api/travel-requests/11111111-1111-4111-8111-111111111111", headers: { authorization: "Bearer test" }, payload: { [field]: "2026-04-31" } }); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe("VALID_001"); expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date"); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("lists only own records when actor lacks view_all", async () => { const prisma = buildPrisma({ items: [buildTravelRequestRecord()], }); const app = await buildApp({ employee: { id: "employee-1", department_id: "dept-1", permissionCodes: ["travel.view_own"] }, prisma, }); const response = await app.inject({ method: "GET", url: "/api/travel-requests?mine=0&page=1&pageSize=10", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ mine: true, total: 1, }), ); expect(prisma.travelRequest.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ OR: [ { applicant_employee_id: "employee-1" }, { submitter_id: "employee-1" }, ], }), }), ); await app.close(); }); it("allows owner to fetch detail and blocks outsiders", async () => { const detail = buildTravelRequestRecord(); const ownerApp = await buildApp({ employee: { id: "employee-1", department_id: "dept-1", permissionCodes: ["travel.view_own"] }, prisma: buildPrisma({ detail }), }); const outsiderApp = await buildApp({ employee: { id: "employee-9", department_id: "dept-9", permissionCodes: ["travel.view_own"] }, prisma: buildPrisma({ detail }), }); const ownerResponse = await ownerApp.inject({ method: "GET", url: `/api/travel-requests/${detail.id}`, headers: { authorization: "Bearer test" }, }); const outsiderResponse = await outsiderApp.inject({ method: "GET", url: `/api/travel-requests/${detail.id}`, headers: { authorization: "Bearer test" }, }); expect(ownerResponse.statusCode).toBe(200); expect(ownerResponse.json().data).toEqual( expect.objectContaining({ id: detail.id, purpose: detail.purpose, permissions: expect.objectContaining({ canEditDraft: true }), }), ); expect(outsiderResponse.statusCode).toBe(403); expect(outsiderResponse.json().error.code).toBe("PERM_001"); await ownerApp.close(); await outsiderApp.close(); }); });