const Fastify = require("fastify"); const budgetsRoutes = require("../../../src/routes/budgets"); const performanceRoutes = require("../../../src/routes/performance"); const { AppError } = require("../../../src/utils/AppError"); const ID = "11111111-1111-4111-8111-111111111111"; const OTHER_ID = "22222222-2222-4222-8222-222222222222"; const budgetInput = { code: "SYNTHETIC", name: "合成预算", periodStart: "2024-02-29", periodEnd: "2024-03-31", allocatedAmount: "100.00" }; const cycleInput = { name: "合成周期", cycleType: "monthly", startDate: "2024-02-29", endDate: "2024-03-31" }; const apps = []; afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); async function buildApp({ allowed = true, enabled = true } = {}) { const app = Fastify(); apps.push(app); const cycle = { id: ID, status: "draft", start_date: new Date("2024-02-29T00:00:00Z"), end_date: new Date("2024-03-31T00:00:00Z") }; const prisma = { featureFlag: { findUnique: vi.fn().mockResolvedValue({ flag_key: "employee_lifecycle_v1", override_mode: enabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [] }) }, department: { findUnique: vi.fn().mockResolvedValue({ id: ID, archived_at: null }) }, legalEntity: { findUnique: vi.fn().mockResolvedValue({ id: ID, archived_at: null, enabled: true }) }, costCenter: { findUnique: vi.fn().mockResolvedValue({ id: ID, legal_entity_id: ID, archived_at: null, enabled: true }) }, budgetPool: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), findUnique: vi.fn().mockResolvedValue({ id: ID, ...budgetInput }), create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ id: ID, ...data })), }, performanceReviewCycle: { findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue(cycle), findFirst: vi.fn().mockResolvedValue(null), create: vi.fn(async ({ data }) => ({ ...cycle, ...data })), update: vi.fn(async ({ data }) => ({ ...cycle, ...data })), }, employee: { findUnique: vi.fn().mockResolvedValue({ id: ID, status: "active" }) }, performanceReview: { findFirst: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue({ id: ID }), create: vi.fn().mockResolvedValue({ id: ID }), update: vi.fn().mockResolvedValue({ id: ID }), }, performanceReviewDimension: { deleteMany: vi.fn(), createMany: vi.fn() }, auditLog: { create: vi.fn().mockResolvedValue({}) }, $queryRaw: vi.fn().mockResolvedValue([{ id: ID, status: "active" }]), }; prisma.$transaction = vi.fn(async (callback) => callback(prisma)); app.decorate("config", { employeeLifecycleEnabled: true, appTimezone: "Asia/Shanghai" }); app.decorate("prisma", prisma); app.decorate("authenticate", async (request) => { request.employee = { id: OTHER_ID, department_id: null }; }); app.decorate("requirePermission", () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); }); app.decorate("hasPermission", () => allowed); app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code ?? "SYS_001", details: error.details ?? null } })); await app.register(budgetsRoutes, { prefix: "/api" }); await app.register(performanceRoutes, { prefix: "/api" }); return { app, prisma, cycle }; } function expectNoWrites(prisma, { cycleUpdateAttempted = false } = {}) { for (const fn of [prisma.budgetPool.create, prisma.budgetPool.update, prisma.performanceReviewCycle.create, ...(!cycleUpdateAttempted ? [prisma.performanceReviewCycle.update] : []), prisma.performanceReview.create, prisma.performanceReview.update, prisma.performanceReviewDimension.deleteMany, prisma.performanceReviewDimension.createMany, prisma.auditLog.create]) expect(fn).not.toHaveBeenCalled(); } function expectFailure(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); } describe("budget route error contracts", () => { it.each([ ["GET", "/budgets?page=0", undefined, "budget.query_invalid", "page"], ["POST", "/budgets", { ...budgetInput, allocatedAmount: "-1" }, "budget.create_input_invalid", "allocatedAmount"], ["PATCH", `/budgets/${ID}`, { warningThresholdPercent: 101 }, "budget.update_input_invalid", "warningThresholdPercent"], ])("%s %s rejects invalid input before budget access", async (method, path, payload, reasonCode, field) => { const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe("VALID_001"); expect(response.json().error.details).toMatchObject({ reasonCode, formErrors: [], fieldErrors: { [field]: expect.any(Array) } }); expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled(); expect(prisma.budgetPool.findMany).not.toHaveBeenCalled(); expect(prisma.budgetPool.count).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it.each([["PATCH", "/budgets/bad", { name: "合成" }], ["POST", "/budgets/bad/archive", undefined]])( "%s %s rejects an invalid id", async (method, path, payload) => { const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); expectFailure(response, 400, "budget.id_invalid", { id: "invalid_uuid" }); expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled(); expectNoWrites(prisma); }, ); it.each([ ["department", null, { departmentId: ID }, 400, "budget.department_not_found", { departmentId: "not_found" }], ["department", { id: ID, archived_at: new Date("2024-01-01Z") }, { departmentId: ID }, 409, "budget.department_archived", { departmentId: "archived" }], ["legalEntity", null, { legalEntityId: ID, costCenterId: ID }, 400, "budget.legal_entity_not_found", { legalEntityId: "not_found" }], ["legalEntity", { id: ID, enabled: false }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.legal_entity_unavailable", { legalEntityId: "disabled_or_archived" }], ["legalEntity", { id: ID, enabled: true, archived_at: new Date("2024-01-01Z") }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.legal_entity_unavailable", { legalEntityId: "disabled_or_archived" }], ["costCenter", null, { legalEntityId: ID, costCenterId: ID }, 400, "budget.cost_center_not_found", { costCenterId: "not_found" }], ["costCenter", { id: ID, enabled: false }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_unavailable", { costCenterId: "disabled_or_archived" }], ["costCenter", { id: ID, enabled: true, archived_at: new Date("2024-01-01Z") }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_unavailable", { costCenterId: "disabled_or_archived" }], ["costCenter", { id: ID, enabled: true, legal_entity_id: OTHER_ID }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_company_mismatch", { costCenterId: "legalEntityId_mismatch" }], ])("preserves reference rejection %s %j", async (model, row, scope, status, reason, details) => { const { app, prisma } = await buildApp(); prisma[model].findUnique.mockResolvedValue(row); const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, ...scope } }); expectFailure(response, status, reason, details); if (model === "legalEntity") expect(prisma.costCenter.findUnique).not.toHaveBeenCalled(); expectNoWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); }); it.each([ ["PATCH", `/budgets/${ID}`, {}, "budget.update_empty", { body: "empty_patch" }], ["POST", "/budgets", { ...budgetInput, periodEnd: "2024-02-28" }, "budget.period_invalid", { periodEnd: "must_be_after_periodStart" }], ])("preserves %s %s business validation", async (method, path, payload, reason, details) => { const { app, prisma } = await buildApp(); expectFailure(await app.inject({ method, url: `/api${path}`, payload }), 400, reason, details); expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled(); expect(prisma.department.findUnique).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it("explains the shared scope conflict without writes", async () => { const { app, prisma } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, departmentId: ID, legalEntityId: ID, costCenterId: ID } }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ scope: "mixed_dimensions_not_allowed", reasonCode: "budget.scope_mixed" }); expect(prisma.department.findUnique).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it("keeps a valid scoped budget, patch and archive working", async () => { const { app, prisma } = await buildApp(); const created = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, legalEntityId: ID, costCenterId: ID } }); expect(created.statusCode).toBe(200); expect(prisma.budgetPool.create.mock.calls[0][0].data).toMatchObject({ legal_entity_id: ID, cost_center_id: ID, period_start: new Date("2024-02-29T00:00:00Z"), allocated_amount: "100.00" }); expect((await app.inject({ method: "PATCH", url: `/api/budgets/${ID}`, payload: { name: "合成修改", warningThresholdPercent: 90 } })).statusCode).toBe(200); expect((await app.inject({ method: "POST", url: `/api/budgets/${ID}/archive` })).statusCode).toBe(200); expect(prisma.auditLog.create).toHaveBeenCalledTimes(3); }); }); describe("performance route error contracts", () => { it.each([ ["POST", "/performance/cycles", { ...cycleInput, name: "" }, "performance.cycle_create_input_invalid", "name"], ["PATCH", `/performance/cycles/${ID}`, { cycleType: "invalid" }, "performance.cycle_update_input_invalid", "cycleType"], ["PUT", `/performance/cycles/${ID}/reviews/${ID}`, { overallScore: 4.2 }, "performance.review_input_invalid", "overallScore"], ["PUT", `/performance/cycles/${ID}/reviews/${ID}`, { overallScore: 4, dimensions: [{ dimensionType: "teamwork", score: 4 }, { dimensionType: "teamwork", score: 5 }] }, "performance.review_input_invalid", "dimensions"], ])("%s %s rejects invalid operation input before business access", async (method, path, payload, reason, field) => { const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: `/api${path}`, payload }); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe("VALID_001"); expect(response.json().error.details).toMatchObject({ reasonCode: reason, formErrors: [], fieldErrors: { [field]: expect.any(Array) } }); expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled(); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it.each([ ["PATCH", "/performance/cycles/bad", { name: "合成" }], ["GET", "/performance/cycles/bad/reviews"], ["GET", "/performance/employees/bad/reviews"], ["PUT", `/performance/cycles/${ID}/reviews/bad`, { overallScore: 4 }], ])("%s %s rejects invalid identifiers", async (method, path, payload) => { const { app, prisma } = await buildApp(); expectFailure(await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }), 400, "performance.id_invalid", { id: "invalid_uuid" }); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it.each(["POST", "PATCH"])("%s retains the reversed date guard", async (method) => { const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: `/api/performance/cycles${method === "PATCH" ? `/${ID}` : ""}`, payload: method === "POST" ? { ...cycleInput, endDate: "2024-02-28" } : { endDate: "2024-02-28" } }); expectFailure(response, 400, "performance.cycle_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }); expectNoWrites(prisma); }); it.each([ ["completed", { name: "合成" }, "performance.completed_cycle_locked", "completed_cycle_locked"], ["active", { name: "合成" }, "performance.active_cycle_only_complete", "active_cycle_only_supports_complete"], ["draft", { status: "completed" }, "performance.draft_cannot_complete", "draft_cycle_cannot_complete_directly"], ])("retains %s cycle mutation guard", async (status, payload, reason, detail) => { const { app, prisma, cycle } = await buildApp(); cycle.status = status; expectFailure(await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload }), 409, reason, { cycle: detail }); expectNoWrites(prisma); }); it.each(["precheck", "unique_constraint"])("explains another active cycle at %s", async (stage) => { const { app, prisma } = await buildApp(); if (stage === "precheck") prisma.performanceReviewCycle.findFirst.mockResolvedValue({ id: OTHER_ID }); else prisma.performanceReviewCycle.update.mockRejectedValue({ code: "P2002" }); const response = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload: { status: "active" } }); expectFailure(response, 409, "performance.active_cycle_exists", { cycle: "active_cycle_exists", ...(stage === "precheck" ? { activeCycleId: OTHER_ID } : {}) }); expectNoWrites(prisma, { cycleUpdateAttempted: stage === "unique_constraint" }); if (stage === "unique_constraint") expect(prisma.performanceReviewCycle.update).toHaveBeenCalledTimes(1); }); it("preserves non-unique database failures without a misleading conflict reason", async () => { const { app, prisma } = await buildApp(); prisma.performanceReviewCycle.update.mockRejectedValue(new Error("synthetic_database_failure")); const response = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload: { status: "active" } }); expect(response.statusCode).toBe(500); expect(response.json().error).toEqual({ code: "SYS_001", details: null }); expectNoWrites(prisma, { cycleUpdateAttempted: true }); }); it("rejects inactive employees before the review transaction", async () => { const { app, prisma } = await buildApp(); prisma.employee.findUnique.mockResolvedValue({ id: ID, status: "inactive" }); expectFailure(await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4 } }), 409, "performance.employee_inactive", { employee: "inactive_employee_not_reviewable" }); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it("rechecks the locked cycle before any review or dimension writes", async () => { const { app, prisma, cycle } = await buildApp(); cycle.status = "active"; prisma.$queryRaw.mockResolvedValue([{ id: ID, status: "completed" }]); expectFailure(await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4 } }), 409, "performance.active_cycle_required", { cycle: "active_cycle_required" }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); expect(prisma.performanceReview.findFirst).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it("preserves the disabled feature rejection before cycle queries", async () => { const { app, prisma } = await buildApp({ enabled: false }); expectFailure(await app.inject({ method: "GET", url: "/api/performance/cycles" }), 409, "performance.disabled", { featureFlag: "employee_lifecycle_v1_disabled", reason: "flag_force_off" }); expect(prisma.performanceReviewCycle.findMany).not.toHaveBeenCalled(); expectNoWrites(prisma); }); it("still writes a valid admin review in the existing transaction", async () => { const { app, prisma } = await buildApp(); const response = await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4.5, dimensions: [{ dimensionType: "teamwork", score: 4 }] } }); expect(response.statusCode).toBe(200); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(prisma.performanceReview.create.mock.calls[0][0].data).toMatchObject({ overall_score: 4.5, cycle_id: ID, employee_id: ID }); expect(prisma.performanceReviewDimension.createMany).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); }); }); it.each(["/budgets", "/performance/cycles"])("keeps permission rejection before %s writes", async (path) => { const { app, prisma } = await buildApp({ allowed: false }); const response = await app.inject({ method: "POST", url: `/api${path}`, payload: {} }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); expectNoWrites(prisma); });