补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
218 行
7.0 KiB
TypeScript
218 行
7.0 KiB
TypeScript
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<typeof axios>;
|
||
|
||
function makeTrack(overrides: Partial<RadarTrack> = {}): 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<keyof RadarPushPayload> = [
|
||
'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);
|
||
});
|
||
});
|
||
});
|