文件
OmniCard/backend/test/modules/crm/lead-pool.test.ts
wikiboss eec259a3a7 feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
2026-09-20 23:47:47 +08:00

401 行
16 KiB
TypeScript

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);
});
});
});