import { getMetadataArgsStorage } from 'typeorm'; import { BaseEntity } from '../../../src/comm/entity/base'; import { CrmTrackEntity } from '../../../src/modules/crm/entity/track'; import { CrmRadarService } from '../../../src/modules/crm/service/radar'; import { OpenCrmRadarController } from '../../../src/modules/crm/controller/open/radar'; import { RadarTrack, TouchType } from '../../../src/comm/types/omni'; import { CoolCommException } from '@cool-midway/core'; describe('Task 11: 后端访客雷达模块 (modules/crm) 单元测试', () => { const metadata = getMetadataArgsStorage(); // ========================================== // 1. 实体与契约测试 (CrmTrackEntity & BaseEntity) // ========================================== describe('1. 访客足迹实体定义与数据库映射规范 (CrmTrackEntity)', () => { it('实体表名必须映射为 crm_track', () => { const table = metadata.tables.find(t => t.target === CrmTrackEntity); expect(table).toBeDefined(); expect(table?.name).toBe('crm_track'); }); it('实体必须继承自 BaseEntity,具备底层租户隔离与审计时间戳能力', () => { expect(CrmTrackEntity.prototype instanceof BaseEntity).toBe(true); }); it('所有实体属性与数据库列必须遵循驼峰至下划线映射规范', () => { const columns = metadata.columns.filter(c => c.target === CrmTrackEntity); const expectedColumnMappings: Record = { cardId: 'card_id', visitorId: 'visitor_id', ip: 'ip', country: 'country', city: 'city', touchType: 'touch_type', stayDurationMs: 'stay_duration_ms', scrollDepth: 'scroll_depth', viewedBlocks: 'viewed_blocks', }; 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, country, touchType 应声明索引', () => { const indexes = metadata.indices.filter(i => i.target === CrmTrackEntity); const indexProps = indexes.flatMap(i => Array.isArray(i.columns) ? i.columns : [] ); expect(indexProps).toContain('cardId'); expect(indexProps).toContain('visitorId'); expect(indexProps).toContain('country'); expect(indexProps).toContain('touchType'); }); it('默认值应符合业务规范: touchType=\'link\', stayDurationMs=0, scrollDepth=0', () => { const columns = metadata.columns.filter(c => c.target === CrmTrackEntity); const touchTypeCol = columns.find(c => c.propertyName === 'touchType'); expect(touchTypeCol?.options.default).toBe('link'); const stayCol = columns.find(c => c.propertyName === 'stayDurationMs'); expect(stayCol?.options.default).toBe(0); const scrollCol = columns.find(c => c.propertyName === 'scrollDepth'); expect(scrollCol?.options.default).toBe(0); }); it('CrmTrackEntity 实例应完全符合 RadarTrack 契约结构 (包含 createdAt 属性)', () => { const track = new CrmTrackEntity(); track.id = 1; track.tenantId = 10; track.cardId = 101; track.visitorId = 'vis_abc_123'; track.ip = '8.8.8.8'; track.country = 'US'; track.city = 'Mountain View'; track.touchType = 'nfc'; track.stayDurationMs = 15000; track.scrollDepth = 80; track.viewedBlocks = ['hero', 'case_1', 'form']; track.createTime = new Date('2026-09-20T12:00:00Z'); const contract: RadarTrack = track; expect(contract.id).toBe(1); expect(contract.cardId).toBe(101); expect(contract.visitorId).toBe('vis_abc_123'); expect(contract.touchType).toBe('nfc'); expect(contract.viewedBlocks).toEqual(['hero', 'case_1', 'form']); expect(contract.createdAt).toBeDefined(); }); }); // ========================================== // 2. IP 解析与地理位置推断 (parseIpLocation) // ========================================== describe('2. IP 地理位置轻量解析机制 (parseIpLocation)', () => { let radarService: CrmRadarService; beforeEach(() => { radarService = new CrmRadarService(); }); it('本地回环 IP (127.0.0.1 / ::1 / localhost) 应解析为 Local', () => { expect(radarService.parseIpLocation('127.0.0.1')).toEqual({ country: 'Local', city: 'Local', }); expect(radarService.parseIpLocation('::1')).toEqual({ country: 'Local', city: 'Local', }); expect(radarService.parseIpLocation('localhost')).toEqual({ country: 'Local', city: 'Local', }); }); it('局域网私有 IP (192.168.x / 10.x / 172.16.x) 应解析为 Local', () => { expect(radarService.parseIpLocation('192.168.1.100')).toEqual({ country: 'Local', city: 'Local', }); expect(radarService.parseIpLocation('10.0.8.1')).toEqual({ country: 'Local', city: 'Local', }); }); it('请求头携带 CDN 地理位置时 (cf-ipcountry / x-country) 应优先提取', () => { const headers = { 'cf-ipcountry': 'AE', 'cf-ipcity': 'Dubai' }; const loc = radarService.parseIpLocation('185.220.101.5', headers); expect(loc.country).toBe('AE'); expect(loc.city).toBe('Dubai'); }); it('未知公网 IP 且无地理请求头时应安全兜底为 Unknown', () => { const loc = radarService.parseIpLocation('203.0.113.195'); expect(loc.country).toBe('Unknown'); expect(loc.city).toBe('Unknown'); }); }); // ========================================== // 3. 访客轨迹埋点记录测试 (recordTrack) // ========================================== describe('3. 访客轨迹埋点记录服务 (recordTrack)', () => { let radarService: CrmRadarService; let savedTracks: CrmTrackEntity[] = []; let idCounter = 1; beforeEach(() => { savedTracks = []; idCounter = 1; const mockRepo: any = { save: jest.fn().mockImplementation(async (item: any) => { if (!item.id) { item.id = idCounter++; item.createTime = new Date(); savedTracks.push(item); } else { const idx = savedTracks.findIndex(t => t.id === item.id); if (idx >= 0) { savedTracks[idx] = { ...savedTracks[idx], ...item }; } else { savedTracks.push(item); } } return { ...item }; }), find: jest.fn().mockImplementation(async (options: any) => { let list = [...savedTracks]; if (options?.where?.cardId) { list = list.filter(t => t.cardId === Number(options.where.cardId)); } if (options?.order?.id === 'DESC' || options?.order?.createTime === 'DESC') { list.sort((a, b) => Number(b.id) - Number(a.id)); } if (options?.take) { list = list.slice(0, options.take); } return list; }), }; radarService = new CrmRadarService(); radarService.crmTrackEntity = mockRepo; }); it('缺少 cardId 时应抛出 400 异常', async () => { await expect( radarService.recordTrack({ touchType: 'link' } as any) ).rejects.toThrow(CoolCommException); }); it('正常埋点记录应成功保存并补全默认值与指纹', async () => { const result = await radarService.recordTrack({ cardId: 101, touchType: 'qr', stayDurationMs: 45000, scrollDepth: 75, viewedBlocks: ['hero', 'cases', 'social'], ip: '127.0.0.1', }); expect(result.id).toBeDefined(); expect(result.cardId).toBe(101); expect(result.touchType).toBe('qr'); expect(result.stayDurationMs).toBe(45000); expect(result.scrollDepth).toBe(75); expect(result.viewedBlocks).toEqual(['hero', 'cases', 'social']); expect(result.country).toBe('Local'); expect(result.city).toBe('Local'); expect(result.visitorId).toBeTruthy(); }); it('未指定 touchType 或非法触达渠道应兜底为 link', async () => { const track1 = await radarService.recordTrack({ cardId: 101, touchType: 'invalid_touch' as any, }); expect(track1.touchType).toBe('link'); const track2 = await radarService.recordTrack({ cardId: 101, }); expect(track2.touchType).toBe('link'); }); it('数值边界纠偏: stayDurationMs 负数纠偏为 0, scrollDepth 限制在 0~100', async () => { const result = await radarService.recordTrack({ cardId: 101, stayDurationMs: -500, scrollDepth: 150, }); expect(result.stayDurationMs).toBe(0); expect(result.scrollDepth).toBe(100); }); it('viewedBlocks 支持 JSON 字符串自动解析', async () => { const result = await radarService.recordTrack({ cardId: 101, viewedBlocks: JSON.stringify(['hero', 'contact_btn']) as any, }); expect(result.viewedBlocks).toEqual(['hero', 'contact_btn']); }); it('外部显式传入地理位置时应优先尊重', async () => { const result = await radarService.recordTrack({ cardId: 101, country: 'Singapore', city: 'Singapore', ip: '10.0.0.1', }); expect(result.country).toBe('Singapore'); expect(result.city).toBe('Singapore'); }); it('高停留足迹会走雷达 Push,普通足迹不推', async () => { const published: any[] = []; radarService.crmRadarPushService = { publish: (track: any) => { published.push(track); return track; }, } as any; await radarService.recordTrack({ cardId: 101, stayDurationMs: 5000, viewedBlocks: ['hero'], }); await radarService.recordTrack({ cardId: 101, stayDurationMs: 180000, viewedBlocks: ['hero'], }); expect(published).toHaveLength(1); expect(published[0].stayDurationMs).toBe(180000); }); }); describe('P2 高意向雷达收件箱', () => { it('hotInbox 只统计当前用户名片的高意向足迹', async () => { const radarService = new CrmRadarService(); radarService.cardInfoEntity = { find: jest.fn(async () => [{ id: 101, userId: 11 }]), } as any; radarService.crmTrackEntity = { find: jest.fn(async () => [ { id: 1, cardId: 101, stayDurationMs: 180000, viewedBlocks: [] }, { id: 2, cardId: 101, stayDurationMs: 1000, viewedBlocks: ['hero'] }, { id: 3, cardId: 202, stayDurationMs: 200000, viewedBlocks: [] }, ]), } as any; const inbox = await radarService.hotInbox(11); expect(inbox.unread).toBe(1); expect(inbox.list[0].cardId).toBe(101); }); }); // ========================================== // R25. 语言占比 / 设备占比诚实聚合 // ========================================== describe('R25. 语言占比与设备占比真实计数', () => { let radarService: CrmRadarService; let trackList: any[]; beforeEach(() => { trackList = []; radarService = new CrmRadarService(); radarService.crmTrackEntity = { find: jest.fn(async (options?: any) => { let list = [...trackList]; if (options?.where?.cardId !== undefined) { list = list.filter((t) => Number(t.cardId) === Number(options.where.cardId)); } return list; }), } as any; }); it('无数据时 getLanguageStats / getDeviceStats 返回 [],analytics 同步为空', async () => { const analytics = await radarService.getAnalytics(); expect(await radarService.getLanguageStats()).toEqual([]); expect(await radarService.getDeviceStats()).toEqual([]); expect(analytics.languageStats).toEqual([]); expect(analytics.deviceStats).toEqual([]); }); it('设备占比来自 touchType 计数,空足迹不返回固定 0% 渠道行', async () => { expect(await radarService.getDeviceStats()).toEqual([]); trackList = [ { id: 1, touchType: 'qr' }, { id: 2, touchType: 'qr' }, { id: 3, touchType: 'link' }, ]; const devices = await radarService.getDeviceStats(); expect(devices.find((d) => d.type === 'qr')?.percentage).toBeCloseTo(66.7, 0); expect(devices.find((d) => d.type === 'link')?.percentage).toBeCloseTo(33.3, 0); expect(devices.find((d) => d.type === 'nfc')).toBeUndefined(); }); }); // ========================================== // 4. 最近足迹流水查询测试 (getRecentTracks) // ========================================== describe('4. 最近足迹流水查询 (getRecentTracks)', () => { let radarService: CrmRadarService; let savedTracks: CrmTrackEntity[] = []; beforeEach(async () => { savedTracks = []; const mockRepo: any = { save: jest.fn().mockImplementation(async (item: any) => { savedTracks.push(item); return item; }), find: jest.fn().mockImplementation(async (options: any) => { let list = [...savedTracks]; if (options?.where?.cardId) { list = list.filter(t => t.cardId === Number(options.where.cardId)); } if (options?.order?.id === 'DESC' || options?.order?.createTime === 'DESC') { list.sort((a, b) => Number(b.id) - Number(a.id)); } if (options?.take) { list = list.slice(0, options.take); } return list; }), }; radarService = new CrmRadarService(); radarService.crmTrackEntity = mockRepo; // 预先存入 5 条足迹 for (let i = 1; i <= 5; i++) { const t = new CrmTrackEntity(); t.id = i; t.cardId = 101; t.visitorId = `vis_${i}`; t.touchType = 'nfc'; t.createTime = new Date(`2026-09-20T10:0${i}:00Z`); savedTracks.push(t); } }); it('缺少 cardId 时应抛出 400 异常', async () => { await expect(radarService.getRecentTracks(null as any)).rejects.toThrow( CoolCommException ); }); it('应按倒序返回指定名片的最近足迹列表', async () => { const list = await radarService.getRecentTracks(101, 3); expect(list.length).toBe(3); expect(list[0].id).toBe(5); expect(list[1].id).toBe(4); expect(list[2].id).toBe(3); }); it('limit 超限或非正整数时应自动修正约束在 1~100 区间', async () => { const list1 = await radarService.getRecentTracks(101, 0); expect(list1.length).toBe(1); const list2 = await radarService.getRecentTracks(101, 500); expect(list2.length).toBe(5); }); }); // ========================================== // 5. 公开免登录埋点控制器测试 (OpenCrmRadarController) // ========================================== describe('5. 公开免登录埋点控制器 (OpenCrmRadarController)', () => { let controller: OpenCrmRadarController; let mockRadarService: any; beforeEach(() => { mockRadarService = { recordTrack: jest.fn().mockResolvedValue({ id: 999, cardId: 101, visitorId: 'vis_test', country: 'Local', city: 'Local', }), getRecentTracks: jest.fn().mockResolvedValue([ { id: 999, cardId: 101 }, ]), }; controller = new OpenCrmRadarController(); controller.crmRadarService = mockRadarService; (controller as any).ctx = { ip: '127.0.0.1', headers: {}, }; (controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data }); (controller as any).fail = (msg: string, code = 1001) => ({ code, message: msg }); }); it('POST /open/crm/radar/track 正常埋点返回成功响应 (code: 1000)', async () => { const res = await controller.track({ cardId: 101, touchType: 'nfc', }); expect(res.code).toBe(1000); expect((res as any).data.id).toBe(999); expect(mockRadarService.recordTrack).toHaveBeenCalled(); }); it('埋点服务抛出异常时应捕获并返回 fail 响应', async () => { mockRadarService.recordTrack.mockRejectedValueOnce( new CoolCommException('缺少名片标识 cardId', 400) ); const res = await controller.track({} as any); expect(res.code).toBe(400); expect(res.message).toContain('缺少名片标识 cardId'); }); it('GET /open/crm/radar/recent 正常查询最近足迹', async () => { const res = await controller.recent(101, 10); expect(res.code).toBe(1000); expect((res as any).data.length).toBe(1); expect(mockRadarService.getRecentTracks).toHaveBeenCalledWith(101, 10); }); }); }); describe('雷达监控行补全', () => { it('用线索姓名、内部用户头像和模块标题补全足迹,对不上则留空', () => { const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar'); const now = Date.parse('2026-09-23T10:00:00.000Z'); const rows = decorateRadarTracks( [ { id: 8, cardId: 12, visitorId: 'vis_alex', country: 'CN', city: 'Shanghai', ip: '8.8.8.8', touchType: 'link', stayDurationMs: 12000, scrollDepth: 40, viewedBlocks: ['2', 'hero'], createTime: new Date(now - 30 * 1000).toISOString(), }, { id: 9, cardId: 12, visitorId: 'vis_anon', country: 'Local', city: 'Local', touchType: 'qr', stayDurationMs: 0, scrollDepth: 0, viewedBlocks: [], createTime: new Date(now - 60 * 60 * 1000).toISOString(), }, ], [ { id: 3, visitorId: 'vis_alex', name: 'Alex', company: 'Nexus', phone: '13800000000', }, ], [ { phone: '13800000000', avatarUrl: 'https://cdn.example.com/alex.png', nickName: 'Alex Nick', }, ], [ { id: 2, cardId: 12, type: 'case', title: 'Middle East Hub' }, { id: 1, cardId: 12, type: 'hero', title: 'Profile' }, ], now ); expect(rows[0].visitorName).toBe('Alex'); expect(rows[0].visitorCompany).toBe('Nexus'); expect(rows[0].avatarUrl).toBe('https://cdn.example.com/alex.png'); expect(rows[0].online).toBe(true); expect(rows[0].viewedContents).toEqual([ { key: '2', title: 'Middle East Hub' }, { key: 'hero', title: 'Profile' }, ]); expect(rows[1].visitorName).toBeUndefined(); expect(rows[1].avatarUrl).toBeUndefined(); expect(rows[1].online).toBe(false); }); it('数据库蛇形字段也会归一成名片主可读的驼峰监控行', () => { const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar'); const now = Date.parse('2026-09-23T10:00:00.000Z'); const rows = decorateRadarTracks( [ { id: 4, card_id: '12', visitor_id: 'vis_snake', touch_type: 'nfc', stay_duration_ms: 6400, scroll_depth: 80, viewed_blocks: ['hero'], country: 'CN', city: 'Shanghai', ip: '::1', create_time: '2026-09-23 17:59:00', }, ], [], [], [], now ); expect(rows[0].visitorId).toBe('vis_snake'); expect(rows[0].cardId).toBe('12'); expect(rows[0].touchType).toBe('nfc'); expect(rows[0].stayDurationMs).toBe(6400); expect(rows[0].scrollDepth).toBe(80); expect(rows[0].viewedBlocks).toEqual(['hero']); expect(String(rows[0].createdAt)).toContain('2026-09-23'); }); });