443 行
14 KiB
JavaScript
443 行
14 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 actor = options.actor ?? {
|
|
id: "actor-1",
|
|
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
roleCodes: ["employee", "system_admin"],
|
|
permissionCodes: ["system.accounts.manage"],
|
|
};
|
|
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 = actor;
|
|
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 = actor;
|
|
});
|
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
|
const permissions = employee?.permissionCodes ?? [];
|
|
return permissions.includes("*") || permissions.includes(permissionCode);
|
|
});
|
|
|
|
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("rejects the legacy system.users permission before reading the import", async () => {
|
|
const { app, auditCreate, departmentFindMany, employeeFindMany } = await buildApp({
|
|
actor: {
|
|
id: "actor-1",
|
|
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
roleCodes: ["employee"],
|
|
permissionCodes: ["system.users"],
|
|
},
|
|
});
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
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(403);
|
|
expect(departmentFindMany).not.toHaveBeenCalled();
|
|
expect(employeeFindMany).not.toHaveBeenCalled();
|
|
expect(auditCreate).not.toHaveBeenCalled();
|
|
|
|
await app.close();
|
|
});
|
|
|
|
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("accepts an import without a phone header and persists a missing phone as null", async () => {
|
|
const { app, employeeCreate } = await buildApp({
|
|
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
|
});
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
content: [
|
|
"employeeNo,name,email,departmentName,title,roles,hireDate",
|
|
"E006,NoPhone,[email protected],行政部,员工,employee,2026-07-27",
|
|
].join("\n"),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/apply",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(employeeCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ phone: null }),
|
|
select: expect.any(Object),
|
|
});
|
|
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.each(["admin", "system_admin"])(
|
|
"prevents a system administrator from importing the protected %s role",
|
|
async (protectedRole) => {
|
|
const {
|
|
app,
|
|
auditCreate,
|
|
departmentFindMany,
|
|
employeeCreate,
|
|
employeeFindMany,
|
|
roleCreateMany,
|
|
} = 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",
|
|
`E004,Dana,13800000004,[email protected],行政部,,,${protectedRole},2026-03-29`,
|
|
].join("\n"),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/apply",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(departmentFindMany).toHaveBeenCalledTimes(1);
|
|
expect(employeeFindMany).toHaveBeenCalledTimes(1);
|
|
expect(employeeCreate).not.toHaveBeenCalled();
|
|
expect(roleCreateMany).not.toHaveBeenCalled();
|
|
expect(auditCreate).not.toHaveBeenCalled();
|
|
|
|
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();
|
|
});
|
|
});
|