const fs = require("node:fs"); const ExcelJS = require("exceljs"); const { DirectoryExportError, buildSafeReport, parseArgs, parseDirectoryExport, readStdinBuffer, } = require("../../../src/scripts/import-wecom-directory-export"); async function buildDirectoryWorkbook(rows) { const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet("通讯录"); worksheet.addRow(["填写须知:本行为企微导出说明"]); worksheet.addRow([]); worksheet.addRow([ "姓名", "账号", "别名", "职务", "部门", "性别", "手机", "座机", "邮箱", "地址", "企业邮箱", "视频号", "激活状态", "禁用状态", "微信插件", ]); for (const row of rows) { worksheet.addRow([ row.name, row.account, "", row.title ?? "员工", row.department ?? "总部", "", row.phone ?? "", "", row.email ?? "", "", row.businessEmail, "", "已激活", "", "未关注", ]); } return Buffer.from(await workbook.xlsx.writeBuffer()); } describe("WeCom directory export importer", () => { it("reads stdin through the fd-compatible synchronous API", () => { const expected = Buffer.from("synthetic-xlsx"); const readFileSync = vi.spyOn(fs, "readFileSync").mockReturnValue(expected); try { expect(readStdinBuffer()).toBe(expected); expect(readFileSync).toHaveBeenCalledWith(0); } finally { readFileSync.mockRestore(); } }); it("requires stdin, the apply environment gate, and an actor for apply", () => { const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY; delete process.env.WECOM_DIRECTORY_EXPORT_APPLY; try { expect(() => parseArgs(["--input=directory.xlsx"])).toThrowError( expect.objectContaining({ code: "stdin_required" }), ); expect(() => parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toThrowError( expect.objectContaining({ code: "apply_confirmation_required" }), ); process.env.WECOM_DIRECTORY_EXPORT_APPLY = "1"; expect(() => parseArgs(["--apply"])).toThrowError( expect.objectContaining({ code: "actor_required" }), ); expect(parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toEqual( expect.objectContaining({ input: "-", mode: "apply", actorEmployeeNo: "HYD000001" }), ); } finally { if (previous == null) delete process.env.WECOM_DIRECTORY_EXPORT_APPLY; else process.env.WECOM_DIRECTORY_EXPORT_APPLY = previous; } }); it("finds the official Chinese header after instruction rows and accepts one missing phone", async () => { const rows = Array.from({ length: 6 }, (_, index) => ({ name: `合成员${index + 1}`, account: `member-${index + 1}`, phone: index === 5 ? "" : `1990000000${index + 1}`, businessEmail: `member-${index + 1}@example.test`, })); const parsed = await parseDirectoryExport(await buildDirectoryWorkbook(rows)); expect(parsed.sourceSummary).toEqual({ memberCount: 6, activeCount: 6, phonePresentCount: 5, phoneMissingCount: 1, emailPresentCount: 6, incompleteCount: 0, }); expect(parsed.profiles[5].phone).toBeNull(); expect(parsed.profiles[5].missingRequiredFields).toEqual([]); const report = buildSafeReport( { mode: "dry_run" }, parsed, { summary: { created: 5, updated: 1, planned: 6 } }, ); const serialized = JSON.stringify(report); expect(report.status).toBe("PASS_DIRECTORY_EXPORT_DRY_RUN"); for (const row of rows) { expect(serialized).not.toContain(row.name); expect(serialized).not.toContain(row.account); expect(serialized).not.toContain(row.businessEmail); if (row.phone) expect(serialized).not.toContain(row.phone); } }); it.each([ ["account", { account: "member-1", phone: "19900000002", businessEmail: "member-2@example.test" }], ["phone", { account: "member-2", phone: "19900000001", businessEmail: "member-2@example.test" }], ["email", { account: "member-2", phone: "19900000002", businessEmail: "member-1@example.test" }], ])("rejects duplicate %s values without returning the duplicated value", async (_field, duplicateRow) => { const buffer = await buildDirectoryWorkbook([ { name: "合成员一", account: "member-1", phone: "19900000001", businessEmail: "member-1@example.test", }, { name: "合成员二", ...duplicateRow }, ]); let caught; try { await parseDirectoryExport(buffer); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(DirectoryExportError); expect(caught.code).toBe("duplicate_values"); const serialized = JSON.stringify(caught); expect(serialized).not.toContain("member-1@example.test"); expect(serialized).not.toContain("19900000001"); }); it("requires a valid enterprise email even when a personal email is present", async () => { const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{ name: "合成员一", account: "member-1", phone: "19900000001", email: "personal@example.test", businessEmail: "", }])); expect(parsed.sourceSummary.incompleteCount).toBe(1); expect(parsed.profiles[0].missingRequiredFields).toContain("email"); }); it("blocks a supplied invalid phone instead of treating it as an absent phone", async () => { const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{ name: "合成员一", account: "member-1", phone: "not-a-phone", businessEmail: "member-1@example.test", }])); expect(parsed.sourceSummary.phoneMissingCount).toBe(1); expect(parsed.sourceSummary.incompleteCount).toBe(1); expect(parsed.profiles[0].missingRequiredFields).toContain("phone_invalid"); }); it("uses a distinct success status for an apply report", async () => { const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{ name: "合成员一", account: "member-1", phone: "", businessEmail: "member-1@example.test", }])); const report = buildSafeReport( { mode: "apply" }, parsed, { summary: { created: 1, success: 1 } }, ); expect(report.status).toBe("PASS_DIRECTORY_EXPORT_APPLY"); }); });