feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -100,6 +100,7 @@ describe('CRM 管理后台 CRUD 控制器 (admin/crm)', () => {
|
||||
it('应将 updateStatus 注册为 serviceApi(线索状态流转)', () => {
|
||||
const methods = serviceApiMethods(option.serviceApis);
|
||||
expect(methods).toContain('updateStatus');
|
||||
expect(methods).toContain('batchExport');
|
||||
const named = (option.serviceApis || []).find(
|
||||
api => typeof api !== 'string' && api.method === 'updateStatus'
|
||||
) as { method: string; summary: string } | undefined;
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { lookupGeoIp } from '../../../src/comm/geoip/lookup';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
lookupGeoIp,
|
||||
resolveGeoIp,
|
||||
resolveLanguageFromCountry,
|
||||
} from '../../../src/comm/geoip/lookup';
|
||||
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
|
||||
|
||||
describe('T34: 内置 GeoIP 前缀表 (lookupGeoIp)', () => {
|
||||
describe('lookupGeoIp', () => {
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('T34 / P29: GeoIP 可替换解析', () => {
|
||||
const originalBaseUrl = process.env.GEOIP_BASE_URL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBaseUrl === undefined) {
|
||||
delete process.env.GEOIP_BASE_URL;
|
||||
} else {
|
||||
process.env.GEOIP_BASE_URL = originalBaseUrl;
|
||||
}
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('lookupGeoIp(本地前缀库)', () => {
|
||||
it('应将 8.8.8.8 解析为 US / Mountain View', () => {
|
||||
expect(lookupGeoIp('8.8.8.8')).toEqual({
|
||||
country: 'US',
|
||||
@@ -40,6 +59,120 @@ describe('T34: 内置 GeoIP 前缀表 (lookupGeoIp)', () => {
|
||||
city: 'Berkeley',
|
||||
});
|
||||
});
|
||||
|
||||
it('未知公网 IP 不得瞎填国家/城市(含迪拜)', () => {
|
||||
expect(lookupGeoIp('203.0.113.50')).toBeNull();
|
||||
expect(lookupGeoIp('45.33.32.156')).toBeNull();
|
||||
expect(lookupGeoIp('185.199.108.153')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGeoIp(可替换)', () => {
|
||||
it('无 GEOIP_BASE_URL 时本地库命中返回国家城市与映射语言', async () => {
|
||||
delete process.env.GEOIP_BASE_URL;
|
||||
await expect(resolveGeoIp('8.8.8.8')).resolves.toEqual({
|
||||
country: 'US',
|
||||
city: 'Mountain View',
|
||||
language: 'en',
|
||||
});
|
||||
expect(mockedAxios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('无 GEOIP_BASE_URL 且本地未命中时返回空地理 + reason,不假装迪拜,不猜语言', async () => {
|
||||
delete process.env.GEOIP_BASE_URL;
|
||||
const result = await resolveGeoIp('45.33.32.156');
|
||||
expect(result.country).toBe('');
|
||||
expect(result.city).toBe('');
|
||||
expect(result.reason).toBe('not_found');
|
||||
expect(['', 'unknown']).toContain(result.language);
|
||||
expect(result.language).not.toBe('ar');
|
||||
expect(result.city.toLowerCase()).not.toContain('dubai');
|
||||
expect(result.country).not.toBe('AE');
|
||||
expect(mockedAxios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('有 GEOIP_BASE_URL 时用 axios 查询,超时 3000ms', async () => {
|
||||
process.env.GEOIP_BASE_URL = 'https://geo.example.com/lookup';
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: { country: 'DE', city: 'Berlin' },
|
||||
});
|
||||
|
||||
const result = await resolveGeoIp('1.2.3.4');
|
||||
expect(result).toEqual({ country: 'DE', city: 'Berlin', language: 'de' });
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://geo.example.com/lookup',
|
||||
expect.objectContaining({
|
||||
params: { ip: '1.2.3.4' },
|
||||
timeout: 3000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('远程失败时返回空地理 + reason,不抛错、不填假城市、不猜语言', async () => {
|
||||
process.env.GEOIP_BASE_URL = 'https://geo.example.com/lookup';
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error('timeout of 3000ms exceeded'));
|
||||
|
||||
const result = await resolveGeoIp('1.2.3.4');
|
||||
expect(result.country).toBe('');
|
||||
expect(result.city).toBe('');
|
||||
expect(result.reason).toContain('timeout');
|
||||
expect(['', 'unknown']).toContain(result.language);
|
||||
expect(result.language).not.toBe('ar');
|
||||
expect(result.city.toLowerCase()).not.toContain('dubai');
|
||||
});
|
||||
});
|
||||
|
||||
describe('R46: resolveLanguageFromCountry(无国家不猜语言)', () => {
|
||||
it('无国家时语言为空或 unknown,不得默认 ar', () => {
|
||||
for (const country of ['', ' ', null, undefined]) {
|
||||
const language = resolveLanguageFromCountry(country as string | null | undefined);
|
||||
expect(['', 'unknown']).toContain(language);
|
||||
expect(language).not.toBe('ar');
|
||||
expect(language).not.toBe('en');
|
||||
expect(language).not.toBe('zh');
|
||||
}
|
||||
});
|
||||
|
||||
it('有国家且映射表命中才返回对应语言', () => {
|
||||
expect(resolveLanguageFromCountry('US')).toBe('en');
|
||||
expect(resolveLanguageFromCountry('CN')).toBe('zh');
|
||||
expect(resolveLanguageFromCountry('AE')).toBe('ar');
|
||||
expect(resolveLanguageFromCountry('DE')).toBe('de');
|
||||
expect(resolveLanguageFromCountry('AU')).toBe('en');
|
||||
});
|
||||
|
||||
it('有国家但不在映射表时语言为空或 unknown,不得瞎猜', () => {
|
||||
const language = resolveLanguageFromCountry('ZZ');
|
||||
expect(['', 'unknown']).toContain(language);
|
||||
expect(language).not.toBe('ar');
|
||||
});
|
||||
|
||||
it('本地未命中 / 私网解析结果的 language 不得为 ar', async () => {
|
||||
delete process.env.GEOIP_BASE_URL;
|
||||
const unknown = await resolveGeoIp('185.199.108.153');
|
||||
expect(unknown.country).toBe('');
|
||||
expect(['', 'unknown']).toContain(unknown.language);
|
||||
expect(unknown.language).not.toBe('ar');
|
||||
|
||||
const privateIp = await resolveGeoIp('192.168.1.100');
|
||||
expect(privateIp.country).toBe('');
|
||||
expect(['', 'unknown']).toContain(privateIp.language);
|
||||
expect(privateIp.language).not.toBe('ar');
|
||||
});
|
||||
|
||||
it('远程仅有城市无国家时 language 为空或 unknown,不得默认 ar', async () => {
|
||||
process.env.GEOIP_BASE_URL = 'https://geo.example.com/lookup';
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: { city: 'Somewhere' },
|
||||
});
|
||||
|
||||
const result = await resolveGeoIp('1.2.3.4');
|
||||
expect(result.country).toBe('');
|
||||
expect(result.city).toBe('Somewhere');
|
||||
expect(['', 'unknown']).toContain(result.language);
|
||||
expect(result.language).not.toBe('ar');
|
||||
expect(result.city.toLowerCase()).not.toContain('dubai');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CrmRadarService.parseIpLocation', () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import axios from 'axios';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { CrmHubSpotService } from '../../../src/modules/crm/service/hubspot';
|
||||
|
||||
describe('CrmHubSpotService.createContact', () => {
|
||||
let service: CrmHubSpotService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CrmHubSpotService();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('accessToken 为空时应抛出 CoolCommException,说明未配置 HubSpot', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
|
||||
await expect(
|
||||
service.createContact('', {
|
||||
properties: { email: 'a@b.com' },
|
||||
})
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
await expect(
|
||||
service.createContact(' ', {
|
||||
properties: { email: 'a@b.com' },
|
||||
})
|
||||
).rejects.toThrow(/HubSpot/);
|
||||
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('有 token 时应向 HubSpot Contacts API 发起 Bearer 请求并返回 id 与原始响应', async () => {
|
||||
const token = 'pat-test-token-xyz';
|
||||
const payload = {
|
||||
properties: {
|
||||
email: 'john@acme.com',
|
||||
firstname: 'John',
|
||||
},
|
||||
};
|
||||
const raw = { id: 'hs-contact-99', properties: payload.properties };
|
||||
|
||||
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
||||
status: 201,
|
||||
data: raw,
|
||||
});
|
||||
|
||||
const result = await service.createContact(token, payload);
|
||||
|
||||
expect(postSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, body, config] = postSpy.mock.calls[0];
|
||||
expect(url).toBe('https://api.hubapi.com/crm/v3/objects/contacts');
|
||||
expect(body).toEqual(payload);
|
||||
expect(config?.headers?.Authorization).toBe(`Bearer ${token}`);
|
||||
expect(config?.timeout).toBe(8000);
|
||||
|
||||
expect(result.id).toBe('hs-contact-99');
|
||||
expect(result.raw).toEqual(raw);
|
||||
});
|
||||
|
||||
it('对方返回 4xx 时应抛出包含状态码与错误信息的异常,不吞掉', async () => {
|
||||
const axiosError = Object.assign(new Error('Request failed with status code 400'), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 400,
|
||||
data: { message: 'Property values were not valid' },
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(axios, 'post').mockRejectedValueOnce(axiosError);
|
||||
|
||||
await expect(
|
||||
service.createContact('pat-valid', {
|
||||
properties: { email: 'bad' },
|
||||
})
|
||||
).rejects.toThrow(/400|Property values were not valid/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import { CrmLeadService } from '../../../src/modules/crm/service/lead';
|
||||
import { OpenCrmLeadController } from '../../../src/modules/crm/controller/open/lead';
|
||||
import { LeadEntity, LeadStatus } from '../../../src/comm/types/omni';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import {
|
||||
AUDIT_ACTION_LEAD_BATCH_EXPORT,
|
||||
BaseAuditLogService,
|
||||
} from '../../../src/modules/base/service/audit';
|
||||
|
||||
describe('Task 12: 后端潜客线索模块 (modules/crm) 单元测试', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
@@ -333,4 +337,82 @@ describe('Task 12: 后端潜客线索模块 (modules/crm) 单元测试', () => {
|
||||
expect(response.code).not.toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 线索批量导出审计 (batchExport)
|
||||
// ==========================================
|
||||
describe('5. 线索批量导出成功后写入审计 (batchExport)', () => {
|
||||
let leadService: CrmLeadService;
|
||||
let savedLeads: CrmLeadEntity[] = [];
|
||||
let record: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
savedLeads = [
|
||||
Object.assign(new CrmLeadEntity(), {
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
phone: '+971-50-1111111',
|
||||
email: 'a@ex.com',
|
||||
status: 'new',
|
||||
source: 'nfc',
|
||||
}),
|
||||
Object.assign(new CrmLeadEntity(), {
|
||||
id: 2,
|
||||
name: 'Bob',
|
||||
phone: '+971-50-2222222',
|
||||
email: 'b@ex.com',
|
||||
status: 'hot',
|
||||
source: 'qr',
|
||||
}),
|
||||
];
|
||||
|
||||
record = jest.fn(async dto => dto);
|
||||
const auditService = new BaseAuditLogService();
|
||||
auditService.record = record as unknown as BaseAuditLogService['record'];
|
||||
|
||||
leadService = new CrmLeadService();
|
||||
leadService.baseAuditLogService = auditService;
|
||||
leadService.crmLeadEntity = {
|
||||
find: jest.fn().mockImplementation(async () => [...savedLeads]),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
} as any;
|
||||
leadService.baseCtx = {
|
||||
admin: { userId: 7, username: 'ExportAdmin' },
|
||||
} as unknown as CrmLeadService['baseCtx'];
|
||||
});
|
||||
|
||||
it('导出成功应记录 lead_batch_export 审计且正文仅含条数不含手机号', async () => {
|
||||
const result = await leadService.batchExport({});
|
||||
|
||||
expect(result.exportCount).toBe(2);
|
||||
expect(result.list).toHaveLength(2);
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'crm',
|
||||
action: AUDIT_ACTION_LEAD_BATCH_EXPORT,
|
||||
operatorId: 7,
|
||||
operatorName: 'ExportAdmin',
|
||||
status: 1,
|
||||
})
|
||||
);
|
||||
const detailRaw = String(record.mock.calls[0][0].detail);
|
||||
const detail = JSON.parse(detailRaw);
|
||||
expect(detail.exportCount).toBe(2);
|
||||
expect(detailRaw).not.toContain('+971-50-1111111');
|
||||
expect(detailRaw).not.toContain('+971-50-2222222');
|
||||
expect(detail).not.toHaveProperty('phone');
|
||||
expect(detail).not.toHaveProperty('phones');
|
||||
});
|
||||
|
||||
it('导出查询失败时不得记成功审计', async () => {
|
||||
(leadService.crmLeadEntity.find as jest.Mock).mockRejectedValue(
|
||||
new Error('db down')
|
||||
);
|
||||
|
||||
await expect(leadService.batchExport({})).rejects.toThrow(/db down/);
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,3 +43,90 @@ describe('性能与防 OOM: 雷达国家分布与时段统计原生 SQL 聚合
|
||||
expect(result[0].avgStayDurationMs).toBe(45000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('R25: 语言占比与设备占比必须来自足迹真实计数', () => {
|
||||
let radarService: CrmRadarService;
|
||||
let trackList: any[];
|
||||
|
||||
beforeEach(() => {
|
||||
trackList = [];
|
||||
radarService = new CrmRadarService();
|
||||
radarService.crmTrackEntity = {
|
||||
find: jest.fn(async (options?: any) => {
|
||||
let list = [...trackList];
|
||||
if (options?.where?.cardId !== undefined) {
|
||||
list = list.filter((t) => Number(t.cardId) === Number(options.where.cardId));
|
||||
}
|
||||
return list;
|
||||
}),
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('无足迹时语言占比与设备占比均返回空数组,不得填示例语言或零值渠道', async () => {
|
||||
const languages = await radarService.getLanguageStats();
|
||||
const devices = await radarService.getDeviceStats();
|
||||
|
||||
expect(languages).toEqual([]);
|
||||
expect(devices).toEqual([]);
|
||||
expect(JSON.stringify(languages)).not.toMatch(/zh-CN|en-US|ar-SA|English|中文/i);
|
||||
expect(JSON.stringify(devices)).not.toMatch(/iPhone|Android|iOS|Samsung/i);
|
||||
});
|
||||
|
||||
it('足迹无 language/locale 字段时语言占比仍为空,不得用国家或固定比例冒充', async () => {
|
||||
trackList = [
|
||||
{ id: 1, cardId: 10, country: 'United States', touchType: 'nfc' },
|
||||
{ id: 2, cardId: 10, country: 'Germany', touchType: 'qr' },
|
||||
];
|
||||
|
||||
const languages = await radarService.getLanguageStats(10);
|
||||
expect(languages).toEqual([]);
|
||||
expect(languages.map((i) => i.percentage)).not.toEqual([40, 35, 25]);
|
||||
});
|
||||
|
||||
it('设备占比按 touchType 真实计数,仅返回有数据的类型与占比', async () => {
|
||||
trackList = [
|
||||
{ id: 1, cardId: 10, touchType: 'nfc' },
|
||||
{ id: 2, cardId: 10, touchType: 'nfc' },
|
||||
{ id: 3, cardId: 10, touchType: 'qr' },
|
||||
{ id: 4, cardId: 10, touchType: 'link' },
|
||||
];
|
||||
|
||||
const devices = await radarService.getDeviceStats(10);
|
||||
expect(devices).toHaveLength(3);
|
||||
|
||||
const byType = Object.fromEntries(devices.map((d) => [d.type, d]));
|
||||
expect(byType.nfc.count).toBe(2);
|
||||
expect(byType.nfc.percentage).toBe(50);
|
||||
expect(byType.qr.count).toBe(1);
|
||||
expect(byType.qr.percentage).toBe(25);
|
||||
expect(byType.link.count).toBe(1);
|
||||
expect(byType.link.percentage).toBe(25);
|
||||
expect(devices.every((d) => d.count > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('仅出现部分渠道时不补齐未出现的 nfc/qr/link 零值行', async () => {
|
||||
trackList = [
|
||||
{ id: 1, cardId: 10, touchType: 'nfc' },
|
||||
{ id: 2, cardId: 10, touchType: 'nfc' },
|
||||
];
|
||||
|
||||
const devices = await radarService.getDeviceStats(10);
|
||||
expect(devices).toEqual([{ type: 'nfc', count: 2, percentage: 100 }]);
|
||||
});
|
||||
|
||||
it('若足迹带有 language 字段则按真实计数聚合语言占比', async () => {
|
||||
trackList = [
|
||||
{ id: 1, cardId: 10, language: 'en-US', touchType: 'link' },
|
||||
{ id: 2, cardId: 10, language: 'en-US', touchType: 'link' },
|
||||
{ id: 3, cardId: 10, language: 'ar-SA', touchType: 'qr' },
|
||||
];
|
||||
|
||||
const languages = await radarService.getLanguageStats(10);
|
||||
expect(languages).toHaveLength(2);
|
||||
const byLang = Object.fromEntries(languages.map((l) => [l.language, l]));
|
||||
expect(byLang['en-US'].count).toBe(2);
|
||||
expect(byLang['en-US'].percentage).toBeCloseTo(66.7, 0);
|
||||
expect(byLang['ar-SA'].count).toBe(1);
|
||||
expect(byLang['ar-SA'].percentage).toBeCloseTo(33.3, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,11 +180,10 @@ describe('Point 09: 全球访客雷达热力分布与大屏服务 (radar-map.tes
|
||||
});
|
||||
|
||||
describe('3. 终端渠道分析 (getDeviceStats)', () => {
|
||||
it('空数据时应安全返回包含基础渠道为 0 的统计', async () => {
|
||||
it('空数据时应安全返回空数组 []', async () => {
|
||||
const result = await radarService.getDeviceStats();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.every((item) => item.count === 0 && item.percentage === 0)).toBe(true);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('应准确按 touchType (nfc, qr, link) 计算数量与占比', async () => {
|
||||
|
||||
@@ -308,6 +308,50 @@ describe('Task 11: 后端访客雷达模块 (modules/crm) 单元测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// R25. 语言占比 / 设备占比诚实聚合
|
||||
// ==========================================
|
||||
describe('R25. 语言占比与设备占比真实计数', () => {
|
||||
let radarService: CrmRadarService;
|
||||
let trackList: any[];
|
||||
|
||||
beforeEach(() => {
|
||||
trackList = [];
|
||||
radarService = new CrmRadarService();
|
||||
radarService.crmTrackEntity = {
|
||||
find: jest.fn(async (options?: any) => {
|
||||
let list = [...trackList];
|
||||
if (options?.where?.cardId !== undefined) {
|
||||
list = list.filter((t) => Number(t.cardId) === Number(options.where.cardId));
|
||||
}
|
||||
return list;
|
||||
}),
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('无数据时 getLanguageStats / getDeviceStats 返回 [],analytics 同步为空', async () => {
|
||||
const analytics = await radarService.getAnalytics();
|
||||
expect(await radarService.getLanguageStats()).toEqual([]);
|
||||
expect(await radarService.getDeviceStats()).toEqual([]);
|
||||
expect(analytics.languageStats).toEqual([]);
|
||||
expect(analytics.deviceStats).toEqual([]);
|
||||
});
|
||||
|
||||
it('设备占比来自 touchType 计数,空足迹不返回固定 0% 渠道行', async () => {
|
||||
expect(await radarService.getDeviceStats()).toEqual([]);
|
||||
|
||||
trackList = [
|
||||
{ id: 1, touchType: 'qr' },
|
||||
{ id: 2, touchType: 'qr' },
|
||||
{ id: 3, touchType: 'link' },
|
||||
];
|
||||
const devices = await radarService.getDeviceStats();
|
||||
expect(devices.find((d) => d.type === 'qr')?.percentage).toBeCloseTo(66.7, 0);
|
||||
expect(devices.find((d) => d.type === 'link')?.percentage).toBeCloseTo(33.3, 0);
|
||||
expect(devices.find((d) => d.type === 'nfc')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 最近足迹流水查询测试 (getRecentTracks)
|
||||
// ==========================================
|
||||
@@ -435,3 +479,105 @@ describe('Task 11: 后端访客雷达模块 (modules/crm) 单元测试', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('雷达监控行补全', () => {
|
||||
it('用线索姓名、内部用户头像和模块标题补全足迹,对不上则留空', () => {
|
||||
const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar');
|
||||
const now = Date.parse('2026-09-23T10:00:00.000Z');
|
||||
const rows = decorateRadarTracks(
|
||||
[
|
||||
{
|
||||
id: 8,
|
||||
cardId: 12,
|
||||
visitorId: 'vis_alex',
|
||||
country: 'CN',
|
||||
city: 'Shanghai',
|
||||
ip: '8.8.8.8',
|
||||
touchType: 'link',
|
||||
stayDurationMs: 12000,
|
||||
scrollDepth: 40,
|
||||
viewedBlocks: ['2', 'hero'],
|
||||
createTime: new Date(now - 30 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
cardId: 12,
|
||||
visitorId: 'vis_anon',
|
||||
country: 'Local',
|
||||
city: 'Local',
|
||||
touchType: 'qr',
|
||||
stayDurationMs: 0,
|
||||
scrollDepth: 0,
|
||||
viewedBlocks: [],
|
||||
createTime: new Date(now - 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 3,
|
||||
visitorId: 'vis_alex',
|
||||
name: 'Alex',
|
||||
company: 'Nexus',
|
||||
phone: '13800000000',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
phone: '13800000000',
|
||||
avatarUrl: 'https://cdn.example.com/alex.png',
|
||||
nickName: 'Alex Nick',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: 2, cardId: 12, type: 'case', title: 'Middle East Hub' },
|
||||
{ id: 1, cardId: 12, type: 'hero', title: 'Profile' },
|
||||
],
|
||||
now
|
||||
);
|
||||
|
||||
expect(rows[0].visitorName).toBe('Alex');
|
||||
expect(rows[0].visitorCompany).toBe('Nexus');
|
||||
expect(rows[0].avatarUrl).toBe('https://cdn.example.com/alex.png');
|
||||
expect(rows[0].online).toBe(true);
|
||||
expect(rows[0].viewedContents).toEqual([
|
||||
{ key: '2', title: 'Middle East Hub' },
|
||||
{ key: 'hero', title: 'Profile' },
|
||||
]);
|
||||
expect(rows[1].visitorName).toBeUndefined();
|
||||
expect(rows[1].avatarUrl).toBeUndefined();
|
||||
expect(rows[1].online).toBe(false);
|
||||
});
|
||||
|
||||
it('数据库蛇形字段也会归一成名片主可读的驼峰监控行', () => {
|
||||
const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar');
|
||||
const now = Date.parse('2026-09-23T10:00:00.000Z');
|
||||
const rows = decorateRadarTracks(
|
||||
[
|
||||
{
|
||||
id: 4,
|
||||
card_id: '12',
|
||||
visitor_id: 'vis_snake',
|
||||
touch_type: 'nfc',
|
||||
stay_duration_ms: 6400,
|
||||
scroll_depth: 80,
|
||||
viewed_blocks: ['hero'],
|
||||
country: 'CN',
|
||||
city: 'Shanghai',
|
||||
ip: '::1',
|
||||
create_time: '2026-09-23 17:59:00',
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
now
|
||||
);
|
||||
expect(rows[0].visitorId).toBe('vis_snake');
|
||||
expect(rows[0].cardId).toBe('12');
|
||||
expect(rows[0].touchType).toBe('nfc');
|
||||
expect(rows[0].stayDurationMs).toBe(6400);
|
||||
expect(rows[0].scrollDepth).toBe(80);
|
||||
expect(rows[0].viewedBlocks).toEqual(['hero']);
|
||||
expect(String(rows[0].createdAt)).toContain('2026-09-23');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import axios from 'axios';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { CrmSalesforceService } from '../../../src/modules/crm/service/salesforce';
|
||||
|
||||
describe('CrmSalesforceService.createLead', () => {
|
||||
let service: CrmSalesforceService;
|
||||
|
||||
const validPayload = {
|
||||
FirstName: 'Jane',
|
||||
LastName: 'Doe',
|
||||
Email: 'jane@acme.com',
|
||||
Company: 'Acme',
|
||||
Phone: '+15551234567',
|
||||
Status: 'Open - Not Contacted',
|
||||
};
|
||||
|
||||
const validInstanceUrl = 'https://myorg.my.salesforce.com';
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CrmSalesforceService();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('accessToken 为空时应抛出 CoolCommException,说明未配置 Salesforce', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
|
||||
await expect(
|
||||
service.createLead('', validInstanceUrl, validPayload)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
await expect(
|
||||
service.createLead(' ', validInstanceUrl, validPayload)
|
||||
).rejects.toThrow(/Salesforce/);
|
||||
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('instanceUrl 为空时应抛出 CoolCommException,说明未配置 Salesforce', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
|
||||
await expect(
|
||||
service.createLead('sf-token', '', validPayload)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
await expect(
|
||||
service.createLead('sf-token', ' ', validPayload)
|
||||
).rejects.toThrow(/Salesforce/);
|
||||
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非法 instanceUrl(非 https 或主机名不符)应抛错且不发请求', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post');
|
||||
|
||||
await expect(
|
||||
service.createLead('sf-token', 'http://myorg.my.salesforce.com', validPayload)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
await expect(
|
||||
service.createLead('sf-token', 'https://evil.example.com', validPayload)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
await expect(
|
||||
service.createLead('sf-token', 'https://127.0.0.1', validPayload)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
expect(postSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('有效时应向 Salesforce Lead API 发起 Bearer 请求并返回 id 与原始响应', async () => {
|
||||
const token = 'sf-access-token-xyz';
|
||||
const raw = { id: '00Qxx000001Lead', success: true, errors: [] };
|
||||
|
||||
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
||||
status: 201,
|
||||
data: raw,
|
||||
});
|
||||
|
||||
const result = await service.createLead(token, validInstanceUrl, validPayload);
|
||||
|
||||
expect(postSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, body, config] = postSpy.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
`${validInstanceUrl}/services/data/v59.0/sobjects/Lead`
|
||||
);
|
||||
expect(body).toEqual(validPayload);
|
||||
expect(config?.headers?.Authorization).toBe(`Bearer ${token}`);
|
||||
expect(config?.timeout).toBe(8000);
|
||||
|
||||
expect(result.id).toBe('00Qxx000001Lead');
|
||||
expect(result.raw).toEqual(raw);
|
||||
});
|
||||
|
||||
it('force.com 主机名应被允许', async () => {
|
||||
const instanceUrl = 'https://na1.salesforce.com';
|
||||
const forceUrl = 'https://custom.force.com';
|
||||
const token = 'sf-token';
|
||||
const raw = { id: '00Qxx000002', success: true };
|
||||
|
||||
const postSpy = jest
|
||||
.spyOn(axios, 'post')
|
||||
.mockResolvedValueOnce({ status: 201, data: raw })
|
||||
.mockResolvedValueOnce({ status: 201, data: raw });
|
||||
|
||||
await service.createLead(token, instanceUrl, validPayload);
|
||||
await service.createLead(token, forceUrl, validPayload);
|
||||
|
||||
expect(postSpy).toHaveBeenCalledTimes(2);
|
||||
expect(postSpy.mock.calls[0][0]).toBe(
|
||||
`${instanceUrl}/services/data/v59.0/sobjects/Lead`
|
||||
);
|
||||
expect(postSpy.mock.calls[1][0]).toBe(
|
||||
`${forceUrl}/services/data/v59.0/sobjects/Lead`
|
||||
);
|
||||
});
|
||||
|
||||
it('对方返回 4xx 时应抛出包含状态码与错误信息的异常,不返回 success:true', async () => {
|
||||
const axiosError = Object.assign(new Error('Request failed with status code 400'), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 400,
|
||||
data: [
|
||||
{
|
||||
message: 'Required fields are missing: [LastName]',
|
||||
errorCode: 'REQUIRED_FIELD_MISSING',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(axios, 'post').mockRejectedValueOnce(axiosError);
|
||||
|
||||
await expect(
|
||||
service.createLead('sf-valid', validInstanceUrl, validPayload)
|
||||
).rejects.toThrow(/400|Required fields are missing/);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('商务概览工作台聚合 (getWorkplaceOverview)', () => {
|
||||
find: jest.fn().mockResolvedValue(leads),
|
||||
} as never;
|
||||
|
||||
const result = await service.getWorkplaceOverview({ feedLimit: 8 });
|
||||
const result = await service.getWorkplaceOverview({ feedLimit: 8, now: today });
|
||||
expect(result.kpis.tapCount).toBe(1);
|
||||
expect(result.kpis.newLeadCount).toBe(1);
|
||||
expect(result.radarFeed[0].viewedBlocks).toEqual(['hero', 'cases']);
|
||||
|
||||
在新工单中引用
屏蔽一个用户