feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
CrmRadarPushService,
|
||||
RadarPushPayload,
|
||||
@@ -5,6 +6,9 @@ import {
|
||||
} 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,
|
||||
@@ -35,9 +39,20 @@ describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () =>
|
||||
];
|
||||
|
||||
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', () => {
|
||||
@@ -116,4 +131,87 @@ describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户