import * as crypto from 'crypto'; import { CoolCommException } from '@cool-midway/core'; import { NfcBatchExportService, BATCH_EXPORT_MANIFEST_FILE, BATCH_EXPORT_QR_DIR, generateSecurityCode, validateBatchNo, } from '../../../src/modules/nfc/service/batch-export'; import { AdminNfcDeviceController } from '../../../src/modules/nfc/controller/admin/device'; const AdmZip = require('adm-zip'); jest.setTimeout(20000); describe('Point 16: 展会批量制卡二维码与离线数据包 Zip 导出', () => { let service: NfcBatchExportService; beforeEach(() => { service = new NfcBatchExportService(); }); describe('1. 参数校验与空数据保护', () => { it('当批次号为空或纯空格时,应抛出 CoolCommException 提示批次号不能为空', async () => { await expect(service.generateBatchPack('', [{ uid: '04:11:22:33', slug: 'test', cardTitle: '测试名片' }])) .rejects.toThrow(CoolCommException); await expect(service.generateBatchPack(' ', [{ uid: '04:11:22:33', slug: 'test', cardTitle: '测试名片' }])) .rejects.toThrow(CoolCommException); }); it('当设备列表为空数组时,应抛出 CoolCommException 提示设备列表不能为空', async () => { await expect(service.generateBatchPack('BATCH-2026-01', [])) .rejects.toThrow(CoolCommException); }); it('当设备列表为 null 或 undefined 时,应抛出保护异常', async () => { await expect(service.generateBatchPack('BATCH-2026-01', null as unknown as [])) .rejects.toThrow(CoolCommException); await expect(service.generateBatchPack('BATCH-2026-01', undefined as unknown as [])) .rejects.toThrow(CoolCommException); }); }); describe('2. 批次号命名规范校验 (validateBatchNo)', () => { it('合法批次号应正常通过校验并返回规范化字符串', () => { expect(validateBatchNo('BATCH-2026-01')).toBe('BATCH-2026-01'); expect(validateBatchNo('expo_shanghai_02')).toBe('expo_shanghai_02'); expect(validateBatchNo('EXPO2026')).toBe('EXPO2026'); }); it('包含非法字符、空格或路径遍历字符时,应抛出 CoolCommException', () => { expect(() => validateBatchNo('../etc/passwd')).toThrow(CoolCommException); expect(() => validateBatchNo('BATCH 2026')).toThrow(CoolCommException); expect(() => validateBatchNo('BATCH/01')).toThrow(CoolCommException); expect(() => validateBatchNo('BATCH:01')).toThrow(CoolCommException); expect(() => validateBatchNo('BATCH*01?')).toThrow(CoolCommException); }); }); describe('3. 出厂防伪码规范 (generateSecurityCode)', () => { it('防伪码应为确定性、不可预测且长度规范的16位大写十六进制字符串', () => { const code1 = generateSecurityCode('BATCH-2026-01', '04:7A:B2:8F:1C:6D:80'); const code2 = generateSecurityCode('BATCH-2026-01', '04:7A:B2:8F:1C:6D:80'); const code3 = generateSecurityCode('BATCH-2026-01', '04:8B:C3:9A:2D:7E:91'); expect(code1).toBe(code2); expect(code1).not.toBe(code3); expect(code1).toMatch(/^[A-F0-9]{16}$/); }); }); describe('4. ZIP 压缩包完整性验证与文件流解析', () => { const batchNo = 'EXPO-2026-GLOBAL'; const mockDevices = [ { uid: '04:7A:B2:8F:1C:6D:80', slug: 'alex-ceo', cardTitle: 'CEO Alex Zheng', }, { uid: '04:8B:C3:9A:2D:7E:91', slug: 'https://card.omnicard.ai/sarah-cto', cardTitle: 'CTO Sarah Lin', }, ]; it('应成功生成包含高清二维码与清单 CSV 的合法 ZIP Buffer', async () => { const result = await service.generateBatchPack(batchNo, mockDevices); expect(result.totalCount).toBe(2); expect(Buffer.isBuffer(result.zipBuffer)).toBe(true); expect(result.zipBuffer.length).toBeGreaterThan(0); // 解压验证 const zip = new AdmZip(result.zipBuffer); const entries = zip.getEntries(); expect(entries.length).toBeGreaterThanOrEqual(3); // 至少 1 个 CSV 清单 + 2 个二维码图片 // 4.1 校验 CSV 清单 const csvEntry = entries.find( (e: { entryName: string }) => e.entryName === BATCH_EXPORT_MANIFEST_FILE || e.entryName === `${batchNo}.csv` || e.entryName.endsWith('.csv') ); expect(csvEntry).toBeDefined(); const csvContent = zip.readAsText(csvEntry); expect(csvContent).toContain('UID'); expect(csvContent).toContain('短链'); expect(csvContent).toContain('微官网标题'); expect(csvContent).toContain('出厂防伪码'); // 校验每一行数据 for (const dev of mockDevices) { expect(csvContent).toContain(dev.uid); expect(csvContent).toContain(dev.cardTitle); // 校验短链是否存在于 CSV 中 if (dev.slug.startsWith('http')) { expect(csvContent).toContain(dev.slug); } else { expect(csvContent).toContain(dev.slug); } } // 4.2 校验高清二维码图片 for (const dev of mockDevices) { const safeUid = dev.uid.replace(/[:\/\\*?"<>|]/g, '_'); const qrEntry = entries.find( (e: { entryName: string; name: string }) => e.name === `${safeUid}.png` || e.entryName === `${BATCH_EXPORT_QR_DIR}/${safeUid}.png` ); expect(qrEntry).toBeDefined(); const qrBuffer: Buffer = zip.readFile(qrEntry); expect(Buffer.isBuffer(qrBuffer)).toBe(true); expect(qrBuffer.length).toBeGreaterThan(500); // 验证 PNG 签名魔法数字 (89 50 4E 47 0D 0A 1A 0A) const isPng = qrBuffer[0] === 0x89 && qrBuffer[1] === 0x50 && qrBuffer[2] === 0x4e && qrBuffer[3] === 0x47 && qrBuffer[4] === 0x0d && qrBuffer[5] === 0x0a && qrBuffer[6] === 0x1a && qrBuffer[7] === 0x0a; expect(isPng).toBe(true); } }); }); describe('5. 控制器 /admin/nfc/device/batch-export-zip 接口扩展', () => { it('AdminNfcDeviceController 必须实现 batchExportZip 方法并支持流式导出', async () => { const controller = new AdminNfcDeviceController(); expect(typeof (controller as any).batchExportZip).toBe('function'); // 模拟 Midway 上下文 const mockHeaders: Record = {}; let responseBody: unknown = null; const mockCtx = { set(key: string, value: string) { mockHeaders[key.toLowerCase()] = value; }, set body(val: unknown) { responseBody = val; }, get body() { return responseBody; }, }; (controller as any).ctx = mockCtx; (controller as any).nfcBatchExportService = service; await (controller as any).batchExportZip('BATCH-DEMO-01', [ { uid: '04:AA:BB:CC:DD:EE:FF', slug: 'demo-card', cardTitle: '演示名片', }, ]); expect(mockHeaders['content-type']).toBe('application/zip'); expect(mockHeaders['content-disposition']).toContain('attachment'); expect(mockHeaders['content-disposition']).toContain('BATCH-DEMO-01.zip'); expect(Buffer.isBuffer(responseBody)).toBe(true); }); }); });