329 行
11 KiB
JavaScript
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();
|
|
});
|
|
});
|