import { getMetadataArgsStorage } from 'typeorm'; import { BaseEntity } from '../../../src/comm/entity/base'; import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead'; import { CrmLeadService } from '../../../src/modules/crm/service/lead'; import { OpenCrmLeadController } from '../../../src/modules/crm/controller/open/lead'; import { LeadEntity, LeadStatus } from '../../../src/comm/types/omni'; import { CoolCommException } from '@cool-midway/core'; import { AUDIT_ACTION_LEAD_BATCH_EXPORT, BaseAuditLogService, } from '../../../src/modules/base/service/audit'; describe('Task 12: 后端潜客线索模块 (modules/crm) 单元测试', () => { const metadata = getMetadataArgsStorage(); // ========================================== // 1. 实体与契约测试 (Lead Entity & BaseEntity) // ========================================== describe('1. 潜客线索实体定义与数据库映射规范 (CrmLeadEntity)', () => { it('实体表名必须映射为 crm_lead', () => { const table = metadata.tables.find(t => t.target === CrmLeadEntity); expect(table).toBeDefined(); expect(table?.name).toBe('crm_lead'); }); it('实体必须继承自 BaseEntity,具备底层租户隔离与审计能力', () => { expect(CrmLeadEntity.prototype instanceof BaseEntity).toBe(true); }); it('所有实体属性与数据库列必须遵循驼峰至下划线映射', () => { const columns = metadata.columns.filter(c => c.target === CrmLeadEntity); const expectedColumnMappings: Record = { cardId: 'card_id', visitorId: 'visitor_id', name: 'name', company: 'company', phone: 'phone', email: 'email', status: 'status', intentScore: 'intent_score', source: 'source', notes: 'notes', }; for (const [prop, dbCol] of Object.entries(expectedColumnMappings)) { const col = columns.find(c => c.propertyName === prop); expect(col).toBeDefined(); expect(col?.options.name || col?.propertyName).toBe(dbCol); } }); it('关键查询字段 cardId, visitorId, status 应声明索引', () => { const indexes = metadata.indices.filter(i => i.target === CrmLeadEntity); const indexProps = indexes.flatMap(i => Array.isArray(i.columns) ? i.columns : [] ); expect(indexProps).toContain('cardId'); expect(indexProps).toContain('visitorId'); expect(indexProps).toContain('status'); }); it('默认值应符合业务规范: status=\'new\', intentScore=0, source=\'link\'', () => { const columns = metadata.columns.filter(c => c.target === CrmLeadEntity); const statusCol = columns.find(c => c.propertyName === 'status'); expect(statusCol?.options.default).toBe('new'); const scoreCol = columns.find(c => c.propertyName === 'intentScore'); expect(scoreCol?.options.default).toBe(0); const sourceCol = columns.find(c => c.propertyName === 'source'); expect(sourceCol?.options.default).toBe('link'); }); it('CrmLeadEntity 实例应完全符合 LeadEntity 契约结构', () => { const lead = new CrmLeadEntity(); lead.id = 1; lead.tenantId = 10; lead.cardId = 101; lead.visitorId = 'vis_xyz_789'; lead.name = 'John Doe'; lead.company = 'Global Tech FZE'; lead.phone = '+971-50-1234567'; lead.email = 'john@globaltech.ae'; lead.status = 'new'; lead.intentScore = 85; lead.source = 'nfc'; lead.notes = '在迪拜 GITEX 展会展台触碰 NFC 卡留存'; const contract: LeadEntity = lead; expect(contract.name).toBe('John Doe'); expect(contract.status).toBe('new'); expect(contract.intentScore).toBe(85); expect(contract.source).toBe('nfc'); }); }); // ========================================== // 2. 意向度评分计算引擎测试 // ========================================== describe('2. 智能意向度评分计算引擎 (calculateIntentScore)', () => { let leadService: CrmLeadService; beforeEach(() => { leadService = new CrmLeadService(); }); it('零行为指标时应输出 0 分且判定等级为 low', () => { const result = leadService.calculateIntentScore(0, 0, 0); expect(result.score).toBe(0); expect(result.level).toBe('low'); }); it('达到满额指标(60秒停留、100% 滚动、5 个模块以上)应获得满分 100 且判定等级为 hot', () => { const result = leadService.calculateIntentScore(60000, 100, 5); expect(result.score).toBe(100); expect(result.level).toBe('hot'); }); it('超额指标(如 120 秒、超界滚动深度等)得分上限应严格限制在 100 分', () => { const result = leadService.calculateIntentScore(120000, 150, 10); expect(result.score).toBe(100); expect(result.level).toBe('hot'); }); it('中等行为指标应正确加权计算并在 40~74 区间内判定等级为 medium', () => { // 停留 30 秒 => 20分,滚动 50% => 15分,浏览 3 个块 => 18分,总计 53分 const result = leadService.calculateIntentScore(30000, 50, 3); expect(result.score).toBe(53); expect(result.level).toBe('medium'); }); it('负数或异常参数输入应有防御性兜底,最小分为 0', () => { const result = leadService.calculateIntentScore(-1000, -20, -5); expect(result.score).toBe(0); expect(result.level).toBe('low'); }); it('临界阈值判定: 75 分为 hot 分界线,40 分为 medium 分界线', () => { // 刚好 75 分 => hot // 停留 45 秒 (30分) + 滚动 70% (21分) + 浏览 4 块 (24分) = 75分 const hotResult = leadService.calculateIntentScore(45000, 70, 4); expect(hotResult.score).toBe(75); expect(hotResult.level).toBe('hot'); // 停留 39 分 => low // 停留 30 秒 (20分) + 滚动 30% (9分) + 浏览 1 块 (6分) = 35分 const lowResult = leadService.calculateIntentScore(30000, 30, 1); expect(lowResult.score).toBe(35); expect(lowResult.level).toBe('low'); }); }); // ========================================== // 3. 线索留资沉淀与流转测试 (submitLead & updateStatus) // ========================================== describe('3. 线索留资沉淀与状态流转业务逻辑 (submitLead & updateStatus)', () => { let leadService: CrmLeadService; let savedLeads: CrmLeadEntity[] = []; let idCounter = 1; beforeEach(() => { savedLeads = []; idCounter = 1; const mockRepo: any = { save: jest.fn().mockImplementation(async (item: any) => { if (!item.id) { item.id = idCounter++; savedLeads.push(item); } else { const idx = savedLeads.findIndex(l => l.id === item.id); if (idx >= 0) { savedLeads[idx] = { ...savedLeads[idx], ...item }; } else { savedLeads.push(item); } } return { ...item }; }), findOne: jest.fn().mockImplementation(async (options: any) => { const where = options?.where; if (!where) return null; return savedLeads.find(l => l.id === Number(where.id)) || null; }), }; leadService = new CrmLeadService(); leadService.crmLeadEntity = mockRepo; }); describe('3.1 submitLead 留资校验与沉淀', () => { it('缺少 cardId 时应拒绝并抛出 400 异常', async () => { await expect( leadService.submitLead({ name: 'Alex', phone: '13800138000' } as any) ).rejects.toThrow(CoolCommException); }); it('缺少姓名 name 时应拒绝并抛出 400 异常', async () => { await expect( leadService.submitLead({ cardId: 101, phone: '13800138000' } as any) ).rejects.toThrow(CoolCommException); }); it('phone 与 email 均为空时应拒绝并抛出 400 异常', async () => { await expect( leadService.submitLead({ cardId: 101, name: 'Alex' } as any) ).rejects.toThrow(/联系方式/); }); it('提供有效 phone 时留资应成功,默认来源为 link,状态为 new', async () => { const lead = await leadService.submitLead({ cardId: 101, name: 'Sarah Connor', phone: '+1-555-0199', company: 'Cyberdyne Systems', }); expect(lead.id).toBeDefined(); expect(lead.name).toBe('Sarah Connor'); expect(lead.phone).toBe('+1-555-0199'); expect(lead.status).toBe('new'); expect(lead.source).toBe('link'); }); it('提供有效 email 且携带行为数据时,应自动计算意向度评分', async () => { const lead = await leadService.submitLead({ cardId: 101, name: 'David Miller', email: 'david@miller.org', stayDurationMs: 60000, scrollDepth: 100, blockCount: 5, }); expect(lead.intentScore).toBe(100); // 满分高意向自动将初始状态升级为 'hot' expect(lead.status).toBe('hot'); }); it('外部显式指定 status 或 intentScore 时以传入数据为准', async () => { const lead = await leadService.submitLead({ cardId: 101, name: 'Emma Watson', email: 'emma@watson.com', status: 'contacted', intentScore: 60, source: 'nfc', }); expect(lead.status).toBe('contacted'); expect(lead.intentScore).toBe(60); expect(lead.source).toBe('nfc'); }); }); describe('3.2 updateStatus 线索状态流转', () => { it('更新不存在的线索 ID 时应抛出 404 异常', async () => { await expect(leadService.updateStatus(9999, 'contacted')).rejects.toThrow( CoolCommException ); }); it('更新为非法状态值时应抛出 400 参数异常', async () => { const created = await leadService.submitLead({ cardId: 101, name: 'Test Lead', phone: '13800000000', }); await expect( leadService.updateStatus(created.id, 'invalid_status' as any) ).rejects.toThrow(/非法状态/); }); it('正常流转状态并成功更新保存 (new -> contacted -> won)', async () => { const created = await leadService.submitLead({ cardId: 101, name: 'Enterprise Buyer', email: 'buyer@corp.com', }); expect(created.status).toBe('new'); const contacted = await leadService.updateStatus(created.id, 'contacted'); expect(contacted.status).toBe('contacted'); const won = await leadService.updateStatus(created.id, 'won'); expect(won.status).toBe('won'); }); }); }); // ========================================== // 4. 控制器接口测试 (OpenCrmLeadController) // ========================================== describe('4. 公开免登录留资互换接口 (OpenCrmLeadController)', () => { let controller: OpenCrmLeadController; let leadService: CrmLeadService; beforeEach(() => { leadService = new CrmLeadService(); leadService.crmLeadEntity = { save: jest.fn().mockImplementation(async item => ({ id: 88, ...item, })), findOne: jest.fn(), } as any; controller = new OpenCrmLeadController(); controller.crmLeadService = leadService; }); it('POST /open/crm/lead/exchange 正常留资返回成功响应 (code: 1000)', async () => { const response = await controller.exchange({ cardId: 101, name: 'Michael Scott', phone: '+1-202-555-0143', company: 'Dunder Mifflin', source: 'qr', }); expect(response.code).toBe(1000); expect(response.message).toBe('success'); expect((response as any).data.id).toBe(88); expect((response as any).data.name).toBe('Michael Scott'); expect((response as any).data.source).toBe('qr'); }); it('缺少必要留资参数时应返回失败响应 (非 1000)', async () => { const response = await controller.exchange({ cardId: 101, // 缺少 name 和 联系方式 } as any); expect(response.code).not.toBe(1000); }); }); // ========================================== // 5. 线索批量导出审计 (batchExport) // ========================================== describe('5. 线索批量导出成功后写入审计 (batchExport)', () => { let leadService: CrmLeadService; let savedLeads: CrmLeadEntity[] = []; let record: jest.Mock; beforeEach(() => { savedLeads = [ Object.assign(new CrmLeadEntity(), { id: 1, name: 'Alice', phone: '+971-50-1111111', email: 'a@ex.com', status: 'new', source: 'nfc', }), Object.assign(new CrmLeadEntity(), { id: 2, name: 'Bob', phone: '+971-50-2222222', email: 'b@ex.com', status: 'hot', source: 'qr', }), ]; record = jest.fn(async dto => dto); const auditService = new BaseAuditLogService(); auditService.record = record as unknown as BaseAuditLogService['record']; leadService = new CrmLeadService(); leadService.baseAuditLogService = auditService; leadService.crmLeadEntity = { find: jest.fn().mockImplementation(async () => [...savedLeads]), save: jest.fn(), findOne: jest.fn(), } as any; leadService.baseCtx = { admin: { userId: 7, username: 'ExportAdmin' }, } as unknown as CrmLeadService['baseCtx']; }); it('导出成功应记录 lead_batch_export 审计且正文仅含条数不含手机号', async () => { const result = await leadService.batchExport({}); expect(result.exportCount).toBe(2); expect(result.list).toHaveLength(2); expect(record).toHaveBeenCalledTimes(1); expect(record).toHaveBeenCalledWith( expect.objectContaining({ module: 'crm', action: AUDIT_ACTION_LEAD_BATCH_EXPORT, operatorId: 7, operatorName: 'ExportAdmin', status: 1, }) ); const detailRaw = String(record.mock.calls[0][0].detail); const detail = JSON.parse(detailRaw); expect(detail.exportCount).toBe(2); expect(detailRaw).not.toContain('+971-50-1111111'); expect(detailRaw).not.toContain('+971-50-2222222'); expect(detail).not.toHaveProperty('phone'); expect(detail).not.toHaveProperty('phones'); }); it('导出查询失败时不得记成功审计', async () => { (leadService.crmLeadEntity.find as jest.Mock).mockRejectedValue( new Error('db down') ); await expect(leadService.batchExport({})).rejects.toThrow(/db down/); expect(record).not.toHaveBeenCalled(); }); }); });