feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,266 @@
|
||||
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
|
||||
import { CurdOption } from '@cool-midway/core';
|
||||
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
|
||||
import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar';
|
||||
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
||||
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
|
||||
import { CrmLeadService } from '../../../src/modules/crm/service/lead';
|
||||
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
|
||||
/**
|
||||
* CoolController 元数据(脚本路径解析为异步)
|
||||
*/
|
||||
interface CoolControllerMeta {
|
||||
prefix?: string;
|
||||
curdOption?: CurdOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 CoolController 将 CRUD 配置写入类元数据
|
||||
*/
|
||||
async function waitCoolMeta(
|
||||
target: new (...args: never[]) => unknown
|
||||
): Promise<CoolControllerMeta> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < 2000) {
|
||||
const meta = getClassMetadata(CONTROLLER_KEY, target) as
|
||||
| CoolControllerMeta
|
||||
| undefined;
|
||||
if (meta?.curdOption) {
|
||||
return meta;
|
||||
}
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
}
|
||||
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
|
||||
}
|
||||
|
||||
function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] {
|
||||
return apis.map(api => (typeof api === 'string' ? api : api.method));
|
||||
}
|
||||
|
||||
describe('CRM 管理后台 CRUD 控制器 (admin/crm)', () => {
|
||||
describe('AdminCrmLeadController /admin/crm/lead', () => {
|
||||
let meta: CoolControllerMeta;
|
||||
let option: CurdOption;
|
||||
|
||||
beforeAll(async () => {
|
||||
meta = await waitCoolMeta(AdminCrmLeadController);
|
||||
option = meta.curdOption as CurdOption;
|
||||
});
|
||||
|
||||
it('路由前缀应为 /admin/crm/lead', () => {
|
||||
expect(meta.prefix).toBe('/admin/crm/lead');
|
||||
});
|
||||
|
||||
it('应开放完整 CRUD:add / delete / update / info / list / page', () => {
|
||||
expect(option.api).toEqual([
|
||||
'add',
|
||||
'delete',
|
||||
'update',
|
||||
'info',
|
||||
'list',
|
||||
'page',
|
||||
]);
|
||||
});
|
||||
|
||||
it('应绑定线索实体与线索服务', () => {
|
||||
expect(option.entity).toBe(CrmLeadEntity);
|
||||
expect(option.service).toBe(CrmLeadService);
|
||||
});
|
||||
|
||||
it('分页查询应支持姓名/公司/邮箱/电话模糊检索', () => {
|
||||
const pageQueryOp = option.pageQueryOp as {
|
||||
keyWordLikeFields?: string[];
|
||||
fieldEq?: string[];
|
||||
addOrderBy?: Record<string, string>;
|
||||
};
|
||||
expect(pageQueryOp.keyWordLikeFields).toEqual([
|
||||
'a.name',
|
||||
'a.company',
|
||||
'a.email',
|
||||
'a.phone',
|
||||
]);
|
||||
});
|
||||
|
||||
it('分页查询应支持状态/来源/名片精确筛选,并按创建时间倒序', () => {
|
||||
const pageQueryOp = option.pageQueryOp as {
|
||||
keyWordLikeFields?: string[];
|
||||
fieldEq?: string[];
|
||||
addOrderBy?: Record<string, string>;
|
||||
};
|
||||
expect(pageQueryOp.fieldEq).toEqual(['a.status', 'a.source', 'a.cardId']);
|
||||
expect(pageQueryOp.addOrderBy).toEqual({ createTime: 'desc' });
|
||||
});
|
||||
|
||||
it('应将 updateStatus 注册为 serviceApi(线索状态流转)', () => {
|
||||
const methods = serviceApiMethods(option.serviceApis);
|
||||
expect(methods).toContain('updateStatus');
|
||||
const named = (option.serviceApis || []).find(
|
||||
api => typeof api !== 'string' && api.method === 'updateStatus'
|
||||
) as { method: string; summary: string } | undefined;
|
||||
expect(named?.summary).toBe('线索状态流转');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminCrmRadarController /admin/crm/radar', () => {
|
||||
let meta: CoolControllerMeta;
|
||||
let option: CurdOption;
|
||||
|
||||
beforeAll(async () => {
|
||||
meta = await waitCoolMeta(AdminCrmRadarController);
|
||||
option = meta.curdOption as CurdOption;
|
||||
});
|
||||
|
||||
it('路由前缀应为 /admin/crm/radar', () => {
|
||||
expect(meta.prefix).toBe('/admin/crm/radar');
|
||||
});
|
||||
|
||||
it('足迹只读管理:开放 info / list / page / delete,不含 add', () => {
|
||||
expect(option.api).toEqual(['info', 'list', 'page', 'delete']);
|
||||
expect(option.api).not.toContain('add');
|
||||
});
|
||||
|
||||
it('应绑定足迹实体与雷达服务', () => {
|
||||
expect(option.entity).toBe(CrmTrackEntity);
|
||||
expect(option.service).toBe(CrmRadarService);
|
||||
});
|
||||
|
||||
it('分页查询应支持国家/城市/访客/IP 模糊检索', () => {
|
||||
const pageQueryOp = option.pageQueryOp as {
|
||||
keyWordLikeFields?: string[];
|
||||
fieldEq?: string[];
|
||||
addOrderBy?: Record<string, string>;
|
||||
};
|
||||
expect(pageQueryOp.keyWordLikeFields).toEqual([
|
||||
'a.country',
|
||||
'a.city',
|
||||
'a.visitorId',
|
||||
'a.ip',
|
||||
]);
|
||||
});
|
||||
|
||||
it('分页查询应支持名片/触达方式/国家精确筛选,并按 id 倒序', () => {
|
||||
const pageQueryOp = option.pageQueryOp as {
|
||||
keyWordLikeFields?: string[];
|
||||
fieldEq?: string[];
|
||||
addOrderBy?: Record<string, string>;
|
||||
};
|
||||
expect(pageQueryOp.fieldEq).toEqual([
|
||||
'a.cardId',
|
||||
'a.touchType',
|
||||
'a.country',
|
||||
]);
|
||||
expect(pageQueryOp.addOrderBy).toEqual({ id: 'desc' });
|
||||
});
|
||||
|
||||
it('应将 getRecentTracks 注册为 serviceApi(最近足迹)', () => {
|
||||
const methods = serviceApiMethods(option.serviceApis);
|
||||
expect(methods).toContain('getRecentTracks');
|
||||
const named = (option.serviceApis || []).find(
|
||||
api => typeof api !== 'string' && api.method === 'getRecentTracks'
|
||||
) as { method: string; summary: string } | undefined;
|
||||
expect(named?.summary).toBe('最近足迹');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serviceApis POST body 单参数对象转调', () => {
|
||||
it('updateStatus 接收 { id, status } 时应转调已有双参签名', async () => {
|
||||
const saved: CrmLeadEntity[] = [];
|
||||
const leadService = new CrmLeadService();
|
||||
leadService.crmLeadEntity = {
|
||||
save: jest.fn(async (item: CrmLeadEntity) => {
|
||||
if (!item.id) {
|
||||
item.id = 1;
|
||||
}
|
||||
const idx = saved.findIndex(row => row.id === item.id);
|
||||
if (idx >= 0) {
|
||||
saved[idx] = item;
|
||||
} else {
|
||||
saved.push(item);
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
findOne: jest.fn(async (options: { where?: { id?: number } }) => {
|
||||
const id = options?.where?.id;
|
||||
return saved.find(row => row.id === Number(id)) || null;
|
||||
}),
|
||||
} as unknown as CrmLeadService['crmLeadEntity'];
|
||||
|
||||
const created = await leadService.submitLead({
|
||||
cardId: 101,
|
||||
name: 'Admin Flow',
|
||||
email: 'admin@example.com',
|
||||
});
|
||||
expect(created.status).toBe('new');
|
||||
|
||||
const updated = await leadService.updateStatus({
|
||||
id: Number(created.id),
|
||||
status: 'contacted',
|
||||
});
|
||||
expect(updated.status).toBe('contacted');
|
||||
});
|
||||
|
||||
it('getRecentTracks 接收 { cardId, limit } 时应转调已有双参签名', async () => {
|
||||
const saved: CrmTrackEntity[] = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const track = new CrmTrackEntity();
|
||||
track.id = i;
|
||||
track.cardId = 202;
|
||||
track.visitorId = `vis_${i}`;
|
||||
track.touchType = 'link';
|
||||
saved.push(track);
|
||||
}
|
||||
|
||||
const radarService = new CrmRadarService();
|
||||
radarService.crmTrackEntity = {
|
||||
find: jest.fn(
|
||||
async (options: {
|
||||
where?: { cardId?: number };
|
||||
order?: { id?: string };
|
||||
take?: number;
|
||||
}) => {
|
||||
let list = saved.filter(
|
||||
row => row.cardId === Number(options?.where?.cardId)
|
||||
);
|
||||
if (options?.order?.id === 'DESC') {
|
||||
list = [...list].sort((a, b) => Number(b.id) - Number(a.id));
|
||||
}
|
||||
if (options?.take) {
|
||||
list = list.slice(0, options.take);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
),
|
||||
} as unknown as CrmRadarService['crmTrackEntity'];
|
||||
|
||||
const list = await radarService.getRecentTracks({
|
||||
cardId: 202,
|
||||
limit: 2,
|
||||
});
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].id).toBe(4);
|
||||
expect(list[1].id).toBe(3);
|
||||
});
|
||||
|
||||
it('updateStatus 旧双参签名仍可用,非法状态仍拒绝', async () => {
|
||||
const leadService = new CrmLeadService();
|
||||
leadService.crmLeadEntity = {
|
||||
save: jest.fn(async (item: CrmLeadEntity) => item),
|
||||
findOne: jest.fn(async () => {
|
||||
const lead = new CrmLeadEntity();
|
||||
lead.id = 7;
|
||||
lead.status = 'new';
|
||||
return lead;
|
||||
}),
|
||||
} as unknown as CrmLeadService['crmLeadEntity'];
|
||||
|
||||
const updated = await leadService.updateStatus(7, 'won');
|
||||
expect(updated.status).toBe('won');
|
||||
|
||||
await expect(
|
||||
leadService.updateStatus(7, 'invalid' as 'new')
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { lookupGeoIp } from '../../../src/comm/geoip/lookup';
|
||||
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
|
||||
|
||||
describe('T34: 内置 GeoIP 前缀表 (lookupGeoIp)', () => {
|
||||
describe('lookupGeoIp', () => {
|
||||
it('应将 8.8.8.8 解析为 US / Mountain View', () => {
|
||||
expect(lookupGeoIp('8.8.8.8')).toEqual({
|
||||
country: 'US',
|
||||
city: 'Mountain View',
|
||||
});
|
||||
});
|
||||
|
||||
it('应将 114.114.114.114 解析为 CN / Nanjing', () => {
|
||||
expect(lookupGeoIp('114.114.114.114')).toEqual({
|
||||
country: 'CN',
|
||||
city: 'Nanjing',
|
||||
});
|
||||
});
|
||||
|
||||
it('回环与私网 IP 应返回 null,交由雷达归 Local', () => {
|
||||
expect(lookupGeoIp('127.0.0.1')).toBeNull();
|
||||
expect(lookupGeoIp('::1')).toBeNull();
|
||||
expect(lookupGeoIp('localhost')).toBeNull();
|
||||
expect(lookupGeoIp('192.168.1.100')).toBeNull();
|
||||
expect(lookupGeoIp('10.0.8.1')).toBeNull();
|
||||
expect(lookupGeoIp('172.16.0.8')).toBeNull();
|
||||
});
|
||||
|
||||
it('应覆盖其余内置公网短前缀', () => {
|
||||
expect(lookupGeoIp('8.8.4.4')).toEqual({
|
||||
country: 'US',
|
||||
city: 'Mountain View',
|
||||
});
|
||||
expect(lookupGeoIp('1.1.1.1')).toEqual({
|
||||
country: 'AU',
|
||||
city: 'Sydney',
|
||||
});
|
||||
expect(lookupGeoIp('9.9.9.9')).toEqual({
|
||||
country: 'US',
|
||||
city: 'Berkeley',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CrmRadarService.parseIpLocation', () => {
|
||||
let radarService: CrmRadarService;
|
||||
|
||||
beforeEach(() => {
|
||||
radarService = new CrmRadarService();
|
||||
});
|
||||
|
||||
it('公网 8.8.8.8 应命中 GeoIP 为 US,不再 Unknown', () => {
|
||||
const loc = radarService.parseIpLocation('8.8.8.8');
|
||||
expect(loc.country).toBe('US');
|
||||
expect(loc.city).toBe('Mountain View');
|
||||
});
|
||||
|
||||
it('公网 114.114.114.114 应命中 GeoIP 为 CN', () => {
|
||||
const loc = radarService.parseIpLocation('114.114.114.114');
|
||||
expect(loc.country).toBe('CN');
|
||||
expect(loc.city).toBe('Nanjing');
|
||||
});
|
||||
|
||||
it('127.0.0.1 在 lookup 为 null 时 parseIpLocation 仍归 Local', () => {
|
||||
expect(lookupGeoIp('127.0.0.1')).toBeNull();
|
||||
expect(radarService.parseIpLocation('127.0.0.1')).toEqual({
|
||||
country: 'Local',
|
||||
city: 'Local',
|
||||
});
|
||||
});
|
||||
|
||||
it('cf-ipcountry=AE 应优先于公网 IP 的 GeoIP 结果', () => {
|
||||
const loc = radarService.parseIpLocation('8.8.8.8', {
|
||||
'cf-ipcountry': 'AE',
|
||||
});
|
||||
expect(loc.country).toBe('AE');
|
||||
expect(loc.city).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { BaseEntity } from '../../../src/comm/entity/base';
|
||||
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
||||
import { CrmLeadAuditEntity } from '../../../src/modules/crm/entity/audit';
|
||||
import { CrmLeadPoolService } from '../../../src/modules/crm/service/lead-pool';
|
||||
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
|
||||
|
||||
describe('Point 12: 团队线索公海池流转与自动分配机制 单元测试', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
|
||||
// ==========================================
|
||||
// 1. 实体与数据表映射规范测试
|
||||
// ==========================================
|
||||
describe('1. 实体定义与数据库字段映射测试', () => {
|
||||
it('CrmLeadEntity 应包含 user_id、is_pool 与 last_follow_up_time 映射', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === CrmLeadEntity);
|
||||
const userCol = columns.find(c => c.propertyName === 'userId');
|
||||
const isPoolCol = columns.find(c => c.propertyName === 'isPool');
|
||||
const followUpCol = columns.find(c => c.propertyName === 'lastFollowUpTime');
|
||||
|
||||
expect(userCol).toBeDefined();
|
||||
expect(userCol?.options.name || userCol?.propertyName).toBe('user_id');
|
||||
|
||||
expect(isPoolCol).toBeDefined();
|
||||
expect(isPoolCol?.options.name || isPoolCol?.propertyName).toBe('is_pool');
|
||||
|
||||
expect(followUpCol).toBeDefined();
|
||||
expect(followUpCol?.options.name || followUpCol?.propertyName).toBe('last_follow_up_time');
|
||||
});
|
||||
|
||||
it('CrmLeadAuditEntity 表名映射应为 crm_lead_audit 且继承 BaseEntity', () => {
|
||||
const table = metadata.tables.find(t => t.target === CrmLeadAuditEntity);
|
||||
expect(table).toBeDefined();
|
||||
expect(table?.name).toBe('crm_lead_audit');
|
||||
expect(CrmLeadAuditEntity.prototype instanceof BaseEntity).toBe(true);
|
||||
});
|
||||
|
||||
it('CrmLeadAuditEntity 应映射 lead_id、action、operator_id、from_user_id、to_user_id、reason 列', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === CrmLeadAuditEntity);
|
||||
const expected: Record<string, string> = {
|
||||
leadId: 'lead_id',
|
||||
action: 'action',
|
||||
operatorId: 'operator_id',
|
||||
fromUserId: 'from_user_id',
|
||||
toUserId: 'to_user_id',
|
||||
reason: 'reason',
|
||||
};
|
||||
for (const [prop, dbCol] of Object.entries(expected)) {
|
||||
const col = columns.find(c => c.propertyName === prop);
|
||||
expect(col).toBeDefined();
|
||||
expect(col?.options.name || col?.propertyName).toBe(dbCol);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 模拟内存数据仓储与并发 CAS
|
||||
// ==========================================
|
||||
describe('2. 公海池核心流转业务逻辑测试 (CrmLeadPoolService)', () => {
|
||||
let service: CrmLeadPoolService;
|
||||
let leads: CrmLeadEntity[];
|
||||
let audits: CrmLeadAuditEntity[];
|
||||
let nextAuditId = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
leads = [];
|
||||
audits = [];
|
||||
nextAuditId = 1;
|
||||
|
||||
// 预置线索数据
|
||||
const lead1 = new CrmLeadEntity();
|
||||
lead1.id = 1;
|
||||
lead1.cardId = 101;
|
||||
lead1.name = '公海线索-1';
|
||||
lead1.status = 'new';
|
||||
lead1.isPool = true;
|
||||
lead1.userId = null;
|
||||
leads.push(lead1);
|
||||
|
||||
const lead2 = new CrmLeadEntity();
|
||||
lead2.id = 2;
|
||||
lead2.cardId = 101;
|
||||
lead2.name = '销售私海线索-2';
|
||||
lead2.status = 'contacted';
|
||||
lead2.isPool = false;
|
||||
lead2.userId = 88;
|
||||
lead2.lastFollowUpTime = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000); // 15天前未跟进
|
||||
leads.push(lead2);
|
||||
|
||||
const lead3 = new CrmLeadEntity();
|
||||
lead3.id = 3;
|
||||
lead3.cardId = 101;
|
||||
lead3.name = '最近跟进私海线索-3';
|
||||
lead3.status = 'hot';
|
||||
lead3.isPool = false;
|
||||
lead3.userId = 88;
|
||||
lead3.lastFollowUpTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2天前跟进
|
||||
leads.push(lead3);
|
||||
|
||||
const lead4 = new CrmLeadEntity();
|
||||
lead4.id = 4;
|
||||
lead4.cardId = 101;
|
||||
lead4.name = '已赢单线索-4';
|
||||
lead4.status = 'won';
|
||||
lead4.isPool = false;
|
||||
lead4.userId = 88;
|
||||
lead4.lastFollowUpTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30天前,但已赢单不回收
|
||||
leads.push(lead4);
|
||||
|
||||
// Mock CrmLeadEntity Repository
|
||||
const mockLeadRepo = {
|
||||
findOne: jest.fn(async (options: { where?: { id?: number } }) => {
|
||||
const id = options?.where?.id;
|
||||
const found = leads.find(l => l.id === Number(id));
|
||||
return found ? Object.assign(new CrmLeadEntity(), found) : null;
|
||||
}),
|
||||
find: jest.fn(async (options?: { where?: Partial<CrmLeadEntity> }) => {
|
||||
if (!options?.where) return [...leads];
|
||||
return leads.filter(item => {
|
||||
const itemMap = item as unknown as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(options.where!)) {
|
||||
if (itemMap[key] !== val) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
save: jest.fn(async (entity: CrmLeadEntity) => {
|
||||
const idx = leads.findIndex(l => l.id === entity.id);
|
||||
if (idx >= 0) {
|
||||
Object.assign(leads[idx], entity);
|
||||
return leads[idx];
|
||||
}
|
||||
leads.push(entity);
|
||||
return entity;
|
||||
}),
|
||||
// 模拟原子更新 CAS 操作
|
||||
update: jest.fn(async (criteria: { id: number; isPool?: boolean }, values: Partial<CrmLeadEntity>) => {
|
||||
const idx = leads.findIndex(l => {
|
||||
if (l.id !== Number(criteria.id)) return false;
|
||||
if (criteria.isPool !== undefined && l.isPool !== criteria.isPool) return false;
|
||||
return true;
|
||||
});
|
||||
if (idx === -1) {
|
||||
return { affected: 0 };
|
||||
}
|
||||
Object.assign(leads[idx], values);
|
||||
return { affected: 1 };
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock CrmLeadAuditEntity Repository
|
||||
const mockAuditRepo = {
|
||||
save: jest.fn(async (entity: CrmLeadAuditEntity) => {
|
||||
const saved = Object.assign(new CrmLeadAuditEntity(), entity, {
|
||||
id: nextAuditId++,
|
||||
createTime: new Date(),
|
||||
updateTime: new Date(),
|
||||
});
|
||||
audits.push(saved);
|
||||
return saved;
|
||||
}),
|
||||
find: jest.fn(async (options?: { where?: { leadId?: number } }) => {
|
||||
if (options?.where?.leadId) {
|
||||
return audits.filter(a => a.leadId === options.where!.leadId);
|
||||
}
|
||||
return [...audits];
|
||||
}),
|
||||
};
|
||||
|
||||
service = new CrmLeadPoolService();
|
||||
service.crmLeadEntity = mockLeadRepo as unknown as CrmLeadPoolService['crmLeadEntity'];
|
||||
service.crmLeadAuditEntity = mockAuditRepo as unknown as CrmLeadPoolService['crmLeadAuditEntity'];
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2.1 recycleToPool 回收线索至公海
|
||||
// ----------------------------------------------------
|
||||
describe('2.1 recycleToPool - 线索手动回收入公海池', () => {
|
||||
it('线索不存在时应抛出 404 异常', async () => {
|
||||
await expect(service.recycleToPool(9999, '测试不存在')).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
|
||||
it('成功回收私海线索,置 isPool=true, userId=null 并记录审计日志', async () => {
|
||||
const recycled = await service.recycleToPool(2, '客户暂时无需跟进');
|
||||
expect(recycled.isPool).toBe(true);
|
||||
expect(recycled.userId).toBeNull();
|
||||
|
||||
const leadLogs = audits.filter(a => a.leadId === 2);
|
||||
expect(leadLogs.length).toBe(1);
|
||||
expect(leadLogs[0].action).toBe('recycle');
|
||||
expect(leadLogs[0].fromUserId).toBe(88);
|
||||
expect(leadLogs[0].toUserId).toBeNull();
|
||||
expect(leadLogs[0].reason).toBe('客户暂时无需跟进');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2.2 scanAndAutoRecycle 超时未跟进自动批量回收
|
||||
// ----------------------------------------------------
|
||||
describe('2.2 scanAndAutoRecycle - 超时未跟进自动回收机制', () => {
|
||||
it('应自动将超过 inactivityDays 未跟进且非公海、非 won/archived 线索批量回收', async () => {
|
||||
// 设置 7 天未跟进为阈值
|
||||
const result = await service.scanAndAutoRecycle(7);
|
||||
// lead2 (15天未跟进) 应被回收
|
||||
// lead3 (2天未跟进) 不应被回收
|
||||
// lead4 (30天未跟进但为 won) 不应被回收
|
||||
// lead1 本来就是公海,不重复回收
|
||||
expect(result.recycledCount).toBe(1);
|
||||
expect(result.leadIds).toContain(2);
|
||||
|
||||
const targetLead = leads.find(l => l.id === 2);
|
||||
expect(targetLead?.isPool).toBe(true);
|
||||
expect(targetLead?.userId).toBeNull();
|
||||
|
||||
// 验证审计日志
|
||||
const autoLog = audits.find(a => a.leadId === 2 && a.action === 'auto_recycle');
|
||||
expect(autoLog).toBeDefined();
|
||||
expect(autoLog?.fromUserId).toBe(88);
|
||||
expect(autoLog?.reason).toMatch(/超过 7 天未跟进/);
|
||||
});
|
||||
|
||||
it('天数未达到阈值时不应回收任何线索', async () => {
|
||||
const result = await service.scanAndAutoRecycle(30);
|
||||
expect(result.recycledCount).toBe(0);
|
||||
expect(result.leadIds.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2.3 claimLead 公海抢单与原子性防并发重抢
|
||||
// ----------------------------------------------------
|
||||
describe('2.3 claimLead - 销售抢单领用与并发保护', () => {
|
||||
it('线索不存在时应抛出 404 异常', async () => {
|
||||
await expect(service.claimLead(9999, 100)).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
|
||||
it('线索若已被领用(不在公海池),再次抢单应抛出 409 异常被拦截', async () => {
|
||||
// lead2 是私海线索 (isPool: false, userId: 88)
|
||||
await expect(service.claimLead(2, 100)).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
|
||||
it('正常抢单公海线索应成功,置 isPool=false, userId=领用人,并记录审计日志', async () => {
|
||||
// lead1 是公海线索
|
||||
const claimed = await service.claimLead(1, 100);
|
||||
expect(claimed.isPool).toBe(false);
|
||||
expect(claimed.userId).toBe(100);
|
||||
|
||||
const claimLog = audits.find(a => a.leadId === 1 && a.action === 'claim');
|
||||
expect(claimLog).toBeDefined();
|
||||
expect(claimLog?.toUserId).toBe(100);
|
||||
expect(claimLog?.operatorId).toBe(100);
|
||||
});
|
||||
|
||||
it('高并发场景防重抢:多个销售并发争抢同一公海线索,严格保证只有 1 人成功', async () => {
|
||||
// 确保 lead1 处于公海
|
||||
const lead = leads.find(l => l.id === 1);
|
||||
lead!.isPool = true;
|
||||
lead!.userId = null;
|
||||
|
||||
const salesUsers = [201, 202, 203, 204, 205];
|
||||
const results = await Promise.allSettled(
|
||||
salesUsers.map(uid => service.claimLead(1, uid))
|
||||
);
|
||||
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled');
|
||||
const rejected = results.filter(r => r.status === 'rejected');
|
||||
|
||||
// 仅有 1 个请求能成功抢单
|
||||
expect(fulfilled.length).toBe(1);
|
||||
// 其余 4 个并发争抢者全部被拦截拒绝
|
||||
expect(rejected.length).toBe(4);
|
||||
|
||||
// 最终数据库状态必须归属于成功的销售,且不在公海
|
||||
const finalLead = leads.find(l => l.id === 1);
|
||||
expect(finalLead?.isPool).toBe(false);
|
||||
expect(salesUsers).toContain(finalLead?.userId);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2.4 assignLead 管理员指定派发
|
||||
// ----------------------------------------------------
|
||||
describe('2.4 assignLead - 管理员指定分配线索', () => {
|
||||
it('线索不存在时应抛出 404 异常', async () => {
|
||||
await expect(service.assignLead(9999, 100, 1)).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
|
||||
it('缺少目标指派用户 targetUserId 时抛出 400 异常', async () => {
|
||||
await expect(service.assignLead(1, 0, 1)).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
|
||||
it('管理员成功指派线索给目标销售,记录分配前后人及管理员审计日志', async () => {
|
||||
const assigned = await service.assignLead(1, 105, 1);
|
||||
expect(assigned.isPool).toBe(false);
|
||||
expect(assigned.userId).toBe(105);
|
||||
|
||||
const assignLog = audits.find(a => a.leadId === 1 && a.action === 'assign');
|
||||
expect(assignLog).toBeDefined();
|
||||
expect(assignLog?.fromUserId).toBeNull();
|
||||
expect(assignLog?.toUserId).toBe(105);
|
||||
expect(assignLog?.operatorId).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2.5 审计轨迹完整性 (Audit Trail)
|
||||
// ----------------------------------------------------
|
||||
describe('2.5 getAuditLogs - 流转审计链路追踪', () => {
|
||||
it('应能完整检索线索全生命周期的流转审计轨迹', async () => {
|
||||
// 先分配给销售 101
|
||||
await service.assignLead(1, 101, 1);
|
||||
// 销售 101 退回公海
|
||||
await service.recycleToPool(1, '重新流转');
|
||||
// 销售 102 抢单
|
||||
await service.claimLead(1, 102);
|
||||
|
||||
const logs = await service.getAuditLogs(1);
|
||||
expect(logs.length).toBe(3);
|
||||
expect(logs.map(l => l.action)).toEqual(['assign', 'recycle', 'claim']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 管理后台控制器接口规范与路由调用测试
|
||||
// ==========================================
|
||||
describe('3. 管理后台接口暴露 (AdminCrmLeadController)', () => {
|
||||
let controller: AdminCrmLeadController;
|
||||
let poolService: CrmLeadPoolService;
|
||||
|
||||
beforeEach(() => {
|
||||
poolService = new CrmLeadPoolService();
|
||||
poolService.claimLead = jest.fn(async (leadId, userId) => {
|
||||
const lead = new CrmLeadEntity();
|
||||
lead.id = Number(leadId);
|
||||
lead.isPool = false;
|
||||
lead.userId = Number(userId);
|
||||
return lead;
|
||||
});
|
||||
poolService.assignLead = jest.fn(async (leadId, targetUserId, assignerId) => {
|
||||
const lead = new CrmLeadEntity();
|
||||
lead.id = Number(leadId);
|
||||
lead.isPool = false;
|
||||
lead.userId = Number(targetUserId);
|
||||
return lead;
|
||||
});
|
||||
|
||||
controller = new AdminCrmLeadController();
|
||||
controller.crmLeadPoolService = poolService;
|
||||
// 模拟 Cool-Admin BaseController 上下文与 ok 方法
|
||||
controller.ok = (data: unknown) => ({
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data,
|
||||
});
|
||||
(controller as unknown as { ctx: { admin: { userId: number } } }).ctx = {
|
||||
admin: { userId: 9 },
|
||||
};
|
||||
});
|
||||
|
||||
it('控制器应包含 poolClaim 与 poolAssign 接口方法', () => {
|
||||
expect(typeof controller.poolClaim).toBe('function');
|
||||
expect(typeof controller.poolAssign).toBe('function');
|
||||
});
|
||||
|
||||
it('POST /pool-claim 认领公海线索成功返回标准 1000 响应', async () => {
|
||||
const res = await controller.poolClaim({ leadId: 1, userId: 101 });
|
||||
expect(res.code).toBe(1000);
|
||||
expect(poolService.claimLead).toHaveBeenCalledWith(1, 101);
|
||||
const resData = (res as unknown as { data: CrmLeadEntity }).data;
|
||||
expect(resData.userId).toBe(101);
|
||||
});
|
||||
|
||||
it('POST /pool-claim 未提供 userId 时优先采用上下文登录用户 ID', async () => {
|
||||
const res = await controller.poolClaim({ leadId: 1 });
|
||||
expect(res.code).toBe(1000);
|
||||
expect(poolService.claimLead).toHaveBeenCalledWith(1, 9);
|
||||
});
|
||||
|
||||
it('POST /pool-assign 管理员派发成功返回标准 1000 响应', async () => {
|
||||
const res = await controller.poolAssign({ leadId: 1, targetUserId: 202 });
|
||||
expect(res.code).toBe(1000);
|
||||
expect(poolService.assignLead).toHaveBeenCalledWith(1, 202, 9);
|
||||
const resData = (res as unknown as { data: CrmLeadEntity }).data;
|
||||
expect(resData.userId).toBe(202);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
CrmLeadScoreService,
|
||||
LeadIntentGrade,
|
||||
LeadScoreBehaviorInput,
|
||||
} from '../../../src/modules/crm/service/lead-score';
|
||||
import { CrmLeadService } from '../../../src/modules/crm/service/lead';
|
||||
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
||||
import { RadarTrack, LeadEntity } from '../../../src/comm/types/omni';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
describe('Point 10: 线索智能意向度评分引擎 (lead-score.ts) 单元测试', () => {
|
||||
let scoreService: CrmLeadScoreService;
|
||||
|
||||
const mockTrack: Partial<RadarTrack> = {
|
||||
country: 'United Arab Emirates',
|
||||
city: 'Dubai',
|
||||
touchType: 'nfc',
|
||||
stayDurationMs: 320000,
|
||||
viewedBlocks: ['hero', 'case_study', 'product_demo', 'contact'],
|
||||
};
|
||||
|
||||
const mockLead: Partial<LeadEntity> = {
|
||||
name: 'Tariq Al-Mansoor',
|
||||
company: 'Gulf Horizon Trading',
|
||||
phone: '+971-50-9876543',
|
||||
email: 'tariq@gulfhorizon.ae',
|
||||
source: 'nfc',
|
||||
notes: '对中东区域独家代理感兴趣',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
scoreService = new CrmLeadScoreService();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 1. 停留时长加权规则测试 (>30s +15, >120s +30, >300s +40)
|
||||
// ==========================================
|
||||
describe('1. 停留时长加权规则', () => {
|
||||
it('停留时长 <= 30秒时,得分应为 0', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 0 }).score).toBe(0);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 15 }).score).toBe(0);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 30 }).score).toBe(0);
|
||||
});
|
||||
|
||||
it('停留时长 > 30秒且 <= 120秒时,得分应加 15', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 31 }).score).toBe(15);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 60 }).score).toBe(15);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 120 }).score).toBe(15);
|
||||
});
|
||||
|
||||
it('停留时长 > 120秒且 <= 300秒时,得分应加 30', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 121 }).score).toBe(30);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 200 }).score).toBe(30);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 300 }).score).toBe(30);
|
||||
});
|
||||
|
||||
it('停留时长 > 300秒时,得分应加 40', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 301 }).score).toBe(40);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 600 }).score).toBe(40);
|
||||
});
|
||||
|
||||
it('支持毫秒输入 (stayDurationMs) 并正确转换为秒级加权', () => {
|
||||
// 25000ms = 25s <= 30s -> 0
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 25000 }).score).toBe(0);
|
||||
// 45000ms = 45s > 30s -> 15
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 45000 }).score).toBe(15);
|
||||
// 150000ms = 150s > 120s -> 30
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 150000 }).score).toBe(30);
|
||||
// 360000ms = 360s > 300s -> 40
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 360000 }).score).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 浏览模块深度测试 (每个查看模块 +5,上限 20)
|
||||
// ==========================================
|
||||
describe('2. 浏览模块深度加权规则', () => {
|
||||
it('查看 0 个模块时,得分应为 0', () => {
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 0 }).score).toBe(0);
|
||||
expect(scoreService.calculateScore({ viewedBlocks: [] }).score).toBe(0);
|
||||
});
|
||||
|
||||
it('查看 1 至 3 个模块时,按每个模块 +5 累加', () => {
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 1 }).score).toBe(5);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 2 }).score).toBe(10);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 3 }).score).toBe(15);
|
||||
});
|
||||
|
||||
it('查看 4 个及以上模块时,得分上限截断为 20', () => {
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 4 }).score).toBe(20);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 5 }).score).toBe(20);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 10 }).score).toBe(20);
|
||||
});
|
||||
|
||||
it('传入 viewedBlocks 数组时应自动去重并计算', () => {
|
||||
const blocksWithDuplicates = ['hero', 'case', 'hero', 'social', 'case'];
|
||||
// 去重后为 3 个不同模块: hero, case, social -> 15分
|
||||
const res = scoreService.calculateScore({ viewedBlocks: blocksWithDuplicates });
|
||||
expect(res.score).toBe(15);
|
||||
expect(res.breakdown.moduleScore).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 高价值互动加权测试 (WhatsApp +25, vCard +20, 留资 +35)
|
||||
// ==========================================
|
||||
describe('3. 高价值互动加权规则', () => {
|
||||
it('点击 WhatsApp 加 25 分', () => {
|
||||
const res = scoreService.calculateScore({ clickedWhatsApp: true });
|
||||
expect(res.score).toBe(25);
|
||||
expect(res.breakdown.interactionScore).toBe(25);
|
||||
});
|
||||
|
||||
it('下载 vCard 加 20 分', () => {
|
||||
const res = scoreService.calculateScore({ downloadedVCard: true });
|
||||
expect(res.score).toBe(20);
|
||||
expect(res.breakdown.interactionScore).toBe(20);
|
||||
});
|
||||
|
||||
it('主动留资加 35 分', () => {
|
||||
const res = scoreService.calculateScore({ submittedLead: true });
|
||||
expect(res.score).toBe(35);
|
||||
expect(res.breakdown.interactionScore).toBe(35);
|
||||
});
|
||||
|
||||
it('多个高价值互动可叠加加分', () => {
|
||||
// WhatsApp (25) + vCard (20) = 45
|
||||
const res = scoreService.calculateScore({
|
||||
clickedWhatsApp: true,
|
||||
downloadedVCard: true,
|
||||
});
|
||||
expect(res.score).toBe(45);
|
||||
expect(res.breakdown.interactionScore).toBe(45);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 综合加权、边界与异常防御测试
|
||||
// ==========================================
|
||||
describe('4. 综合加权与边界防御保护 (0~100 截断、负数与异常)', () => {
|
||||
it('无任何行为时,应得 0 分并判定为 Cold', () => {
|
||||
const res = scoreService.calculateScore({});
|
||||
expect(res.score).toBe(0);
|
||||
expect(res.level).toBe('Cold');
|
||||
expect(res.breakdown).toEqual({
|
||||
stayScore: 0,
|
||||
moduleScore: 0,
|
||||
interactionScore: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('各维度组合加权正确', () => {
|
||||
// 停留 150s (+30) + 模块 2个 (+10) + WhatsApp (+25) = 65
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: 150,
|
||||
viewedBlockCount: 2,
|
||||
clickedWhatsApp: true,
|
||||
});
|
||||
expect(res.score).toBe(65);
|
||||
expect(res.level).toBe('Warm');
|
||||
expect(res.breakdown).toEqual({
|
||||
stayScore: 30,
|
||||
moduleScore: 10,
|
||||
interactionScore: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('高维度满额且叠加所有互动时,总分严格截断在 100 分上限', () => {
|
||||
// 停留 >300s (40) + 模块 >=4 (20) + WhatsApp (25) + vCard (20) + 留资 (35) = 140
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: 350,
|
||||
viewedBlockCount: 5,
|
||||
clickedWhatsApp: true,
|
||||
downloadedVCard: true,
|
||||
submittedLead: true,
|
||||
});
|
||||
expect(res.score).toBe(100);
|
||||
expect(res.level).toBe('Hot');
|
||||
});
|
||||
|
||||
it('负数与非法值输入时具备防御性保护,最小分为 0', () => {
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: -50,
|
||||
stayDurationMs: -10000,
|
||||
viewedBlockCount: -5,
|
||||
});
|
||||
expect(res.score).toBe(0);
|
||||
expect(res.level).toBe('Cold');
|
||||
expect(res.breakdown.stayScore).toBe(0);
|
||||
expect(res.breakdown.moduleScore).toBe(0);
|
||||
});
|
||||
|
||||
it('NaN 或异常参数输入时优雅兜底', () => {
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: NaN,
|
||||
viewedBlockCount: NaN,
|
||||
});
|
||||
expect(res.score).toBe(0);
|
||||
expect(res.level).toBe('Cold');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 分级判定测试 (>=75 Hot, 40~74 Warm, <40 Cold)
|
||||
// ==========================================
|
||||
describe('5. 意向度分级判定规范 (Hot / Warm / Cold)', () => {
|
||||
it('分值 < 40 分级为 Cold', () => {
|
||||
expect(scoreService.resolveIntentLevel(0)).toBe('Cold');
|
||||
expect(scoreService.resolveIntentLevel(15)).toBe('Cold');
|
||||
expect(scoreService.resolveIntentLevel(35)).toBe('Cold');
|
||||
expect(scoreService.resolveIntentLevel(39)).toBe('Cold');
|
||||
});
|
||||
|
||||
it('分值 40 ~ 74 分级为 Warm', () => {
|
||||
expect(scoreService.resolveIntentLevel(40)).toBe('Warm');
|
||||
expect(scoreService.resolveIntentLevel(50)).toBe('Warm');
|
||||
expect(scoreService.resolveIntentLevel(65)).toBe('Warm');
|
||||
expect(scoreService.resolveIntentLevel(74)).toBe('Warm');
|
||||
});
|
||||
|
||||
it('分值 >= 75 分级为 Hot', () => {
|
||||
expect(scoreService.resolveIntentLevel(75)).toBe('Hot');
|
||||
expect(scoreService.resolveIntentLevel(80)).toBe('Hot');
|
||||
expect(scoreService.resolveIntentLevel(100)).toBe('Hot');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 诊断报告与破冰首语生成测试 (generateDiagnosticReport)
|
||||
// ==========================================
|
||||
describe('6. 意向诊断报告与破冰首语生成引擎 (generateDiagnosticReport)', () => {
|
||||
it('为 Hot 级别线索生成高意向跟进建议与破冰首语', () => {
|
||||
const report = scoreService.generateDiagnosticReport(85, mockTrack, mockLead);
|
||||
|
||||
expect(report.score).toBe(85);
|
||||
expect(report.level).toBe('Hot');
|
||||
expect(report.suggestion).toBeDefined();
|
||||
expect(report.suggestion.length).toBeGreaterThan(10);
|
||||
expect(report.icebreaker).toBeDefined();
|
||||
expect(report.icebreaker).toContain('Tariq');
|
||||
expect(report.icebreaker).toContain('Gulf Horizon Trading');
|
||||
expect(report.highlights.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('为 Warm 级别线索生成培育建议与破冰首语', () => {
|
||||
const warmLead: Partial<LeadEntity> = {
|
||||
name: 'Sarah Chen',
|
||||
company: 'Apex Solutions',
|
||||
};
|
||||
const report = scoreService.generateDiagnosticReport(55, mockTrack, warmLead);
|
||||
|
||||
expect(report.score).toBe(55);
|
||||
expect(report.level).toBe('Warm');
|
||||
expect(report.suggestion).toBeDefined();
|
||||
expect(report.icebreaker).toContain('Sarah');
|
||||
expect(report.icebreaker).toContain('Apex Solutions');
|
||||
});
|
||||
|
||||
it('为 Cold 级别线索生成自动化触达建议与通用破冰首语', () => {
|
||||
const report = scoreService.generateDiagnosticReport(20, null, null);
|
||||
|
||||
expect(report.score).toBe(20);
|
||||
expect(report.level).toBe('Cold');
|
||||
expect(report.suggestion).toBeDefined();
|
||||
expect(report.icebreaker).toBeDefined();
|
||||
});
|
||||
|
||||
it('支持直接传入 LeadScoreResult 对象并生成诊断报告', () => {
|
||||
const scoreResult = scoreService.calculateScore({
|
||||
stayDurationSeconds: 320,
|
||||
viewedBlockCount: 4,
|
||||
clickedWhatsApp: true,
|
||||
}); // 40 + 20 + 25 = 85 -> Hot
|
||||
const report = scoreService.generateDiagnosticReport(
|
||||
scoreResult,
|
||||
mockTrack,
|
||||
mockLead
|
||||
);
|
||||
|
||||
expect(report.score).toBe(85);
|
||||
expect(report.level).toBe('Hot');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 7. CrmLeadService 集成智能评分引擎验证
|
||||
// ==========================================
|
||||
describe('7. CrmLeadService 评分引擎集成验证', () => {
|
||||
it('CrmLeadService 拥有 leadScoreService 属性或集成方法', () => {
|
||||
const leadService = new CrmLeadService();
|
||||
expect(leadService).toBeDefined();
|
||||
const scoreServiceInstance =
|
||||
leadService.leadScoreService || new CrmLeadScoreService();
|
||||
expect(scoreServiceInstance).toBeInstanceOf(CrmLeadScoreService);
|
||||
});
|
||||
|
||||
it('CrmLeadService.calculateLeadScore 能够正确代理评分', () => {
|
||||
const leadService = new CrmLeadService();
|
||||
const result = leadService.calculateLeadScore({
|
||||
stayDurationSeconds: 150,
|
||||
viewedBlockCount: 2,
|
||||
clickedWhatsApp: true,
|
||||
});
|
||||
expect(result.score).toBe(65);
|
||||
expect(result.level).toBe('Warm');
|
||||
expect(result.breakdown).toEqual({
|
||||
stayScore: 30,
|
||||
moduleScore: 10,
|
||||
interactionScore: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('CrmLeadService.generateDiagnosticReport 能够正确生成报告', () => {
|
||||
const leadService = new CrmLeadService();
|
||||
const report = leadService.generateDiagnosticReport(80, mockTrack, mockLead);
|
||||
expect(report.score).toBe(80);
|
||||
expect(report.level).toBe('Hot');
|
||||
expect(report.icebreaker).toContain('Tariq');
|
||||
});
|
||||
|
||||
it('CrmLeadService.submitLead 携带互动特征时自动调用新引擎算分并判定 hot 状态', async () => {
|
||||
const leadService = new CrmLeadService();
|
||||
const mockSavedEntity: Partial<CrmLeadEntity> = {};
|
||||
leadService.crmLeadEntity = {
|
||||
save: jest.fn().mockImplementation(async (entity: CrmLeadEntity) => {
|
||||
Object.assign(mockSavedEntity, entity);
|
||||
return entity;
|
||||
}),
|
||||
} as unknown as Repository<CrmLeadEntity>;
|
||||
|
||||
const saved = await leadService.submitLead({
|
||||
cardId: 202,
|
||||
name: 'Alexander Weber',
|
||||
email: 'alex@weber-machinery.de',
|
||||
stayDurationMs: 310000, // >300s -> 40分
|
||||
viewedBlocks: ['hero', 'case_1', 'video', 'contact'], // 4模块 -> 20分
|
||||
clickedWhatsApp: true, // +25分
|
||||
// 自动叠加留资 +35分 -> 40 + 20 + 25 + 35 = 120 -> 截断 100分
|
||||
});
|
||||
|
||||
expect(saved.intentScore).toBe(100);
|
||||
expect(saved.status).toBe('hot');
|
||||
expect(leadService.crmLeadEntity.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
CrmRadarPushService,
|
||||
RadarPushPayload,
|
||||
} from '../../../src/modules/crm/service/push';
|
||||
import { RadarTrack } from '../../../src/comm/types/omni';
|
||||
|
||||
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;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CrmRadarPushService();
|
||||
});
|
||||
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
|
||||
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
|
||||
import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar';
|
||||
|
||||
describe('Point 09: 全球访客雷达热力分布与大屏服务 (radar-map.test.ts)', () => {
|
||||
let radarService: CrmRadarService;
|
||||
let trackList: CrmTrackEntity[];
|
||||
|
||||
beforeEach(() => {
|
||||
trackList = [];
|
||||
|
||||
// Mock TypeORM Repository
|
||||
const mockRepo: any = {
|
||||
find: jest.fn().mockImplementation(async (options?: any) => {
|
||||
let list = [...trackList];
|
||||
if (options?.where) {
|
||||
const where = options.where;
|
||||
if (where.cardId !== undefined && where.cardId !== null) {
|
||||
list = list.filter((item) => Number(item.cardId) === Number(where.cardId));
|
||||
}
|
||||
if (where.createTime && typeof where.createTime === 'object') {
|
||||
// 支持 TypeORM MoreThanOrEqual / Between 等操作符过滤
|
||||
if (where.createTime._type === 'moreThanOrEqual' || where.createTime._value) {
|
||||
const minTime = where.createTime._value || where.createTime;
|
||||
list = list.filter((item) => new Date(item.createTime) >= new Date(minTime));
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
radarService = new CrmRadarService();
|
||||
radarService.crmTrackEntity = mockRepo;
|
||||
});
|
||||
|
||||
describe('1. 国家分布统计 (getCountryDistribution)', () => {
|
||||
it('空数据时应安全兜底返回空数组 []', async () => {
|
||||
const result = await radarService.getCountryDistribution();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('应准确按国家聚合访客数、计算占比与平均停留时长,并按人次降序排列', async () => {
|
||||
trackList = [
|
||||
{
|
||||
id: 1,
|
||||
cardId: 10,
|
||||
country: 'United States',
|
||||
stayDurationMs: 30000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
{
|
||||
id: 2,
|
||||
cardId: 10,
|
||||
country: 'United States',
|
||||
stayDurationMs: 10000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
{
|
||||
id: 3,
|
||||
cardId: 10,
|
||||
country: 'Singapore',
|
||||
stayDurationMs: 20000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
{
|
||||
id: 4,
|
||||
cardId: 10,
|
||||
country: 'Germany',
|
||||
stayDurationMs: 40000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
];
|
||||
|
||||
const result = await radarService.getCountryDistribution();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
// US 有 2 次访问 (50%), Singapore 1 次 (25%), Germany 1 次 (25%)
|
||||
expect(result[0].country).toBe('United States');
|
||||
expect(result[0].visitorCount).toBe(2);
|
||||
expect(result[0].percentage).toBe(50);
|
||||
expect(result[0].avgStayDurationMs).toBe(20000);
|
||||
|
||||
expect(result[1].visitorCount).toBe(1);
|
||||
expect(result[1].percentage).toBe(25);
|
||||
});
|
||||
|
||||
it('当国家字段为空或缺失时,应统一兜底为 Unknown', async () => {
|
||||
trackList = [
|
||||
{
|
||||
id: 1,
|
||||
cardId: 10,
|
||||
country: '',
|
||||
stayDurationMs: 5000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
{
|
||||
id: 2,
|
||||
cardId: 10,
|
||||
country: undefined,
|
||||
stayDurationMs: 15000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
];
|
||||
|
||||
const result = await radarService.getCountryDistribution();
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].country).toBe('Unknown');
|
||||
expect(result[0].visitorCount).toBe(2);
|
||||
expect(result[0].percentage).toBe(100);
|
||||
expect(result[0].avgStayDurationMs).toBe(10000);
|
||||
});
|
||||
|
||||
it('应支持根据指定 cardId 过滤数据', async () => {
|
||||
trackList = [
|
||||
{
|
||||
id: 1,
|
||||
cardId: 10,
|
||||
country: 'Japan',
|
||||
stayDurationMs: 10000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
{
|
||||
id: 2,
|
||||
cardId: 20,
|
||||
country: 'Japan',
|
||||
stayDurationMs: 20000,
|
||||
createTime: new Date(),
|
||||
} as CrmTrackEntity,
|
||||
];
|
||||
|
||||
const result = await radarService.getCountryDistribution(10);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].visitorCount).toBe(1);
|
||||
expect(result[0].country).toBe('Japan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 停留时长时段分布 (getHourlyStayStats)', () => {
|
||||
it('返回结果必须严格包含 0 至 23 共 24 个时段,空时段以 0 填充兜底', async () => {
|
||||
const stats = await radarService.getHourlyStayStats(7);
|
||||
expect(stats.length).toBe(24);
|
||||
for (let h = 0; h < 24; h++) {
|
||||
expect(stats[h].hour).toBe(h);
|
||||
expect(stats[h].visitorCount).toBe(0);
|
||||
expect(stats[h].avgStayDurationMs).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('应能正确将足迹按创建时间的小时进行归类与停留时长均值统计', async () => {
|
||||
const now = new Date();
|
||||
const dateHour14 = new Date(now);
|
||||
dateHour14.setHours(14, 30, 0, 0);
|
||||
|
||||
const dateHour14_2 = new Date(now);
|
||||
dateHour14_2.setHours(14, 45, 0, 0);
|
||||
|
||||
trackList = [
|
||||
{
|
||||
id: 1,
|
||||
cardId: 10,
|
||||
stayDurationMs: 10000,
|
||||
createTime: dateHour14,
|
||||
} as CrmTrackEntity,
|
||||
{
|
||||
id: 2,
|
||||
cardId: 10,
|
||||
stayDurationMs: 30000,
|
||||
createTime: dateHour14_2,
|
||||
} as CrmTrackEntity,
|
||||
];
|
||||
|
||||
const stats = await radarService.getHourlyStayStats(7);
|
||||
expect(stats.length).toBe(24);
|
||||
expect(stats[14].visitorCount).toBe(2);
|
||||
expect(stats[14].avgStayDurationMs).toBe(20000);
|
||||
expect(stats[0].visitorCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 终端渠道分析 (getDeviceStats)', () => {
|
||||
it('空数据时应安全返回包含基础渠道为 0 的统计', 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);
|
||||
});
|
||||
|
||||
it('应准确按 touchType (nfc, qr, link) 计算数量与占比', async () => {
|
||||
trackList = [
|
||||
{ id: 1, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity,
|
||||
{ id: 2, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity,
|
||||
{ id: 3, touchType: 'qr', createTime: new Date() } as CrmTrackEntity,
|
||||
{ id: 4, touchType: 'link', createTime: new Date() } as CrmTrackEntity,
|
||||
];
|
||||
|
||||
const result = await radarService.getDeviceStats();
|
||||
const nfcItem = result.find((item) => item.type === 'nfc');
|
||||
const qrItem = result.find((item) => item.type === 'qr');
|
||||
const linkItem = result.find((item) => item.type === 'link');
|
||||
|
||||
expect(nfcItem?.count).toBe(2);
|
||||
expect(nfcItem?.percentage).toBe(50);
|
||||
expect(qrItem?.count).toBe(1);
|
||||
expect(qrItem?.percentage).toBe(25);
|
||||
expect(linkItem?.count).toBe(1);
|
||||
expect(linkItem?.percentage).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 控制器接口契约 (AdminCrmRadarController)', () => {
|
||||
let controller: AdminCrmRadarController;
|
||||
let mockService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockService = {
|
||||
getCountryDistribution: jest.fn().mockResolvedValue([
|
||||
{ country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 },
|
||||
]),
|
||||
getHourlyStayStats: jest.fn().mockResolvedValue([]),
|
||||
getDeviceStats: jest.fn().mockResolvedValue([]),
|
||||
getAnalytics: jest.fn().mockResolvedValue({
|
||||
countryDistribution: [
|
||||
{ country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 },
|
||||
],
|
||||
hourlyStats: [],
|
||||
deviceStats: [],
|
||||
summary: {
|
||||
totalVisitors: 10,
|
||||
avgStayDurationMs: 15000,
|
||||
topCountry: 'Singapore',
|
||||
countriesCount: 1,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
controller = new AdminCrmRadarController();
|
||||
(controller as any).crmRadarService = mockService;
|
||||
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
|
||||
(controller as any).fail = (msg: string, code = 1001) => ({ code, message: msg });
|
||||
});
|
||||
|
||||
it('GET /admin/crm/radar/distribution 应返回标准 1000 成功响应与国家分布列表', async () => {
|
||||
const res: any = await controller.distribution({ cardId: 10 });
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data[0].country).toBe('Singapore');
|
||||
expect(mockService.getCountryDistribution).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('GET/POST /admin/crm/radar/analytics 应聚合返回大屏综合数据与 KPI 汇总', async () => {
|
||||
const res: any = await controller.analytics({ cardId: 10, days: 7 });
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.countryDistribution).toBeDefined();
|
||||
expect(res.data.hourlyStats).toBeDefined();
|
||||
expect(res.data.deviceStats).toBeDefined();
|
||||
expect(res.data.summary).toBeDefined();
|
||||
expect(res.data.summary.totalVisitors).toBe(10);
|
||||
expect(res.data.summary.topCountry).toBe('Singapore');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import * as crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { CrmSyncService } from '../../../src/modules/crm/service/sync';
|
||||
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
|
||||
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
|
||||
describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服务', () => {
|
||||
let syncService: CrmSyncService;
|
||||
let mockLead: CrmLeadEntity;
|
||||
|
||||
beforeEach(() => {
|
||||
syncService = new CrmSyncService();
|
||||
mockLead = new CrmLeadEntity();
|
||||
mockLead.id = 101;
|
||||
mockLead.name = 'John Doe';
|
||||
mockLead.company = 'Acme Global Corp';
|
||||
mockLead.email = 'john.doe@acme.com';
|
||||
mockLead.phone = '+1-555-0199';
|
||||
mockLead.status = 'new';
|
||||
mockLead.intentScore = 88;
|
||||
mockLead.source = 'nfc';
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 1. HubSpot Contacts v3 转换测试
|
||||
// ==========================================
|
||||
describe('1. HubSpot Contacts v3 格式转换 (formatToHubSpot)', () => {
|
||||
it('应正确转换完整线索字段,并固定 lifecyclestage 为 lead', () => {
|
||||
const hubspotData = syncService.formatToHubSpot(mockLead);
|
||||
|
||||
expect(hubspotData).toEqual({
|
||||
properties: {
|
||||
email: 'john.doe@acme.com',
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
phone: '+1-555-0199',
|
||||
company: 'Acme Global Corp',
|
||||
lifecyclestage: 'lead',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('单名应正确填入 firstname,lastname 为空字符串', () => {
|
||||
mockLead.name = 'Madonna';
|
||||
const hubspotData = syncService.formatToHubSpot(mockLead);
|
||||
|
||||
expect(hubspotData.properties.firstname).toBe('Madonna');
|
||||
expect(hubspotData.properties.lastname).toBe('');
|
||||
});
|
||||
|
||||
it('包含中间名/多个词的姓名应正确拆分首词为 firstname,其余为 lastname', () => {
|
||||
mockLead.name = 'John Fitzgerald Kennedy';
|
||||
const hubspotData = syncService.formatToHubSpot(mockLead);
|
||||
|
||||
expect(hubspotData.properties.firstname).toBe('John');
|
||||
expect(hubspotData.properties.lastname).toBe('Fitzgerald Kennedy');
|
||||
});
|
||||
|
||||
it('缺少可选字段时应优雅兜底为空字符串,不崩溃', () => {
|
||||
const partialLead: Partial<CrmLeadEntity> = {
|
||||
name: '',
|
||||
};
|
||||
const hubspotData = syncService.formatToHubSpot(partialLead);
|
||||
|
||||
expect(hubspotData.properties.email).toBe('');
|
||||
expect(hubspotData.properties.firstname).toBe('');
|
||||
expect(hubspotData.properties.lastname).toBe('');
|
||||
expect(hubspotData.properties.phone).toBe('');
|
||||
expect(hubspotData.properties.company).toBe('');
|
||||
expect(hubspotData.properties.lifecyclestage).toBe('lead');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. Salesforce Lead sObject 格式转换测试
|
||||
// ==========================================
|
||||
describe('2. Salesforce Lead sObject 格式转换 (formatToSalesforce)', () => {
|
||||
it('应正确转换标准字段,且状态固定为 Open - Not Contacted', () => {
|
||||
const sfData = syncService.formatToSalesforce(mockLead);
|
||||
|
||||
expect(sfData).toEqual({
|
||||
FirstName: 'John',
|
||||
LastName: 'Doe',
|
||||
Email: 'john.doe@acme.com',
|
||||
Company: 'Acme Global Corp',
|
||||
Phone: '+1-555-0199',
|
||||
Status: 'Open - Not Contacted',
|
||||
});
|
||||
});
|
||||
|
||||
it('单名时应确保必填字段 LastName 有值,FirstName 为空', () => {
|
||||
mockLead.name = 'Zendaya';
|
||||
const sfData = syncService.formatToSalesforce(mockLead);
|
||||
|
||||
expect(sfData.FirstName).toBe('');
|
||||
expect(sfData.LastName).toBe('Zendaya');
|
||||
});
|
||||
|
||||
it('姓名为空时应兜底 LastName 为 Unknown,FirstName 为空', () => {
|
||||
const emptyLead: Partial<CrmLeadEntity> = {
|
||||
name: '',
|
||||
company: '',
|
||||
};
|
||||
const sfData = syncService.formatToSalesforce(emptyLead);
|
||||
|
||||
expect(sfData.FirstName).toBe('');
|
||||
expect(sfData.LastName).toBe('Unknown');
|
||||
expect(sfData.Company).toBe('Unknown');
|
||||
expect(sfData.Status).toBe('Open - Not Contacted');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. HMAC-SHA256 签名计算与验证
|
||||
// ==========================================
|
||||
describe('3. HMAC-SHA256 签名计算 (generateSignature)', () => {
|
||||
it('应基于 secret 对 payload 计算出正确的 64 位十六进制 HMAC-SHA256 签名', () => {
|
||||
const secret = 'omnicard_super_secret_key_2026';
|
||||
const payload = {
|
||||
event: 'crm.lead.sync',
|
||||
data: { id: 101, name: 'John Doe' },
|
||||
};
|
||||
|
||||
const signature = syncService.generateSignature(payload, secret);
|
||||
const expected = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(JSON.stringify(payload))
|
||||
.digest('hex');
|
||||
|
||||
expect(signature).toHaveLength(64);
|
||||
expect(signature).toBe(expected);
|
||||
});
|
||||
|
||||
it('相同 payload 与 secret 计算出的签名必须严格幂等一致', () => {
|
||||
const secret = 'test_secret';
|
||||
const payload = { test: 123 };
|
||||
|
||||
const sig1 = syncService.generateSignature(payload, secret);
|
||||
const sig2 = syncService.generateSignature(payload, secret);
|
||||
|
||||
expect(sig1).toBe(sig2);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. Webhook 发送与模拟退避重试 (sendWebhook)
|
||||
// ==========================================
|
||||
describe('4. Webhook 发送与模拟退避重试 (sendWebhook)', () => {
|
||||
it('首次请求 200 成功时,应附加 X-OmniCard-Signature 请求头并返回 attempts: 1', async () => {
|
||||
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { received: true },
|
||||
});
|
||||
|
||||
const url = 'https://api.partner.com/webhook';
|
||||
const secret = 'wh_secret_888';
|
||||
const result = await syncService.sendWebhook(url, secret, mockLead, {
|
||||
delayMs: 1,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(postSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [calledUrl, calledPayload, config] = postSpy.mock.calls[0];
|
||||
expect(calledUrl).toBe(url);
|
||||
expect(calledPayload).toHaveProperty('event', 'crm.lead.sync');
|
||||
expect(calledPayload).toHaveProperty('data');
|
||||
expect(config?.headers?.['Content-Type']).toBe('application/json');
|
||||
expect(config?.headers?.['X-OmniCard-Signature']).toBeDefined();
|
||||
|
||||
const verifiedSig = syncService.generateSignature(calledPayload, secret);
|
||||
expect(config?.headers?.['X-OmniCard-Signature']).toBe(verifiedSig);
|
||||
});
|
||||
|
||||
it('若前两次网络失败第三次成功,应自动重试并在第 3 次成功返回', async () => {
|
||||
const postSpy = jest
|
||||
.spyOn(axios, 'post')
|
||||
.mockRejectedValueOnce(new Error('Network Timeout'))
|
||||
.mockRejectedValueOnce(new Error('502 Bad Gateway'))
|
||||
.mockResolvedValueOnce({ status: 200, data: { ok: true } });
|
||||
|
||||
const result = await syncService.sendWebhook(
|
||||
'https://api.partner.com/webhook',
|
||||
'wh_secret',
|
||||
mockLead,
|
||||
{ delayMs: 1 }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.attempts).toBe(3);
|
||||
expect(postSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('连续失败超过 3 次时,应停止重试,捕获错误并返回 success: false, attempts: 3', async () => {
|
||||
const postSpy = jest
|
||||
.spyOn(axios, 'post')
|
||||
.mockRejectedValue(new Error('Connection Refused'));
|
||||
|
||||
const result = await syncService.sendWebhook(
|
||||
'https://api.partner.com/webhook',
|
||||
'wh_secret',
|
||||
mockLead,
|
||||
{ delayMs: 1 }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.attempts).toBe(3);
|
||||
expect(result.error).toBe('Connection Refused');
|
||||
expect(postSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('缺少目标 Webhook URL 时应抛出 400 校验异常', async () => {
|
||||
await expect(
|
||||
syncService.sendWebhook('', 'secret', mockLead)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 控制器接口集成 (/admin/crm/lead/sync-external)
|
||||
// ==========================================
|
||||
describe('5. 控制器接口 /admin/crm/lead/sync-external 集成测试', () => {
|
||||
let controller: AdminCrmLeadController;
|
||||
let mockLeadRepo: { findOne: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
controller = new AdminCrmLeadController();
|
||||
mockLeadRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
// 依赖注入模拟
|
||||
(controller as unknown as { crmLeadEntity: unknown }).crmLeadEntity =
|
||||
mockLeadRepo;
|
||||
(controller as unknown as { crmSyncService: unknown }).crmSyncService =
|
||||
syncService;
|
||||
});
|
||||
|
||||
it('缺少线索 id 时应抛出 400 异常', async () => {
|
||||
await expect(
|
||||
controller.syncExternal({
|
||||
id: 0,
|
||||
target: 'hubspot',
|
||||
})
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
|
||||
it('不支持的 target 类型时应抛出 400 异常', async () => {
|
||||
await expect(
|
||||
controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'unknown_crm' as unknown as 'hubspot',
|
||||
})
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
|
||||
it('线索记录不存在时应抛出 404 异常', async () => {
|
||||
mockLeadRepo.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
controller.syncExternal({
|
||||
id: 999,
|
||||
target: 'hubspot',
|
||||
})
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
|
||||
it('正确同步至 hubspot 并返回格式化数据', async () => {
|
||||
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
|
||||
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'hubspot',
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data: {
|
||||
target: 'hubspot',
|
||||
leadId: 101,
|
||||
payload: {
|
||||
properties: {
|
||||
email: 'john.doe@acme.com',
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
phone: '+1-555-0199',
|
||||
company: 'Acme Global Corp',
|
||||
lifecyclestage: 'lead',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('正确同步至 salesforce 并返回格式化数据', async () => {
|
||||
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
|
||||
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'salesforce',
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data: {
|
||||
target: 'salesforce',
|
||||
leadId: 101,
|
||||
payload: {
|
||||
FirstName: 'John',
|
||||
LastName: 'Doe',
|
||||
Email: 'john.doe@acme.com',
|
||||
Company: 'Acme Global Corp',
|
||||
Phone: '+1-555-0199',
|
||||
Status: 'Open - Not Contacted',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('正确通过 webhook 发送签名数据并返回结果', async () => {
|
||||
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
|
||||
jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { ok: true },
|
||||
});
|
||||
|
||||
const res = await controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'webhook',
|
||||
webhookUrl: 'https://webhook.site/test',
|
||||
webhookSecret: 'secret_123',
|
||||
});
|
||||
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.target).toBe('webhook');
|
||||
expect(res.data.leadId).toBe(101);
|
||||
expect(res.data.result.success).toBe(true);
|
||||
expect(res.data.result.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('webhook 缺少 webhookUrl 时应抛出 400 异常', async () => {
|
||||
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
|
||||
|
||||
await expect(
|
||||
controller.syncExternal({
|
||||
id: 101,
|
||||
target: 'webhook',
|
||||
})
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户