const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const leaveRoutes = require("../../../src/routes/leave"); function buildLeaveType() { const now = new Date("2026-03-25T08:00:00.000Z"); return { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", code: "annual", name: "年假", description: null, enabled: true, requires_attachment: false, attachment_mime_types: [], sort_order: 1, created_at: now, updated_at: now, leave_policy: { id: "policy-1", annual_quota_days: 10, enabled: true, description: null, }, }; } function buildLockedLeave({ ownerId = "employee-1", status = "draft", } = {}) { return { id: "11111111-1111-4111-8111-111111111111", owner_id: ownerId, department_id: "department-1", attachment_storage_path: null, attachment_original_name: null, attachment_mime_type: null, status, }; } function buildUpdatedLeave({ id = "11111111-1111-4111-8111-111111111111", ownerId = "employee-1", status = "draft", } = {}) { const now = new Date("2026-03-25T09:00:00.000Z"); return { id, owner_id: ownerId, department_id: "department-1", leave_type_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", start_date: new Date("2026-04-01T00:00:00.000Z"), end_date: new Date("2026-04-02T00:00:00.000Z"), start_session: "am", end_session: "pm", requested_days: 2, reason: "更新后的请假原因", attachment_original_name: null, attachment_mime_type: null, status, created_at: now, updated_at: now, leave_type: { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", code: "annual", name: "年假", requires_attachment: false, attachment_mime_types: [], }, owner: { id: ownerId, employee_no: "E001", name: "张三", }, }; } function buildTx({ lockedLeave, updatedLeave }) { return { $queryRaw: vi.fn().mockResolvedValue([lockedLeave]), leaveType: { findUnique: vi.fn().mockResolvedValue(buildLeaveType()), }, leaveRequest: { update: vi.fn().mockResolvedValue(updatedLeave), }, }; } function buildPrisma(tx) { return { $transaction: vi.fn().mockImplementation((callback) => callback(tx)), }; } async function buildApp({ employee, prisma }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { cutoverConsoleEnabled: false, leaveHolidayCalendarEnabled: false, }); 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("requireEntityPermission", (_entity, action) => async (request) => { await app.authenticate(request); const permissionCode = action === "submit" ? "leave.submit" : "leave.view_own"; 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(leaveRoutes, { prefix: "/api" }); return app; } describe("leave edit route", () => { it("allows owner to edit draft leave on the same record", async () => { const lockedLeave = buildLockedLeave({ status: "draft" }); const updatedLeave = buildUpdatedLeave({ status: "draft" }); const tx = buildTx({ lockedLeave, updatedLeave }); const prisma = buildPrisma(tx); const app = await buildApp({ employee: { id: "employee-1", department_id: "department-1", permissionCodes: ["leave.submit"], }, prisma, }); const response = await app.inject({ method: "PATCH", url: `/api/leave-requests/${lockedLeave.id}`, headers: { authorization: "Bearer test" }, payload: { leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", startDate: "2026-04-01", endDate: "2026-04-02", startSession: "am", endSession: "pm", reason: "更新后的请假原因", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ id: lockedLeave.id, status: "draft", startDate: "2026-04-01", endDate: "2026-04-02", requestedDays: 2, reason: "更新后的请假原因", }), ); expect(tx.leaveRequest.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: lockedLeave.id }, data: expect.objectContaining({ leave_type_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", requested_days: 2, updated_by: "employee-1", }), }), ); await app.close(); }); it("allows owner to edit rejected leave on the same record", async () => { const lockedLeave = buildLockedLeave({ status: "rejected" }); const updatedLeave = buildUpdatedLeave({ status: "rejected" }); const tx = buildTx({ lockedLeave, updatedLeave }); const app = await buildApp({ employee: { id: "employee-1", department_id: "department-1", permissionCodes: ["leave.submit"], }, prisma: buildPrisma(tx), }); const response = await app.inject({ method: "PATCH", url: `/api/leave-requests/${lockedLeave.id}`, headers: { authorization: "Bearer test" }, payload: { leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", startDate: "2026-04-01", endDate: "2026-04-02", startSession: "am", endSession: "pm", reason: "更新后的请假原因", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.status).toBe("rejected"); await app.close(); }); it("rejects editing when leave is already reviewing", async () => { const lockedLeave = buildLockedLeave({ status: "reviewing" }); const tx = buildTx({ lockedLeave, updatedLeave: buildUpdatedLeave({ status: "reviewing" }) }); const app = await buildApp({ employee: { id: "employee-1", department_id: "department-1", permissionCodes: ["leave.submit"], }, prisma: buildPrisma(tx), }); const response = await app.inject({ method: "PATCH", url: `/api/leave-requests/${lockedLeave.id}`, headers: { authorization: "Bearer test" }, payload: { leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", startDate: "2026-04-01", endDate: "2026-04-02", startSession: "am", endSession: "pm", reason: "更新后的请假原因", }, }); expect(response.statusCode).toBe(409); expect(response.json().error).toEqual( expect.objectContaining({ code: "VALID_001", details: expect.objectContaining({ status: "invalid_edit_status", }), }), ); expect(tx.leaveRequest.update).not.toHaveBeenCalled(); await app.close(); }); it("rejects editing by non-owner", async () => { const lockedLeave = buildLockedLeave({ ownerId: "employee-9", status: "draft" }); const tx = buildTx({ lockedLeave, updatedLeave: buildUpdatedLeave({ ownerId: "employee-9", status: "draft" }) }); const app = await buildApp({ employee: { id: "employee-1", department_id: "department-1", permissionCodes: ["leave.submit"], }, prisma: buildPrisma(tx), }); const response = await app.inject({ method: "PATCH", url: `/api/leave-requests/${lockedLeave.id}`, headers: { authorization: "Bearer test" }, payload: { leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", startDate: "2026-04-01", endDate: "2026-04-02", startSession: "am", endSession: "pm", reason: "更新后的请假原因", }, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); expect(tx.leaveRequest.update).not.toHaveBeenCalled(); await app.close(); }); });