const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { encryptStringOrThrow } = require("../../../src/services/crypto"); const { PaymentOrderXlsError, buildCustomerSerial, generatePaymentOrderXls, } = require("../../../src/services/paymentOrderXls"); const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }; const encrypt = (value) => encryptStringOrThrow(config, value); function buildBatch(overrides = {}) { return { batch_no: "PB-20260802-002", payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成测试公司", bankName: "中国建设银行", accountNumberEncrypted: encrypt("6217000000000012345"), }, items: [ { id: "b", entity_code: "BX-20260802-0002", amount: "2.00", snapshot_json: { payee: { name: "乙", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } }, }, { id: "a", entity_code: "BX-20260802-0001", amount: "1.20", snapshot_json: { payee: { name: "甲", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000012345") } }, }, ], ...overrides, }; } describe("payment order BIFF8 generator", () => { it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => { const first = generatePaymentOrderXls(config, buildBatch()); const second = generatePaymentOrderXls(config, buildBatch()); expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1"); expect(first.rowCount).toBe(2); expect(first.serials).toEqual(second.serials); expect(first.totalAmount).toBe("3.20"); }); it("fails the whole export and reports every blocking payee issue", () => { const batch = buildBatch(); batch.items[1].snapshot_json.payee.bankInstitutionCode = "OTHER"; batch.items[0].snapshot_json.payee.bankAccountEncrypted = null; try { generatePaymentOrderXls(config, batch); throw new Error("expected payment order generation to fail"); } catch (error) { expect(error).toBeInstanceOf(PaymentOrderXlsError); expect(error.code).toBe("payee_bank_not_supported"); expect(error.details.blockers).toEqual(expect.arrayContaining([ { code: "payee_bank_not_supported", entityCode: "BX-20260802-0001" }, { code: "payee_account_unavailable", entityCode: "BX-20260802-0002" }, ])); } }); it("does not invent a bank display name when the frozen snapshot is incomplete", () => { const batch = buildBatch(); delete batch.items[0].snapshot_json.payee.bankName; expect(() => generatePaymentOrderXls(config, batch)).toThrow(expect.objectContaining({ code: "payee_bank_name_missing", details: { blockers: expect.arrayContaining([ { code: "payee_bank_name_missing", entityCode: "BX-20260802-0002" }, ]), }, })); }); it("validates customer serial length and character set without truncation", () => { expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-001"); expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError); expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError); }); it("fails closed when the fixed Python runtime is unavailable", () => { expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: "/definitely-missing/payment-order-python", })).toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" })); }); it("fails closed when the fixed Python dependencies are unavailable", () => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-dependency-test-")); try { const fakePython = path.join(temporaryRoot, "python"); fs.writeFileSync( fakePython, "#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n", { mode: 0o700 }, ); expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython })) .toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" })); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); it("fails closed when the controlled template hash drifts", () => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-template-test-")); try { const scriptDirectory = path.join(temporaryRoot, "src", "scripts"); const assetDirectory = path.join(temporaryRoot, "src", "assets", "payment-order"); fs.mkdirSync(scriptDirectory, { recursive: true }); fs.mkdirSync(assetDirectory, { recursive: true }); const scriptPath = path.resolve(__dirname, "../../../src/scripts/fill-payment-order-template.py"); const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls"); const copiedScriptPath = path.join(scriptDirectory, "fill-payment-order-template.py"); const copiedTemplatePath = path.join(assetDirectory, "ccb-payment-order-template.xls"); fs.copyFileSync(scriptPath, copiedScriptPath); const template = fs.readFileSync(templatePath); template[template.length - 1] ^= 1; fs.writeFileSync(copiedTemplatePath, template); expect(() => generatePaymentOrderXls(config, buildBatch(), { scriptPath: copiedScriptPath, })).toThrow(expect.objectContaining({ code: "payment_order_template_unavailable" })); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); it("does not copy child stderr or account payload into the exposed error", () => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-stderr-test-")); try { const fakePython = path.join(temporaryRoot, "python"); fs.writeFileSync(fakePython, "#!/bin/sh\ntee /dev/stderr >/dev/null\nexit 70\n", { mode: 0o700 }); let caught; try { generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(PaymentOrderXlsError); expect(caught.code).toBe("payment_order_generation_failed"); expect(JSON.stringify(caught)).not.toContain("6217000000000012345"); expect(JSON.stringify(caught)).not.toContain("6222000000000012345"); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); it.each([ ["timeout", "#!/bin/sh\nwhile :; do :; done\n", { timeout: 20 }], [ "oversized output", "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do printf X; i=$((i + 1)); done\n", { maxBuffer: 1024 }, ], ])("fails closed on child %s", (_label, script, options) => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-process-test-")); try { const fakePython = path.join(temporaryRoot, "python"); fs.writeFileSync(fakePython, script, { mode: 0o700 }); expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython, ...options })) .toThrow(expect.objectContaining({ code: "payment_order_generation_failed" })); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); });