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: "carol@example.com", departmentName: "行政部", directManagerEmployeeNo: "E002", hireDate: "2026-03-28", }, { employeeNo: "E002", name: "Bob", phone: "13800000002", email: "bob@example.com", 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,alice@example.com,行政部\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: "existing@example.com", status: "active", }, { id: "mgr-archived", employee_no: "M001", phone: "13800000008", email: "manager@example.com", status: "archived", }, ], }); const multipartRequest = buildMultipartRequest({ filename: "users.csv", contentType: "text/csv", content: [ "employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", "E001,Alice,13800000009,alice@example.com,归档部门,,M001,employee,2026-03-28", "E001,Bob,13800000007,bob@example.com,缺失部门,,,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,carol@example.com,行政部,,E002,employee,2026-03-28", "E002,Bob,13800000002,bob@example.com,行政部,经理,,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: "carol@example.com", }), ); 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,carol@example.com,不存在部门,,,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(); }); });