const crypto = require("node:crypto"); const Fastify = require("fastify"); const performanceRoutes = require("../../../src/routes/performance"); const { AppError } = require("../../../src/utils/AppError"); const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; function buildFeatureFlagRecord() { return { id: "flag-1", flag_key: "employee_lifecycle_v1", override_mode: "force_on", rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-03-28T00:00:00.000Z"), created_by: null, updated_at: new Date("2026-03-28T00:00:00.000Z"), updated_by: null, }; } function buildCycleRow(overrides = {}) { return { id: "11111111-1111-4111-8111-111111111111", name: "2026 Q1", cycle_type: "quarterly", start_date: new Date("2026-01-01T00:00:00.000Z"), end_date: new Date("2026-03-31T00:00:00.000Z"), status: "draft", activated_at: null, completed_at: null, created_at: new Date("2026-03-29T08:00:00.000Z"), created_by: ADMIN_ID, updated_at: new Date("2026-03-29T08:00:00.000Z"), updated_by: ADMIN_ID, ...overrides, }; } async function buildApp({ actor = { id: ADMIN_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.settings"] } } = {}) { const app = Fastify(); const state = { cycles: [], }; const idSequence = [ "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333", ]; const performanceReviewCycle = { findMany: vi.fn(async () => [...state.cycles]), findUnique: vi.fn(async ({ where }) => state.cycles.find((item) => item.id === where.id) ?? null), findFirst: vi.fn(async ({ where }) => { return ( state.cycles.find((item) => { if (where.status && item.status !== where.status) { return false; } if (where.id?.not && item.id === where.id.not) { return false; } return true; }) ?? null ); }), create: vi.fn(async ({ data }) => { const cycle = buildCycleRow({ id: idSequence[state.cycles.length] ?? crypto.randomUUID(), name: data.name, cycle_type: data.cycle_type, start_date: data.start_date, end_date: data.end_date, created_by: data.created_by, updated_by: data.updated_by, }); state.cycles.push(cycle); return cycle; }), update: vi.fn(async ({ where, data }) => { const index = state.cycles.findIndex((item) => item.id === where.id); state.cycles[index] = { ...state.cycles[index], ...data, updated_at: new Date("2026-03-29T09:00:00.000Z"), }; return state.cycles[index]; }), }; app.decorate("config", { appTimezone: "Asia/Shanghai", employeeLifecycleEnabled: true }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; const codes = actor.permissionCodes ?? []; if (!codes.includes("*") && !codes.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("hasPermission", (employee, permissionCode) => { const codes = employee.permissionCodes ?? []; return codes.includes("*") || codes.includes(permissionCode); }); app.decorate("prisma", { featureFlag: { findUnique: vi.fn().mockResolvedValue(buildFeatureFlagRecord()), }, performanceReviewCycle, auditLog: { create: vi.fn().mockResolvedValue({}), }, }); app.setErrorHandler((error, request, reply) => { reply.status(error.statusCode || 500).send({ requestId: request.id, error: { code: error.code || "SYS_001", message: error.message, details: error.details ?? null, }, }); }); await app.register(performanceRoutes, { prefix: "/api" }); return { app, state, performanceReviewCycle }; } describe("performance cycle routes", () => { it("allows admins to create, update, activate and complete cycles with a single active cycle guard", async () => { const { app, state, performanceReviewCycle } = await buildApp(); const createResponse = await app.inject({ method: "POST", url: "/api/performance/cycles", payload: { name: "2026 Q1", cycleType: "quarterly", startDate: "2026-01-01", endDate: "2026-03-31", }, }); expect(createResponse.statusCode).toBe(200); expect(createResponse.json().data).toMatchObject({ name: "2026 Q1", cycleType: "quarterly", status: "draft", }); const patchResponse = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${state.cycles[0].id}`, payload: { name: "2026 Q1 调整版", endDate: "2026-04-05", }, }); expect(patchResponse.statusCode).toBe(200); expect(patchResponse.json().data).toMatchObject({ name: "2026 Q1 调整版", endDate: "2026-04-05", status: "draft", }); const activateResponse = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${state.cycles[0].id}`, payload: { status: "active", }, }); expect(activateResponse.statusCode).toBe(200); expect(activateResponse.json().data).toMatchObject({ status: "active", }); const secondCreateResponse = await app.inject({ method: "POST", url: "/api/performance/cycles", payload: { name: "2026 Q2", cycleType: "quarterly", startDate: "2026-04-01", endDate: "2026-06-30", }, }); expect(secondCreateResponse.statusCode).toBe(200); const secondActivateResponse = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${state.cycles[1].id}`, payload: { status: "active", }, }); expect(secondActivateResponse.statusCode).toBe(409); expect(secondActivateResponse.json().error.details).toMatchObject({ cycle: "active_cycle_exists", activeCycleId: state.cycles[0].id, }); const completeResponse = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${state.cycles[0].id}`, payload: { status: "completed", }, }); expect(completeResponse.statusCode).toBe(200); expect(completeResponse.json().data).toMatchObject({ status: "completed", }); expect(performanceReviewCycle.create).toHaveBeenCalledTimes(2); expect(performanceReviewCycle.update).toHaveBeenCalledTimes(3); await app.close(); }); it("rejects non-admin users", async () => { const { app } = await buildApp({ actor: { id: "employee-1", department_id: DEPARTMENT_ID, permissionCodes: ["employee"], }, }); const response = await app.inject({ method: "GET", url: "/api/performance/cycles", }); expect(response.statusCode).toBe(403); await app.close(); }); }); describe("performance cycle calendar boundary", () => { it("preserves leap day, month end and a partial date patch", async () => { const { app, state, performanceReviewCycle } = await buildApp(); try { const response = await app.inject({ method: "POST", url: "/api/performance/cycles", payload: { name: "合成周期", cycleType: "quarterly", startDate: "2024-02-29", endDate: "2024-04-30" } }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ startDate: "2024-02-29", endDate: "2024-04-30" }); expect((await app.inject({ method: "PATCH", url: `/api/performance/cycles/${state.cycles[0].id}`, payload: { endDate: "2024-05-31" } })).statusCode).toBe(200); expect(performanceReviewCycle.update.mock.calls[0][0].data).toMatchObject({ end_date: new Date("2024-05-31T00:00:00Z") }); expect(performanceReviewCycle.update.mock.calls[0][0].data).not.toHaveProperty("start_date"); } finally { await app.close(); } }); });