const Fastify = require("fastify"); const multipart = require("@fastify/multipart"); const ExcelJS = require("exceljs"); const JSZip = require("jszip"); const fs = require("node:fs"); const path = require("node:path"); const ts = require("../../../../client/node_modules/typescript"); const usersRoutes = require("../../../src/routes/users"); const { AppError } = require("../../../src/utils/AppError"); // Exercise the actual response/logging callback without importing app/config, // opening a database connection or starting background jobs. function productionErrorHandler(logError) { const source = fs.readFileSync(path.resolve(__dirname, "../../../src/app.js"), "utf8"); const ast = ts.createSourceFile("app.js", source, ts.ScriptTarget.Latest, true); const handlers = []; function visit(node) { if (ts.isCallExpression(node) && node.expression.getText(ast) === "app.setErrorHandler") { handlers.push(node.arguments[0].getText(ast)); } ts.forEachChild(node, visit); } visit(ast); expect(handlers).toHaveLength(1); return new Function("AppError", "app", `return (${handlers[0]});`)(AppError, { log: { error: logError } }); } 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: options.fileSize ?? 1024 * 1024, files: 1 }, }); if (options.fileReader) { app.addHook("preHandler", async (request) => { request.file = options.fileReader; }); } app.setErrorHandler(options.logError ? productionErrorHandler(options.logError) : (error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); 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,alice@example.com,行政部\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: "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("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,nophone@example.com,行政部,员工,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,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.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,dana@example.com,行政部,,,${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,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(); }); }); describe("user import calendar boundary", () => { async function calendarUpload(format, dates) { const rows = dates.map((hireDate, index) => ({ employeeNo: `000${index}`, name: `Synthetic ${index}`, email: `calendar${index}@example.test`, departmentName: "合成部门", title: "员工", roles: "employee", hireDate })); const content = format === "xlsx" ? await buildXlsxBuffer(rows) : ["employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", ...rows.map((r) => [r.employeeNo, r.name, "", r.email, r.departmentName, r.title, "", r.roles, r.hireDate].join(","))].join("\n"); return buildMultipartRequest({ filename: `calendar.${format}`, contentType: format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv", content }); } it.each(["csv", "xlsx"].flatMap((format) => ["dry-run", "apply"].map((action) => [format, action])))( "%s %s keeps row errors and rejects a mixed invalid batch without writes", async (format, action) => { const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); try { const response = await app.inject({ method: "POST", url: `/api/users/import/${action}`, ...await calendarUpload(format, ["2024-02-29", "2026-02-29", "2026-04-31"]) }); expect(response.statusCode).toBe(action === "apply" ? 409 : 200); const result = action === "apply" ? response.json().error.details : response.json().data; expect(result.summary).toEqual({ totalRows: 3, validRows: 1, invalidRows: 2 }); expect(result.rows[0].errors).toEqual([]); for (const row of result.rows.slice(1)) expect(row.errors).toContainEqual({ field: "hireDate", message: "invalid" }); for (const write of [employeeCreate, employeeUpdate, roleCreateMany]) expect(write).not.toHaveBeenCalled(); if (action === "apply") { expect(auditCreate).not.toHaveBeenCalled(); expect(result.reasonCode).toBe("user.import_rows_invalid"); } else expect(auditCreate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: "SYSTEM_USERS_IMPORT_DRY_RUN" }) })); } finally { await app.close(); } }, ); it.each(["csv", "xlsx"])("%s applies leap day, month end and blank date using the existing default", async (format) => { const { app, employeeCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); const { currentDateString } = require("../../../src/utils/date"); try { const before = currentDateString("Asia/Shanghai"); const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...await calendarUpload(format, ["2024-02-29", "2026-04-30", ""]) }); expect(response.statusCode).toBe(200); expect(employeeCreate).toHaveBeenCalledTimes(3); expect(employeeCreate.mock.calls[0][0].data.employee_no).toBe("0000"); const dates = employeeCreate.mock.calls.map(([{ data }]) => data.hire_date.toISOString().slice(0, 10)); expect(dates.slice(0, 2)).toEqual(["2024-02-29", "2026-04-30"]); expect([before, currentDateString("Asia/Shanghai")]).toContain(dates[2]); } finally { await app.close(); } }); }); describe("user import file reasons", () => { const header = "employeeNo,name,email,departmentName\n"; const row = "S001,合成员工,synthetic@example.invalid,合成部门\n"; const cases = [ ["file_too_large", "user.import_file_too_large", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(20) }, { fileSize: 64 }, {}], ["missing", "user.import_file_missing", null, {}, {}], ["unsupported_extension", "user.import_extension_unsupported", { filename: "users.txt", contentType: "text/csv", content: header + row }, {}, {}], ["unsupported_mime_type", "user.import_mime_unsupported", { filename: "users.csv", contentType: "application/json", content: header + row }, {}, {}], ["missing_headers", "user.import_headers_missing", { filename: "users.csv", contentType: "text/csv", content: "employeeNo,name\nS001,合成员工\n" }, {}, { missingHeaders: ["email", "departmentName"] }], ["empty", "user.import_file_empty", { filename: "users.csv", contentType: "text/csv", content: header }, {}, {}], ["row_limit_exceeded", "user.import_row_limit_exceeded", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(501) }, {}, { limit: 500 }], ]; it.each(cases.flatMap((item) => ["dry-run", "apply"].map((operation) => [operation, ...item])))("explains %s %s before business access", async (operation, file, reasonCode, upload, options, extra) => { const { app, ...spies } = await buildApp(options); try { const request = upload ? buildMultipartRequest(upload) : { payload: "--synthetic-empty--\r\n", headers: { "content-type": "multipart/form-data; boundary=synthetic-empty" }, }; const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...request }); expect(response.statusCode).toBe(400); expect(response.json().error).toEqual({ code: "VALID_001", details: { file, reasonCode, ...extra } }); for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled(); expect(app.prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("allows 500 rows in preview and keeps the original preview audit", async () => { const { app, employeeCreate, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); try { const rows = Array.from({ length: 500 }, (_, index) => `S${index},合成员工${index},synthetic${index}@example.invalid,合成部门\n`).join(""); const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", ...buildMultipartRequest({ filename: "users.csv", contentType: "text/csv", content: header + rows }) }); expect(response.statusCode).toBe(200); expect(response.json().data.rows).toHaveLength(500); expect(employeeCreate).not.toHaveBeenCalled(); expect(app.prisma.$transaction).not.toHaveBeenCalled(); expect(auditCreate).toHaveBeenCalledOnce(); } finally { await app.close(); } }); it("keeps permission rejection before import parsing", async () => { const { app, ...spies } = await buildApp({ actor: { id: "synthetic-denied", permissionCodes: [] } }); try { const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...buildMultipartRequest({ filename: "users.txt", contentType: "text/plain", content: "synthetic" }) }); expect(response.statusCode).toBe(403); for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled(); } finally { await app.close(); } }); }); describe("user import parser boundary", () => { const marker = "SYNTHETIC_CELL_CANARY"; const header = "employeeNo,name,email,departmentName\n"; const mime = (format) => format === "csv" ? "text/csv" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; let malformed; beforeAll(async () => { const xlsx = await buildXlsxBuffer([]); malformed = { "unclosed-csv": ["csv", Buffer.from(`${header}\"${marker}`)], "bad-quote-csv": ["csv", Buffer.from(`${header}\"${marker}\"x,synthetic`)], "non-zip-xlsx": ["xlsx", Buffer.from(marker)], "truncated-xlsx": ["xlsx", xlsx.subarray(0, Math.floor(xlsx.length / 2))], }; for (const [label, xml] of [ ["closing-tag", `<${marker}>`], ["entity", `&${marker};`], ["attribute", ``], ]) { const zip = await JSZip.loadAsync(xlsx); zip.file("xl/worksheets/sheet1.xml", xml); malformed[`${label}-xlsx`] = ["xlsx", await zip.generateAsync({ type: "nodebuffer" })]; } }); afterEach(() => { vi.restoreAllMocks(); }); function expectNoBusinessAccess(app, spies) { for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled(); expect(app.prisma.$transaction).not.toHaveBeenCalled(); } for (const operation of ["dry-run", "apply"]) { it.each(["unclosed-csv", "bad-quote-csv", "non-zip-xlsx", "truncated-xlsx", "closing-tag-xlsx", "entity-xlsx", "attribute-xlsx"])(`${operation} explains %s without logging cells or accessing business data`, async (label) => { const logError = vi.fn(); const { app, ...spies } = await buildApp({ logError }); try { const [format, content] = malformed[label]; const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...buildMultipartRequest({ filename: `${marker}.${format}`, contentType: mime(format), content }) }); expect(response.statusCode).toBe(400); expect(response.json().error).toEqual({ code: "VALID_001", message: "参数校验失败", details: { file: "invalid_format", reasonCode: "user.import_file_invalid" } }); expect(response.body).not.toContain(marker); expect(logError).not.toHaveBeenCalled(); expectNoBusinessAccess(app, spies); } finally { await app.close(); } }); it.each(["csv", "xlsx"])(`${operation} preserves 500 and discards unknown %s parser error content`, async (format) => { const parserClass = require(`exceljs/lib/${format}/${format}`); const original = Object.assign(new Error(`Internal csv xlsx ${marker}`), { cause: new Error(marker), payload: marker, statusCode: 400, code: "FST_REQ_FILE_TOO_LARGE", }); vi.spyOn(parserClass.prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(original); const logError = vi.fn(); const { app, ...spies } = await buildApp({ logError }); try { const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) }); expect(response.statusCode).toBe(500); expect(response.json().error).toEqual({ code: "SYS_001", message: "服务器内部错误", details: {} }); expect(response.body).not.toContain(marker); expect(logError).toHaveBeenCalledOnce(); const logged = logError.mock.calls[0][0].err; expect(logged).not.toBe(original); expect(logged.message).toBe("User import parser failed"); expect(Object.getOwnPropertyNames(logged).sort()).toEqual(["message", "stack"]); expect(logged.stack).not.toContain(marker); expectNoBusinessAccess(app, spies); } finally { await app.close(); } }); it(`${operation} rejects permission before attempting even a malformed parse`, async () => { const read = vi.spyOn(require("exceljs/lib/csv/csv").prototype, "read"); const logError = vi.fn(); const { app, ...spies } = await buildApp({ logError, actor: { id: "synthetic-denied", permissionCodes: [] } }); try { const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), content: `${header}\"${marker}` }) }); expect(response.statusCode).toBe(403); expect(read).not.toHaveBeenCalled(); expect(logError).not.toHaveBeenCalled(); expectNoBusinessAccess(app, spies); } finally { await app.close(); } }); } it.each([ ["csv", new TypeError('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')], ["xlsx", new TypeError("Corrupted zip: can't find end of central directory")], ["csv", new Error("1:45: undefined entity.")], ["xlsx", new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')], ["xlsx", Object.assign(new Error("Corrupted zip: can't find end of central directory"), { code: "EIO" })], ["csv", new Error("Parse Error: expected: ',' OR new line got: 'x'! at 'synthetic")], ["csv", { message: 'Parse Error: missing closing: \'"\' in line: at \'synthetic\'' }], ])("does not classify a different error type or parser's message as %s input failure", async (format, error) => { vi.spyOn(require(`exceljs/lib/${format}/${format}`).prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(error); const logError = vi.fn(); const { app, ...spies } = await buildApp({ logError }); try { const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", ...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) }); expect(response.statusCode).toBe(500); expect(logError.mock.calls[0][0].err.message).toBe("User import parser failed"); expectNoBusinessAccess(app, spies); } finally { await app.close(); } }); it.each(["file", "toBuffer"])("keeps %s read failures outside the parser classification", async (stage) => { const original = new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\''); const fileReader = stage === "file" ? vi.fn().mockRejectedValue(original) : vi.fn().mockResolvedValue({ filename: "synthetic.csv", mimetype: "text/csv", toBuffer: vi.fn().mockRejectedValue(original), }); const logError = vi.fn(); const { app, ...spies } = await buildApp({ logError, fileReader }); try { const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run" }); expect(response.statusCode).toBe(500); expect(logError.mock.calls[0][0].err).toBe(original); expectNoBusinessAccess(app, spies); } finally { await app.close(); } }); it("keeps quoted commas, escaped quotes and line breaks as CSV text", async () => { const { app, employeeCreate, auditCreate } = await buildApp(); try { const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), content: `${header}0001,"合成,\"\"姓名\"\"\n第二行",synthetic@example.invalid,合成部门\n` }) }); expect(response.statusCode).toBe(200); const row = response.json().data.rows[0]; expect(row.normalized.employeeNo).toBe("0001"); expect(row.normalized.name).toBe('合成,"姓名"\n第二行'); expect(employeeCreate).not.toHaveBeenCalled(); expect(auditCreate).toHaveBeenCalledOnce(); } finally { await app.close(); } }); it.each(["dry-run", "apply"])("keeps %s database failures outside format classification", async (operation) => { const original = new Error("Corrupted zip: can't find end of central directory"); const logError = vi.fn(); const { app, departmentFindMany, employeeCreate, auditCreate } = await buildApp({ logError }); departmentFindMany.mockRejectedValueOnce(original); try { const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), content: `${header}S001,合成员工,synthetic@example.invalid,合成部门\n` }) }); expect(response.statusCode).toBe(500); expect(logError.mock.calls[0][0].err).toBe(original); expect(employeeCreate).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); // Apply intentionally performs preview reads inside its transaction. expect(app.prisma.$transaction).toHaveBeenCalledTimes(operation === "apply" ? 1 : 0); } finally { await app.close(); } }); it.each(["transaction", "hash"])("does not translate an apply %s failure into a file error", async (stage) => { const original = new Error("Corrupted zip: can't find end of central directory"); const logError = vi.fn(); const { app, employeeCreate, auditCreate } = await buildApp({ logError, departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); if (stage === "transaction") app.prisma.$transaction.mockRejectedValueOnce(original); else vi.spyOn(require("bcryptjs"), "hash").mockRejectedValueOnce(original); try { const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), content: `${header.trim()},title,roles\nS001,合成员工,synthetic@example.invalid,合成部门,合成岗位,employee\n` }) }); expect(response.statusCode).toBe(500); expect(app.prisma.$transaction).toHaveBeenCalledOnce(); expect(logError.mock.calls[0][0].err).toBe(original); expect(employeeCreate).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); it.each([["dry-run", 500, 200], ["dry-run", 501, 400], ["apply", 501, 400]])("preserves XLSX %s row limit at %s", async (operation, count, status) => { const content = await buildXlsxBuffer(Array.from({ length: count }, (_, index) => ({ employeeNo: `S${index}`, name: "合成员工", email: `synthetic${index}@example.invalid`, departmentName: "合成部门", }))); const logError = vi.fn(); const { app, ...spies } = await buildApp({ logError }); try { const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...buildMultipartRequest({ filename: "synthetic.xlsx", contentType: mime("xlsx"), content }) }); expect(response.statusCode).toBe(status); expect(logError).not.toHaveBeenCalled(); if (count === 501) { expect(response.json().error.details).toEqual({ file: "row_limit_exceeded", limit: 500, reasonCode: "user.import_row_limit_exceeded" }); expectNoBusinessAccess(app, spies); } else { expect(response.json().data.rows).toHaveLength(500); expect(spies.auditCreate).toHaveBeenCalledOnce(); expect(spies.employeeCreate).not.toHaveBeenCalled(); expect(app.prisma.$transaction).not.toHaveBeenCalled(); } } finally { await app.close(); } }); });