- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
401 行
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|