文件
hyb-oa/server/tests/unit/routes/employees.wecomDirectory.test.js
T

329 行
11 KiB
JavaScript

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: "[email protected]",
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();
});
});