feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -221,12 +221,162 @@ describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服
|
||||
syncService.sendWebhook('', 'secret', mockLead)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
|
||||
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/crm',
|
||||
'file:///etc/passwd',
|
||||
])('拒绝内网或非 http(s) URL:%s,不发请求且不记成功', async url => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
|
||||
await expect(
|
||||
syncService.sendWebhook(url, 'wh_secret', mockLead, { delayMs: 1 })
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('公网 https URL 仍应发送并附带 X-OmniCard-Signature', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { ok: true },
|
||||
});
|
||||
const secret = 'wh_secret_public';
|
||||
const url = 'https://hooks.example.com/crm/lead';
|
||||
|
||||
const result = await syncService.sendWebhook(url, secret, mockLead, {
|
||||
delayMs: 1,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(postSpy).toHaveBeenCalledTimes(1);
|
||||
const [, payload, config] = postSpy.mock.calls[0];
|
||||
expect(config?.headers?.['X-OmniCard-Signature']).toBe(
|
||||
syncService.generateSignature(payload, secret)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 控制器接口集成 (/admin/crm/lead/sync-external)
|
||||
// 5. Slack / 飞书:组包 ≠ 已送达 (delivered)
|
||||
// ==========================================
|
||||
describe('5. 控制器接口 /admin/crm/lead/sync-external 集成测试', () => {
|
||||
describe('5. Slack / 飞书组包与送达标记', () => {
|
||||
it('formatToSlack 仅组包,不发起 HTTP', () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
const payload = syncService.formatToSlack(mockLead);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
text: expect.stringContaining('John Doe'),
|
||||
});
|
||||
expect(payload.text).toContain('Acme Global Corp');
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('formatToFeishu 仅组包,不发起 HTTP', () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
const payload = syncService.formatToFeishu(mockLead);
|
||||
|
||||
expect(payload.msg_type).toBe('text');
|
||||
expect(payload.content?.text).toContain('John Doe');
|
||||
expect(payload.content?.text).toContain('Acme Global Corp');
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendSlack 无 Webhook URL 时只组包,delivered 必须为 false', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
const result = await syncService.sendSlack(undefined, mockLead);
|
||||
|
||||
expect(result.delivered).toBe(false);
|
||||
expect(result.channel).toBe('slack');
|
||||
expect(result.payload).toBeDefined();
|
||||
expect(result.reason).toBeTruthy();
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendFeishu 空 URL 时只组包,delivered 必须为 false', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
const result = await syncService.sendFeishu('', mockLead);
|
||||
|
||||
expect(result.delivered).toBe(false);
|
||||
expect(result.channel).toBe('feishu');
|
||||
expect(result.payload).toBeDefined();
|
||||
expect(result.reason).toBeTruthy();
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendSlack 内网 URL 在发请求前抛 CoolCommException', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
|
||||
await expect(
|
||||
syncService.sendSlack('http://192.168.1.10/slack', mockLead)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendFeishu 内网 URL 在发请求前抛 CoolCommException', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
|
||||
await expect(
|
||||
syncService.sendFeishu('http://10.0.0.8/feishu', mockLead)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendSlack 公网 HTTP 2xx 才 delivered:true', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { ok: true },
|
||||
});
|
||||
|
||||
const result = await syncService.sendSlack(
|
||||
'https://hooks.slack.com/services/T00/B00/xxx',
|
||||
mockLead,
|
||||
{ delayMs: 1 }
|
||||
);
|
||||
|
||||
expect(result.delivered).toBe(true);
|
||||
expect(result.channel).toBe('slack');
|
||||
expect(postSpy).toHaveBeenCalledTimes(1);
|
||||
const [, body] = postSpy.mock.calls[0];
|
||||
expect(body).toMatchObject({ text: expect.any(String) });
|
||||
});
|
||||
|
||||
it('sendFeishu 公网 HTTP 非 2xx / 失败时 delivered:false,不伪造成功', async () => {
|
||||
jest.spyOn(axios, 'post').mockRejectedValue(new Error('502 Bad Gateway'));
|
||||
|
||||
const result = await syncService.sendFeishu(
|
||||
'https://open.feishu.cn/open-apis/bot/v2/hook/public-demo',
|
||||
mockLead,
|
||||
{ delayMs: 1, maxRetries: 1 }
|
||||
);
|
||||
|
||||
expect(result.delivered).toBe(false);
|
||||
expect(result.channel).toBe('feishu');
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('组包成功不得等同于 delivered:true(sendSlack / sendFeishu 无 URL)', async () => {
|
||||
const slack = await syncService.sendSlack(undefined, mockLead);
|
||||
const feishu = await syncService.sendFeishu(undefined, mockLead);
|
||||
|
||||
expect(slack.delivered).toBe(false);
|
||||
expect(feishu.delivered).toBe(false);
|
||||
expect(slack.payload).toBeDefined();
|
||||
expect(feishu.payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 控制器接口集成 (/admin/crm/lead/sync-external)
|
||||
// ==========================================
|
||||
describe('6. 控制器接口 /admin/crm/lead/sync-external 集成测试', () => {
|
||||
let controller: AdminCrmLeadController;
|
||||
let mockLeadRepo: { findOne: jest.Mock };
|
||||
|
||||
@@ -285,6 +435,8 @@ describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服
|
||||
data: {
|
||||
target: 'hubspot',
|
||||
leadId: 101,
|
||||
synced: false,
|
||||
reason: '未配置 HubSpot',
|
||||
payload: {
|
||||
properties: {
|
||||
email: 'john.doe@acme.com',
|
||||
@@ -313,6 +465,8 @@ describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服
|
||||
data: {
|
||||
target: 'salesforce',
|
||||
leadId: 101,
|
||||
synced: false,
|
||||
reason: '未配置 Salesforce',
|
||||
payload: {
|
||||
FirstName: 'John',
|
||||
LastName: 'Doe',
|
||||
|
||||
在新工单中引用
屏蔽一个用户