const Fastify = require("fastify"); const employeesRoutes = require("../../../src/routes/employees"); const { AppError } = require("../../../src/utils/AppError"); const ACTOR_ID = "11111111-1111-4111-8111-111111111111"; const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const TASK_ID = "33333333-3333-4333-8333-333333333333"; const originalFetch = global.fetch; function buildEmployeeRow(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "HYD000888", name: "测试员工", phone: "13800000000", email: "hrwd@example.com", status: "active", employment_status: "active", department_id: DEPARTMENT_ID, title: "工程师", direct_manager_id: null, hire_date: new Date("2026-05-01T00:00:00.000Z"), probation_end_date: null, last_working_date: null, work_location: null, emergency_contact_name: null, emergency_contact_phone: null, notes: null, avatar_path: null, archived_at: null, roles: [{ role_code: "employee" }], department: { id: DEPARTMENT_ID, name: "研发部" }, direct_manager: null, wecom_identity_binding: null, wecom_directory_sync_runs: [], ...overrides, }; } function buildFlagRecord(flagKey) { return { id: `${flagKey}-id`, flag_key: flagKey, override_mode: "force_on", rollout_type: "global", department_ids: [], note: "test", version: 1, created_at: new Date("2026-05-17T00:00:00.000Z"), created_by: null, updated_at: new Date("2026-05-17T00:00:00.000Z"), updated_by: null, }; } function buildWecomFetch() { return vi.fn(async (url) => { const target = String(url); if (target.includes("/cgi-bin/gettoken")) { return { ok: true, json: async () => ({ errcode: 0, errmsg: "ok", access_token: "mock-access-token", expires_in: 7200 }), }; } if (target.includes("/cgi-bin/user/create") || target.includes("/cgi-bin/user/update")) { return { ok: true, json: async () => ({ errcode: 0, errmsg: "ok" }), }; } return { ok: false, status: 404, json: async () => ({ errcode: 404, errmsg: "not found" }), }; }); } async function buildApp({ actorPermissions = ["system.users"], config = {}, employeeOverrides = {}, initialTaskStatus = "pending", } = {}) { const app = Fastify(); let employee = buildEmployeeRow(employeeOverrides); let latestRun = null; let taskStatus = initialTaskStatus; const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); const bindingUpsert = vi.fn(async ({ create, update }) => { employee = { ...employee, wecom_identity_binding: { id: "binding-1", external_user_id: update?.external_user_id ?? create.external_user_id, external_open_user_id: null, }, }; return employee.wecom_identity_binding; }); const syncRunCreate = vi.fn(async ({ data }) => { latestRun = { id: "sync-run-1", employee_id: data.employee_id, external_user_id: data.external_user_id ?? null, operation: data.operation, status: data.status, attempts: data.attempts ?? 1, error_code: null, error_message: null, response_summary: null, triggered_by: data.triggered_by ?? null, created_at: new Date("2026-05-17T12:00:00.000Z"), updated_at: new Date("2026-05-17T12:00:00.000Z"), }; return latestRun; }); const syncRunUpdate = vi.fn(async ({ data }) => { latestRun = { ...latestRun, status: data.status, error_code: data.error_code ?? null, error_message: data.error_message ?? null, response_summary: data.response_summary ?? null, updated_at: new Date("2026-05-17T12:01:00.000Z"), }; employee = { ...employee, wecom_directory_sync_runs: latestRun ? [latestRun] : [], }; return latestRun; }); app.decorate("config", { employeeLifecycleEnabled: true, wecomContactSyncEnabled: true, wecomCorpId: "wx-test-corp", wecomContactSecret: "contact-secret", wecomContactDefaultDepartmentId: "1", ...config, }); app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: actorPermissions, }; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: actorPermissions, }; if (!actorPermissions.includes("*") && !actorPermissions.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("hasPermission", (_employee, permissionCode) => actorPermissions.includes("*") || actorPermissions.includes(permissionCode)); app.decorate("prisma", { featureFlag: { findUnique: vi.fn(async ({ where }) => buildFlagRecord(where.flag_key)), }, employee: { findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? employee : null)), update: vi.fn(async ({ data }) => { employee = { ...employee, ...(data.status !== undefined ? { status: data.status } : {}), ...(data.employment_status !== undefined ? { employment_status: data.employment_status } : {}), ...(data.archived_at !== undefined ? { archived_at: data.archived_at } : {}), ...(data.token_version !== undefined ? { token_version: 1 } : {}), }; return employee; }), findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), }, employeeChecklistTask: { findFirst: vi.fn(async ({ where }) => (where.id === TASK_ID ? { id: TASK_ID, employee_id: EMPLOYEE_ID, status: taskStatus } : null)), findMany: vi.fn().mockResolvedValue([]), count: vi.fn(async ({ where }) => { if (where.employee_id === EMPLOYEE_ID && where.scene === "offboarding" && where.status === "pending") { return taskStatus === "pending" ? 0 : 0; } return 0; }), update: vi.fn(async ({ data }) => { taskStatus = data.status; return { id: TASK_ID, employee_id: EMPLOYEE_ID, status: taskStatus }; }), }, employeeLifecycleEvent: { findMany: vi.fn().mockResolvedValue([]), }, assetCard: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), }, workflowTask: { count: vi.fn().mockResolvedValue(0), }, wecomIdentityBinding: { findUnique: vi.fn(async () => employee.wecom_identity_binding), upsert: bindingUpsert, }, wecomDirectorySyncRun: { create: syncRunCreate, update: syncRunUpdate, findMany: vi.fn(async () => (latestRun ? [latestRun] : [])), }, auditLog: { create: auditCreate, }, $transaction: vi.fn(async (callback) => callback(app.prisma)), }); 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: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} }, }); }); await app.register(employeesRoutes, { prefix: "/api" }); return { app, auditCreate, bindingUpsert, syncRunCreate, syncRunUpdate }; } afterEach(() => { global.fetch = originalFetch; }); describe("employees WeCom directory sync", () => { it("allows HR admins to retry WeCom member creation and keeps a sanitized sync run", async () => { global.fetch = buildWecomFetch(); const { app, bindingUpsert, syncRunUpdate } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, payload: {}, }); expect(response.statusCode).toBe(200); expect(bindingUpsert).toHaveBeenCalledWith(expect.objectContaining({ where: { employee_id: EMPLOYEE_ID } })); expect(syncRunUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "success", response_summary: expect.objectContaining({ phoneMasked: "138****0000" }), }), }), ); expect(global.fetch.mock.calls.some((call) => String(call[0]).includes("/cgi-bin/user/create"))).toBe(true); expect(response.json().data.result.run.responseSummary.phoneMasked).toBe("138****0000"); await app.close(); }); it("records SKIP when contact secret is missing and does not call WeCom", async () => { global.fetch = buildWecomFetch(); const { app } = await buildApp({ config: { wecomContactSecret: "" } }); const response = await app.inject({ method: "POST", url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, payload: { operation: "create" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.result).toEqual( expect.objectContaining({ skipped: true, status: "skipped", code: "wecom_contact_config_missing", }), ); expect(global.fetch).not.toHaveBeenCalled(); await app.close(); }); it("rejects non-HR users from reading WeCom directory sync state", async () => { const { app } = await buildApp({ actorPermissions: ["expense.view_own"] }); const response = await app.inject({ method: "GET", url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync`, }); expect(response.statusCode).toBe(403); await app.close(); }); it("disables the WeCom member when offboarding checklist completion archives the OA account", async () => { global.fetch = buildWecomFetch(); const { app } = await buildApp({ employeeOverrides: { employment_status: "offboarding", wecom_identity_binding: { external_user_id: "HYD000888", external_open_user_id: null }, }, }); const response = await app.inject({ method: "POST", url: `/api/employees/${EMPLOYEE_ID}/checklist-tasks/${TASK_ID}/complete`, payload: {}, }); expect(response.statusCode).toBe(200); expect(global.fetch.mock.calls.some((call) => String(call[0]).includes("/cgi-bin/user/update"))).toBe(true); const updateCall = global.fetch.mock.calls.find((call) => String(call[0]).includes("/cgi-bin/user/update")); expect(JSON.parse(updateCall[1].body)).toEqual(expect.objectContaining({ userid: "HYD000888", enable: 0 })); expect(response.json().data.wecomDirectorySync).toEqual(expect.objectContaining({ status: "success" })); await app.close(); }); });