文件
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

762 行
33 KiB
JavaScript

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,[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();
});
});
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,合成员工,[email protected],合成部门\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", `<worksheet><${marker}></wrong></worksheet>`],
["entity", `<worksheet><sheetData>&${marker};</sheetData></worksheet>`],
["attribute", `<worksheet ${marker}=unquoted></worksheet>`],
]) {
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第二行",[email protected],合成部门\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,合成员工,[email protected],合成部门\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,合成员工,[email protected],合成部门,合成岗位,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(); }
});
});