feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -0,0 +1,229 @@
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
|
||||
import { CrmSyncService } from '../../../src/modules/crm/service/sync';
|
||||
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
||||
|
||||
describe('AdminCrmLeadController.syncExternal 外部 CRM 真实同步', () => {
|
||||
let controller: AdminCrmLeadController;
|
||||
let syncService: CrmSyncService;
|
||||
let mockLeadRepo: { findOne: jest.Mock };
|
||||
let hubspotService: { createContact: jest.Mock };
|
||||
let salesforceService: { createLead: jest.Mock };
|
||||
|
||||
const mockLead = {
|
||||
id: 101,
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@acme.com',
|
||||
phone: '+1-555-0199',
|
||||
company: 'Acme Global Corp',
|
||||
status: 'new',
|
||||
} as unknown as CrmLeadEntity;
|
||||
|
||||
const envKeys = [
|
||||
'HUBSPOT_ACCESS_TOKEN',
|
||||
'SALESFORCE_ACCESS_TOKEN',
|
||||
'SALESFORCE_INSTANCE_URL',
|
||||
] as const;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
|
||||
syncService = new CrmSyncService();
|
||||
mockLeadRepo = { findOne: jest.fn().mockResolvedValue(mockLead) };
|
||||
hubspotService = { createContact: jest.fn() };
|
||||
salesforceService = { createLead: jest.fn() };
|
||||
|
||||
controller = new AdminCrmLeadController();
|
||||
(controller as unknown as { crmLeadEntity: unknown }).crmLeadEntity =
|
||||
mockLeadRepo;
|
||||
(controller as unknown as { crmSyncService: unknown }).crmSyncService =
|
||||
syncService;
|
||||
(controller as unknown as { crmHubSpotService: unknown }).crmHubSpotService =
|
||||
hubspotService;
|
||||
(
|
||||
controller as unknown as { crmSalesforceService: unknown }
|
||||
).crmSalesforceService = salesforceService;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const prev = savedEnv[key];
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('target=hubspot', () => {
|
||||
it('无 HUBSPOT_ACCESS_TOKEN 时不调用客户端,返回 synced:false', async () => {
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'hubspot',
|
||||
});
|
||||
|
||||
expect(hubspotService.createContact).not.toHaveBeenCalled();
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data).toMatchObject({
|
||||
target: 'hubspot',
|
||||
leadId: 101,
|
||||
synced: false,
|
||||
reason: '未配置 HubSpot',
|
||||
payload: {
|
||||
properties: {
|
||||
email: 'john.doe@acme.com',
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
phone: '+1-555-0199',
|
||||
company: 'Acme Global Corp',
|
||||
lifecyclestage: 'lead',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('有 token 时应调用 createContact 并返回 synced:true 与 id', async () => {
|
||||
process.env.HUBSPOT_ACCESS_TOKEN = 'pat-test-token';
|
||||
hubspotService.createContact.mockResolvedValueOnce({
|
||||
id: 'hs-42',
|
||||
raw: { id: 'hs-42' },
|
||||
});
|
||||
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'hubspot',
|
||||
});
|
||||
|
||||
expect(hubspotService.createContact).toHaveBeenCalledTimes(1);
|
||||
expect(hubspotService.createContact).toHaveBeenCalledWith(
|
||||
'pat-test-token',
|
||||
{
|
||||
properties: {
|
||||
email: 'john.doe@acme.com',
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
phone: '+1-555-0199',
|
||||
company: 'Acme Global Corp',
|
||||
lifecyclestage: 'lead',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data).toMatchObject({
|
||||
target: 'hubspot',
|
||||
leadId: 101,
|
||||
synced: true,
|
||||
id: 'hs-42',
|
||||
});
|
||||
});
|
||||
|
||||
it('客户端抛错时应向上抛出,不返回 synced:true', async () => {
|
||||
process.env.HUBSPOT_ACCESS_TOKEN = 'pat-test-token';
|
||||
hubspotService.createContact.mockRejectedValueOnce(
|
||||
new CoolCommException('HubSpot API 错误 400: bad', 400)
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.syncExternal({ id: 101, target: 'hubspot' })
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
expect(hubspotService.createContact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('target=salesforce', () => {
|
||||
it('缺凭证时不调用客户端,返回 synced:false', async () => {
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'salesforce',
|
||||
});
|
||||
|
||||
expect(salesforceService.createLead).not.toHaveBeenCalled();
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data).toMatchObject({
|
||||
target: 'salesforce',
|
||||
leadId: 101,
|
||||
synced: false,
|
||||
reason: '未配置 Salesforce',
|
||||
payload: {
|
||||
FirstName: 'John',
|
||||
LastName: 'Doe',
|
||||
Email: 'john.doe@acme.com',
|
||||
Company: 'Acme Global Corp',
|
||||
Phone: '+1-555-0199',
|
||||
Status: 'Open - Not Contacted',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('仅有 token 缺 instanceUrl 时也不调用客户端', async () => {
|
||||
process.env.SALESFORCE_ACCESS_TOKEN = 'sf-token';
|
||||
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'salesforce',
|
||||
});
|
||||
|
||||
expect(salesforceService.createLead).not.toHaveBeenCalled();
|
||||
expect(res.data).toMatchObject({
|
||||
synced: false,
|
||||
reason: '未配置 Salesforce',
|
||||
});
|
||||
});
|
||||
|
||||
it('凭证齐全时应调用 createLead 并返回 synced:true 与 id', async () => {
|
||||
process.env.SALESFORCE_ACCESS_TOKEN = 'sf-token';
|
||||
process.env.SALESFORCE_INSTANCE_URL = 'https://example.my.salesforce.com';
|
||||
salesforceService.createLead.mockResolvedValueOnce({
|
||||
id: '00QsfLead1',
|
||||
raw: { id: '00QsfLead1', success: true },
|
||||
});
|
||||
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'salesforce',
|
||||
});
|
||||
|
||||
expect(salesforceService.createLead).toHaveBeenCalledTimes(1);
|
||||
expect(salesforceService.createLead).toHaveBeenCalledWith(
|
||||
'sf-token',
|
||||
'https://example.my.salesforce.com',
|
||||
{
|
||||
FirstName: 'John',
|
||||
LastName: 'Doe',
|
||||
Email: 'john.doe@acme.com',
|
||||
Company: 'Acme Global Corp',
|
||||
Phone: '+1-555-0199',
|
||||
Status: 'Open - Not Contacted',
|
||||
}
|
||||
);
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data).toMatchObject({
|
||||
target: 'salesforce',
|
||||
leadId: 101,
|
||||
synced: true,
|
||||
id: '00QsfLead1',
|
||||
});
|
||||
});
|
||||
|
||||
it('客户端抛错时应向上抛出,不返回 synced:true', async () => {
|
||||
process.env.SALESFORCE_ACCESS_TOKEN = 'sf-token';
|
||||
process.env.SALESFORCE_INSTANCE_URL = 'https://example.my.salesforce.com';
|
||||
salesforceService.createLead.mockRejectedValueOnce(
|
||||
new CoolCommException('Salesforce API 错误 401: unauthorized', 401)
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.syncExternal({ id: 101, target: 'salesforce' })
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
expect(salesforceService.createLead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户