const Fastify = require("fastify"); const employeesRoutes = require("../../../src/routes/employees"); const { AppError } = require("../../../src/utils/AppError"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const RECORD_ID = "22222222-2222-4222-8222-222222222222"; const baseUrl = `/api/employees/${EMPLOYEE_ID}`; const contracts = { contractNo: "SYNTHETIC", contractType: "labor", startDate: "2024-02-29", endDate: "2026-04-30" }; const trainings = { name: "合成培训", trainingType: "internal", status: "planned", startDate: "2024-02-29", endDate: "2026-04-30" }; const certificates = { name: "合成证书", issuer: "合成机构", obtainedDate: "2024-02-29", expiryDate: "2026-04-30" }; const archiveCases = [ ["contracts", contracts, "employeeContract", ["startDate", "endDate", "terminatedDate"]], ["trainings", trainings, "employeeTraining", ["startDate", "endDate"]], ["certificates", certificates, "employeeCertificate", ["obtainedDate", "expiryDate"]], ]; const lifecyclePaths = ["onboarding/start", "regularize", "transfer", "offboarding/start"]; const invalidCases = [ ...["hireDate", "probationEndDate", "lastWorkingDate"].map((field) => ["PATCH", "profile", {}, field]), ...lifecyclePaths.map((route) => ["POST", route, {}, "effectiveDate"]), ["POST", "onboarding/start", {}, "probationEndDate"], ["POST", "offboarding/start", {}, "lastWorkingDate"], ...archiveCases.flatMap(([route, payload, , fields]) => fields.flatMap((field) => [ ["POST", route, payload, field], ["PATCH", `${route}/${RECORD_ID}`, {}, field], ])), ]; async function buildApp({ allowed = true, self = false } = {}) { const app = Fastify(); const employee = { id: EMPLOYEE_ID, roles: [], department_id: null, status: "active", employment_status: "active", hire_date: new Date("2020-01-01T00:00:00Z") }; const prisma = { featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: "force_on", rollout_type: "global", department_ids: [] }) } }; for (const model of ["employee", "employeeContract", "employeeTraining", "employeeCertificate", "employeeLifecycleEvent", "employeeChecklistTask", "employeeRole", "auditLog", "wecomDirectorySyncRun", "assetCard", "workflowTask"]) { const row = model === "employee" ? employee : { id: RECORD_ID, employee_id: EMPLOYEE_ID, start_date: new Date("2024-02-29T00:00:00Z"), end_date: new Date("2026-04-30T00:00:00Z"), obtained_date: new Date("2024-02-29T00:00:00Z"), expiry_date: new Date("2026-04-30T00:00:00Z") }; prisma[model] = { findUnique: vi.fn().mockResolvedValue(row), findFirst: vi.fn().mockResolvedValue(row), findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => Object.assign(row, Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)))), createMany: vi.fn().mockResolvedValue({ count: 1 }), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), }; } prisma.$transaction = vi.fn(async (fn) => fn(prisma)); const authenticate = async (request) => { request.employee = { id: self ? EMPLOYEE_ID : "synthetic-actor", permissionCodes: allowed ? ["system.users", "employee.contract.manage"] : [] }; }; app.decorate("config", { employeeLifecycleEnabled: true, appTimezone: "Asia/Shanghai", wecomDirectorySyncEnabled: false }); app.decorate("prisma", prisma); app.decorate("authenticate", authenticate); app.decorate("hasPermission", (actor, permission) => actor.permissionCodes.includes(permission)); app.decorate("requirePermission", (permission) => async (request) => { await 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, details: error.details } })); await app.register(employeesRoutes, { prefix: "/api" }); return { app, prisma }; } function expectNoWrites(prisma) { expect(prisma.$transaction).not.toHaveBeenCalled(); for (const model of Object.values(prisma)) for (const operation of ["create", "update", "createMany", "deleteMany"]) if (model[operation]) expect(model[operation]).not.toHaveBeenCalled(); } describe("employee calendar inputs before side effects", () => { it.each(invalidCases)("%s %s rejects invalid %s %s", async (method, route, payload, field) => { const { app, prisma } = await buildApp(); try { for (const date of ["2026-02-29", "2026-04-31"]) { const response = await app.inject({ method, url: `${baseUrl}/${route}`, payload: { ...payload, [field]: date } }); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe("VALID_001"); const kind = route.split("/")[0]; const reason = { profile: "profile_input_invalid", onboarding: "onboarding_input_invalid", regularize: "regularize_input_invalid", transfer: "transfer_input_invalid", offboarding: "offboarding_input_invalid" }[kind] ?? `${{ contracts: "contract", trainings: "training", certificates: "certificate" }[kind]}_${method === "POST" ? "create" : "update"}_input_invalid`; expect(response.json().error.details.reasonCode).toBe(`employee.${reason}`); expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date"); expectNoWrites(prisma); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); } } finally { await app.close(); } }); it.each(lifecyclePaths.flatMap((route) => ["2024-02-29", "2026-04-30", undefined].map((date) => [route, date])))( "%s preserves effective date %s and its original lifecycle event", async (route, effectiveDate) => { const { app, prisma } = await buildApp(); try { const before = Date.now(); const response = await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload: effectiveDate ? { effectiveDate } : {} }); expect(response.statusCode).toBe(200); expect(prisma.employeeLifecycleEvent.create).toHaveBeenCalledTimes(1); const data = prisma.employeeLifecycleEvent.create.mock.calls[0][0].data; expect(data.event_type).toBe({ "onboarding/start": "onboard", regularize: "regularize", transfer: "transfer", "offboarding/start": "offboard" }[route]); if (effectiveDate) expect(data.effective_date.toISOString()).toBe(`${effectiveDate}T00:00:00.000Z`); else { expect(data.effective_date.getTime()).toBeGreaterThanOrEqual(before); expect(data.effective_date.getTime()).toBeLessThanOrEqual(Date.now()); } expect(prisma.wecomDirectorySyncRun.create).not.toHaveBeenCalled(); } finally { await app.close(); } }, ); it.each(archiveCases)("%s accepts valid boundaries and nullable patches", async (route, payload, model) => { const { app, prisma } = await buildApp(); try { expect((await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload })).statusCode).toBe(200); const data = prisma[model].create.mock.calls[0][0].data; expect((data.start_date ?? data.obtained_date).toISOString()).toBe("2024-02-29T00:00:00.000Z"); expect((data.end_date ?? data.expiry_date).toISOString()).toBe("2026-04-30T00:00:00.000Z"); const field = route === "contracts" ? "terminatedDate" : route === "trainings" ? "endDate" : "expiryDate"; expect((await app.inject({ method: "PATCH", url: `${baseUrl}/${route}/${RECORD_ID}`, payload: { [field]: null } })).statusCode).toBe(200); const updated = prisma[model].update.mock.calls[0][0].data; expect(updated[route === "contracts" ? "terminated_date" : route === "trainings" ? "end_date" : "expiry_date"]).toBeNull(); expect(updated).not.toHaveProperty(route === "certificates" ? "obtained_date" : "start_date"); } finally { await app.close(); } }); it.each(["hireDate", "probationEndDate", "lastWorkingDate"])("profile preserves %s valid/null/omitted", async (field) => { const { app, prisma } = await buildApp(); const column = { hireDate: "hire_date", probationEndDate: "probation_end_date", lastWorkingDate: "last_working_date" }[field]; try { for (const date of ["2024-02-29", "2026-04-30", null]) { expect((await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { [field]: date } })).statusCode).toBe(200); const data = prisma.employee.update.mock.calls.at(-1)[0].data; expect(data[column]).toEqual(date ? new Date(`${date}T00:00:00Z`) : null); } expect((await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { notes: "synthetic" } })).statusCode).toBe(200); expect(prisma.employee.update.mock.calls.at(-1)[0].data).not.toHaveProperty(column); } finally { await app.close(); } }); it.each(lifecyclePaths)("%s preserves unauthorized refusal", async (route) => { const { app, prisma } = await buildApp({ allowed: false }); try { expect((await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload: { effectiveDate: "2024-02-29" } })).statusCode).toBe(403); expectNoWrites(prisma); } finally { await app.close(); } }); }); describe("employee archive and lifecycle error reasons", () => { it.each([ ["contracts", { ...contracts, endDate: "2024-02-28" }, "employee.contract_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }], ["contracts", { ...contracts, terminatedDate: "2024-02-28" }, "employee.contract_termination_date_invalid", { terminatedDate: "must_be_after_or_equal_startDate" }], ["trainings", { ...trainings, endDate: "2024-02-28" }, "employee.training_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }], ["certificates", { ...certificates, expiryDate: "2024-02-28" }, "employee.certificate_date_range_invalid", { expiryDate: "must_be_after_or_equal_obtainedDate" }], ])("retains the date range guard for %s", async (route, payload, reasonCode, details) => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expectNoWrites(prisma); } finally { await app.close(); } }); it.each(["POST", "PATCH"])("explains duplicate contract numbers on %s", async (method) => { const { app, prisma } = await buildApp(); try { const write = method === "POST" ? prisma.employeeContract.create : prisma.employeeContract.update; write.mockRejectedValue({ code: "P2002" }); const response = await app.inject({ method, url: `${baseUrl}/contracts${method === "PATCH" ? `/${RECORD_ID}` : ""}`, payload: contracts }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ contractNo: "duplicate", reasonCode: "employee.contract_number_duplicate" }); expect(write).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("rejects self-only forbidden fields before persistence", async () => { const { app, prisma } = await buildApp({ allowed: false, self: true }); try { const response = await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { hireDate: "2024-02-29" } }); expect(response.statusCode).toBe(403); expect(response.json().error.details).toEqual({ body: "self_profile_fields_forbidden:hireDate", reasonCode: "employee.self_profile_fields_forbidden" }); expectNoWrites(prisma); } finally { await app.close(); } }); it("preserves invalid id and lifecycle disabled guards", async () => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "PATCH", url: "/api/employees/bad/profile", payload: { notes: "合成" } }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "employee.id_invalid" }); prisma.featureFlag.findUnique.mockResolvedValue({ override_mode: "force_off", rollout_type: "global", department_ids: [] }); const disabled = await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { notes: "合成" } }); expect(disabled.statusCode).toBe(409); expect(disabled.json().error.details).toEqual({ featureFlag: "employee_lifecycle_v1_disabled", reason: "flag_force_off", reasonCode: "employee.lifecycle_disabled" }); expectNoWrites(prisma); } finally { await app.close(); } }); it.each([ ["POST", "/employees/wecom-hr-master-sync/runs", { mode: "invalid" }, "employee.hr_sync_input_invalid"], ["GET", "/employees/wecom-hr-master-sync/runs?pageSize=0", undefined, "employee.hr_sync_query_invalid"], ["POST", `/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, { operation: "invalid" }, "employee.directory_retry_input_invalid"], ])("validates %s %s without any external request", async (method, path, payload, reasonCode) => { const { app, prisma } = await buildApp(); const originalFetch = global.fetch; const noFetch = vi.fn(() => { throw new Error("external_request_forbidden"); }); global.fetch = noFetch; try { app.config.wecomHrMasterSyncEnabled = true; for (const model of ["wecomHrMasterSyncRun", "wecomHrMasterSyncItem", "wecomIdentityBinding", "integrationBinding"]) prisma[model] = { findMany: vi.fn(), create: vi.fn() }; const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode, fieldErrors: expect.any(Object) }); expectNoWrites(prisma); expect(noFetch).not.toHaveBeenCalled(); } finally { global.fetch = originalFetch; await app.close(); } }); it.each([ ["POST", "/employees/wecom-hr-master-sync/runs", false, "employee.hr_sync_disabled", { featureFlag: "wecom_hr_master_sync_v1_disabled", reason: "env_hard_off" }], ["POST", "/employees/wecom-hr-master-sync/runs", true, "employee.hr_sync_unavailable", { featureFlag: "wecom_hr_master_sync_v1_unavailable" }], ["GET", `/employees/${EMPLOYEE_ID}/wecom-directory-sync`, false, "employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }], ["POST", `/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, false, "employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }], ])("retains sync capability guards for %s %s", async (method, path, hrEnabled, reasonCode, details) => { const { app, prisma } = await buildApp(); try { app.config.wecomHrMasterSyncEnabled = hrEnabled; const response = await app.inject({ method, url: `/api${path}`, ...(method === "POST" ? { payload: {} } : {}) }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expectNoWrites(prisma); } finally { await app.close(); } }); });