文件
hyb-oa/server/tests/unit/routes/users.import.test.js
T
2026-03-28 18:57:02 +08:00

347 行
11 KiB
JavaScript

const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const ExcelJS = require("exceljs");
const usersRoutes = require("../../../src/routes/users");
const { AppError } = require("../../../src/utils/AppError");
function buildMultipartRequest({ filename, contentType, content }) {
const boundary = "----codex-user-import-boundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), tail]);
return {
payload: body,
headers: {
"content-type": `multipart/form-data; boundary=${boundary}`,
},
};
}
async function buildXlsxBuffer(rows) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("users");
worksheet.addRow([
"employeeNo",
"name",
"phone",
"email",
"departmentName",
"title",
"directManagerEmployeeNo",
"roles",
"hireDate",
]);
for (const row of rows) {
worksheet.addRow([
row.employeeNo ?? "",
row.name ?? "",
row.phone ?? "",
row.email ?? "",
row.departmentName ?? "",
row.title ?? "",
row.directManagerEmployeeNo ?? "",
row.roles ?? "",
row.hireDate ?? "",
]);
}
return Buffer.from(await workbook.xlsx.writeBuffer());
}
async function buildApp(options = {}) {
const app = Fastify();
const existingEmployees = options.existingEmployees ?? [];
const departments = options.departments ?? [];
const auditCreate = vi.fn().mockResolvedValue({});
const roleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
const employeeUpdate = vi.fn().mockResolvedValue({});
let createIndex = 0;
const employeeCreate = vi.fn(async ({ data }) => {
createIndex += 1;
return {
id: `00000000-0000-4000-8000-${String(createIndex).padStart(12, "0")}`,
employee_no: data.employee_no,
name: data.name,
phone: data.phone,
email: data.email,
status: data.status,
department_id: data.department_id,
title: data.title,
created_at: data.created_at,
updated_at: data.updated_at,
};
});
const employeeFindMany = vi.fn(async () => existingEmployees);
const departmentFindMany = vi.fn(async () => departments);
const tx = {
department: { findMany: departmentFindMany },
employee: {
findMany: employeeFindMany,
create: employeeCreate,
update: employeeUpdate,
},
employeeRole: {
createMany: roleCreateMany,
},
auditLog: {
create: auditCreate,
},
};
app.decorate("config", { appTimezone: "Asia/Shanghai" });
app.decorate("prisma", {
...tx,
$transaction: vi.fn(async (callback) => callback(tx)),
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = {
id: "actor-1",
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
permissionCodes: ["system.users"],
};
const permissions = request.employee.permissionCodes ?? [];
if (!permissions.includes("*") && !permissions.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("authenticate", async (request) => {
request.employee = {
id: "actor-1",
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
permissionCodes: ["system.users"],
};
});
app.decorate("hasPermission", () => true);
await app.register(multipart, {
attachFieldsToBody: false,
limits: { fileSize: 1024 * 1024, files: 1 },
});
await app.register(usersRoutes, { prefix: "/api" });
return {
app,
auditCreate,
departmentFindMany,
employeeCreate,
employeeFindMany,
employeeUpdate,
roleCreateMany,
};
}
describe("users import routes", () => {
it("supports xlsx dry-run and allows manager references to later rows in the same file", async () => {
const { app } = await buildApp({
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
});
const fileBuffer = await buildXlsxBuffer([
{
employeeNo: "E003",
name: "Carol",
phone: "13800000003",
email: "[email protected]",
departmentName: "行政部",
directManagerEmployeeNo: "E002",
hireDate: "2026-03-28",
},
{
employeeNo: "E002",
name: "Bob",
phone: "13800000002",
email: "[email protected]",
departmentName: "行政部",
title: "经理",
roles: "manager",
hireDate: "2026-03-27",
},
]);
const multipartRequest = buildMultipartRequest({
filename: "users.xlsx",
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content: fileBuffer,
});
const response = await app.inject({
method: "POST",
url: "/api/users/import/dry-run",
...multipartRequest,
});
expect(response.statusCode).toBe(200);
const payload = response.json().data;
expect(payload.summary).toEqual({
totalRows: 2,
validRows: 2,
invalidRows: 0,
});
expect(payload.rows[0].normalized.title).toBe("员工");
expect(payload.rows[0].normalized.directManagerEmployeeNo).toBe("E002");
expect(payload.rows[0].errors).toEqual([]);
await app.close();
});
it("rejects dry-run uploads with unsupported mime type", async () => {
const { app } = await buildApp();
const multipartRequest = buildMultipartRequest({
filename: "users.csv",
contentType: "application/json",
content: "employeeNo,name,phone,email,departmentName\nE001,Alice,13800000000,[email protected],行政部\n",
});
const response = await app.inject({
method: "POST",
url: "/api/users/import/dry-run",
...multipartRequest,
});
expect(response.statusCode).toBe(400);
await app.close();
});
it("reports duplicate fields, archived departments and inactive managers in dry-run output", async () => {
const { app } = await buildApp({
departments: [{ id: "dept-archived", name: "归档部门", archived_at: new Date("2026-03-01T00:00:00.000Z") }],
existingEmployees: [
{
id: "emp-existing",
employee_no: "E001",
phone: "13800000009",
email: "[email protected]",
status: "active",
},
{
id: "mgr-archived",
employee_no: "M001",
phone: "13800000008",
email: "[email protected]",
status: "archived",
},
],
});
const multipartRequest = buildMultipartRequest({
filename: "users.csv",
contentType: "text/csv",
content: [
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
"E001,Alice,13800000009,[email protected],归档部门,,M001,employee,2026-03-28",
"E001,Bob,13800000007,[email protected],缺失部门,,,employee,2026-03-28",
].join("\n"),
});
const response = await app.inject({
method: "POST",
url: "/api/users/import/dry-run",
...multipartRequest,
});
expect(response.statusCode).toBe(200);
const payload = response.json().data;
expect(payload.summary.invalidRows).toBe(2);
expect(payload.rows[0].errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: "employeeNo", message: "duplicate_existing" }),
expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }),
expect.objectContaining({ field: "departmentName", message: "archived" }),
expect.objectContaining({ field: "directManagerEmployeeNo", message: "inactive" }),
]),
);
expect(payload.rows[1].errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }),
expect.objectContaining({ field: "departmentName", message: "not_found" }),
]),
);
await app.close();
});
it("applies a valid csv import in one transaction and returns one-time passwords", async () => {
const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
});
const multipartRequest = buildMultipartRequest({
filename: "users.csv",
contentType: "text/csv",
content: [
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
"E003,Carol,13800000003,[email protected],行政部,,E002,employee,2026-03-28",
"E002,Bob,13800000002,[email protected],行政部,经理,,manager,2026-03-27",
].join("\n"),
});
const response = await app.inject({
method: "POST",
url: "/api/users/import/apply",
...multipartRequest,
});
expect(response.statusCode).toBe(200);
const payload = response.json().data;
expect(payload.summary).toEqual({
totalRows: 2,
validRows: 2,
invalidRows: 0,
});
expect(payload.imported).toHaveLength(2);
expect(payload.imported[0]).toEqual(
expect.objectContaining({
employeeNo: "E003",
email: "[email protected]",
}),
);
expect(payload.imported[0].oneTimePassword).toMatch(/^ResetA1[0-9a-f]{12}$/);
expect(employeeCreate).toHaveBeenCalledTimes(2);
expect(roleCreateMany).toHaveBeenCalledTimes(2);
expect(employeeUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "00000000-0000-4000-8000-000000000001" },
data: expect.objectContaining({
direct_manager_id: "00000000-0000-4000-8000-000000000002",
}),
}),
);
expect(auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "SYSTEM_USERS_IMPORTED",
}),
}),
);
await app.close();
});
it("refuses apply when preview contains invalid rows and does not create employees", async () => {
const { app, employeeCreate } = await buildApp();
const multipartRequest = buildMultipartRequest({
filename: "users.csv",
contentType: "text/csv",
content: [
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
"E003,Carol,13800000003,[email protected],不存在部门,,,employee,2026-03-28",
].join("\n"),
});
const response = await app.inject({
method: "POST",
url: "/api/users/import/apply",
...multipartRequest,
});
expect(response.statusCode).toBe(409);
expect(employeeCreate).not.toHaveBeenCalled();
await app.close();
});
});