import axios from 'axios'; import { CrmRadarPushService, RadarPushPayload, isHotRadarPush, } from '../../../src/modules/crm/service/push'; import { RadarTrack } from '../../../src/comm/types/omni'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; function makeTrack(overrides: Partial = {}): RadarTrack { return { id: 1, tenantId: 10, cardId: 101, visitorId: 'vis_abc_123', ip: '8.8.8.8', country: 'US', city: 'Mountain View', touchType: 'nfc', stayDurationMs: 12000, scrollDepth: 40, viewedBlocks: ['hero'], createdAt: '2026-09-20T10:00:00.000Z', ...overrides, }; } describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () => { const payloadKeys: Array = [ 'event', 'cardId', 'visitorId', 'country', 'city', 'touchType', 'stayDurationMs', ]; let service: CrmRadarPushService; const originalWebhookUrl = process.env.RADAR_PUSH_WEBHOOK_URL; beforeEach(() => { service = new CrmRadarPushService(); delete process.env.RADAR_PUSH_WEBHOOK_URL; mockedAxios.post.mockReset(); }); afterEach(() => { if (originalWebhookUrl === undefined) { delete process.env.RADAR_PUSH_WEBHOOK_URL; } else { process.env.RADAR_PUSH_WEBHOOK_URL = originalWebhookUrl; } }); it('buildPayload 字段齐全且 event 为 radar.visitor', () => { const track = makeTrack(); const payload = service.buildPayload(track); for (const key of payloadKeys) { expect(payload).toHaveProperty(key); } expect(Object.keys(payload).sort()).toEqual([...payloadKeys].sort()); expect(payload.event).toBe('radar.visitor'); expect(payload.cardId).toBe(track.cardId); expect(payload.visitorId).toBe(track.visitorId); expect(payload.country).toBe(track.country); expect(payload.city).toBe(track.city); expect(payload.touchType).toBe(track.touchType); expect(payload.stayDurationMs).toBe(track.stayDurationMs); }); it('subscribe 后 publish 能收到对应名片的载荷', () => { const received: RadarPushPayload[] = []; const track = makeTrack({ cardId: 202, visitorId: 'vis_sub' }); service.subscribe(202, payload => { received.push(payload); }); service.publish(track); expect(received).toHaveLength(1); expect(received[0].event).toBe('radar.visitor'); expect(received[0].cardId).toBe(202); expect(received[0].visitorId).toBe('vis_sub'); }); it('不同 cardId 的订阅互不串扰', () => { const cardA: RadarPushPayload[] = []; const cardB: RadarPushPayload[] = []; service.subscribe(301, payload => { cardA.push(payload); }); service.subscribe('302', payload => { cardB.push(payload); }); service.publish(makeTrack({ cardId: 301, visitorId: 'vis_a' })); service.publish(makeTrack({ cardId: 302, visitorId: 'vis_b' })); expect(cardA).toHaveLength(1); expect(cardA[0].visitorId).toBe('vis_a'); expect(cardB).toHaveLength(1); expect(cardB[0].visitorId).toBe('vis_b'); }); it('停留满 3 分钟或浏览 3 个模块视为高意向推送', () => { expect(isHotRadarPush({ stayDurationMs: 179999, viewedBlocks: ['hero'] })).toBe( false ); expect(isHotRadarPush({ stayDurationMs: 180000, viewedBlocks: [] })).toBe(true); expect( isHotRadarPush({ stayDurationMs: 1000, viewedBlocks: ['hero', 'case', 'article'] }) ).toBe(true); }); it('unsubscribe 后不再收到后续 publish', () => { const received: RadarPushPayload[] = []; const listener = (payload: RadarPushPayload) => { received.push(payload); }; service.subscribe(401, listener); service.publish(makeTrack({ cardId: 401, visitorId: 'vis_before' })); service.unsubscribe(401, listener); service.publish(makeTrack({ cardId: 401, visitorId: 'vis_after' })); expect(received).toHaveLength(1); expect(received[0].visitorId).toBe('vis_before'); }); describe('dispatch 外发结果', () => { it('未配置 RADAR_PUSH_WEBHOOK_URL 时先通知内存监听者,返回 delivered:false channel:memory', async () => { const received: RadarPushPayload[] = []; const payload = service.buildPayload( makeTrack({ cardId: 501, visitorId: 'vis_dispatch_mem' }) ); service.subscribe(501, p => { received.push(p); }); const result = await service.dispatch(payload); expect(received).toHaveLength(1); expect(received[0].visitorId).toBe('vis_dispatch_mem'); expect(result).toEqual({ delivered: false, channel: 'memory' }); expect(result.delivered).toBe(false); expect(mockedAxios.post).not.toHaveBeenCalled(); }); it('公网 Webhook HTTP 2xx 才返回 delivered:true channel:webhook', async () => { process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar'; mockedAxios.post.mockResolvedValue({ status: 204, data: {} } as never); const payload = service.buildPayload( makeTrack({ cardId: 502, visitorId: 'vis_ok' }) ); const result = await service.dispatch(payload); expect(mockedAxios.post).toHaveBeenCalledWith( 'https://hooks.example.com/radar', payload, { timeout: 5000 } ); expect(result).toEqual({ delivered: true, channel: 'webhook' }); }); it('Webhook 请求失败不抛错,返回 delivered:false 且带 error', async () => { process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar'; mockedAxios.post.mockRejectedValue(new Error('socket hang up')); const payload = service.buildPayload( makeTrack({ cardId: 503, visitorId: 'vis_fail' }) ); await expect(service.dispatch(payload)).resolves.toEqual({ delivered: false, channel: 'webhook', error: 'socket hang up', }); }); it('非 2xx 不伪造 delivered:true', async () => { process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar'; mockedAxios.post.mockResolvedValue({ status: 500, data: 'err' } as never); const payload = service.buildPayload(makeTrack({ cardId: 504 })); const result = await service.dispatch(payload); expect(result.delivered).toBe(false); expect(result.channel).toBe('webhook'); expect(result.error).toBeTruthy(); }); it.each([ 'http://localhost/hook', 'http://127.0.0.1:9000/hook', 'http://10.1.2.3/hook', 'http://172.16.0.1/hook', 'http://172.31.255.1/hook', 'http://192.168.1.10/hook', 'ftp://hooks.example.com/radar', 'file:///etc/passwd', ])('拒绝内网或非 http(s) URL:%s,不发请求', async url => { process.env.RADAR_PUSH_WEBHOOK_URL = url; const payload = service.buildPayload(makeTrack({ cardId: 505 })); const result = await service.dispatch(payload); expect(mockedAxios.post).not.toHaveBeenCalled(); expect(result.delivered).toBe(false); expect(result.error).toMatch(/内网|拒绝|http/i); }); }); });