文件
OmniCard/backend/test/modules/team/handover.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

470 行
17 KiB
TypeScript

import { getMetadataArgsStorage } from 'typeorm';
import { CoolCommException } from '@cool-midway/core';
import { BaseEntity } from '../../../src/comm/entity/base';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { CrmLeadAuditEntity } from '../../../src/modules/crm/entity/audit';
import { TeamHandoverLogEntity } from '../../../src/modules/team/entity/handover-log';
import { TeamHandoverService } from '../../../src/modules/team/service/handover';
import { AdminTeamHandoverController } from '../../../src/modules/team/controller/admin/handover';
import { ExecuteHandoverDTO } from '../../../src/comm/types/omni';
import { CardPortalService } from '../../../src/modules/card/service/portal';
describe('Point 19: 离职资产交接工单流程与历史线索迁移 单元测试', () => {
const metadata = getMetadataArgsStorage();
// ==========================================
// 1. 实体定义与数据库字段映射测试
// ==========================================
describe('1. 实体定义与数据库字段映射测试', () => {
it('TeamHandoverLogEntity 表名映射应为 team_handover_log 且继承 BaseEntity', () => {
const table = metadata.tables.find(t => t.target === TeamHandoverLogEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('team_handover_log');
expect(TeamHandoverLogEntity.prototype instanceof BaseEntity).toBe(true);
});
it('TeamHandoverLogEntity 应映射各核心字段至下划线数据列', () => {
const columns = metadata.columns.filter(c => c.target === TeamHandoverLogEntity);
const expectedColumns: Record<string, string> = {
handoverNo: 'handover_no',
fromUserId: 'from_user_id',
toUserId: 'to_user_id',
cardId: 'card_id',
redirectType: 'redirect_type',
redirectTarget: 'redirect_target',
leadAction: 'lead_action',
migratedLeadCount: 'migrated_lead_count',
operatorId: 'operator_id',
status: 'status',
remark: 'remark',
snapshot: 'snapshot',
};
for (const [prop, colName] of Object.entries(expectedColumns)) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.name || col?.propertyName).toBe(colName);
}
});
it('CardInfoEntity 应支持 redirect_type 与 redirect_target 字段映射', () => {
const columns = metadata.columns.filter(c => c.target === CardInfoEntity);
const redirectTypeCol = columns.find(c => c.propertyName === 'redirectType');
const redirectTargetCol = columns.find(c => c.propertyName === 'redirectTarget');
expect(redirectTypeCol).toBeDefined();
expect(redirectTypeCol?.options.name || redirectTypeCol?.propertyName).toBe('redirect_type');
expect(redirectTargetCol).toBeDefined();
expect(redirectTargetCol?.options.name || redirectTargetCol?.propertyName).toBe('redirect_target');
});
});
// ==========================================
// 2. 核心服务流转与业务逻辑测试
// ==========================================
describe('2. 离职资产交接业务逻辑测试 (TeamHandoverService)', () => {
let service: TeamHandoverService;
let cards: CardInfoEntity[];
let leads: CrmLeadEntity[];
let audits: CrmLeadAuditEntity[];
let handoverLogs: TeamHandoverLogEntity[];
let nextAuditId = 1;
let nextLogId = 1;
beforeEach(() => {
cards = [];
leads = [];
audits = [];
handoverLogs = [];
nextAuditId = 1;
nextLogId = 1;
// 预置名片数据:离职员工 1001 的名片
const card1 = new CardInfoEntity();
card1.id = 101;
card1.userId = 1001;
card1.slug = 'former-employee-alex';
card1.name = 'Alex Former';
card1.status = 'published';
cards.push(card1);
// 接任人 2002 的已有新名片
const card2 = new CardInfoEntity();
card2.id = 202;
card2.userId = 2002;
card2.slug = 'successor-bob';
card2.name = 'Bob Successor';
card2.status = 'published';
cards.push(card2);
// 预置离职员工名下的 CRM 线索数据
const lead1 = new CrmLeadEntity();
lead1.id = 1;
lead1.userId = 1001;
lead1.cardId = 101;
lead1.name = '重点出海客户 A';
lead1.isPool = false;
lead1.status = 'contacted';
leads.push(lead1);
const lead2 = new CrmLeadEntity();
lead2.id = 2;
lead2.userId = 1001;
lead2.cardId = 101;
lead2.name = '潜在采购客户 B';
lead2.isPool = false;
lead2.status = 'new';
leads.push(lead2);
// Mock CardInfoEntity Repository
const mockCardRepo = {
findOneBy: jest.fn(async (criteria: { id?: number; userId?: number }) => {
return cards.find(c => {
if (criteria.id !== undefined && c.id !== criteria.id) return false;
if (criteria.userId !== undefined && c.userId !== criteria.userId) return false;
return true;
}) || null;
}),
find: jest.fn(async (options?: { where?: Partial<CardInfoEntity> }) => {
if (!options?.where) return [...cards];
return cards.filter(c => {
const itemMap = c 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: CardInfoEntity) => {
const idx = cards.findIndex(c => c.id === entity.id);
if (idx >= 0) {
cards[idx] = Object.assign(cards[idx], entity);
return cards[idx];
}
cards.push(entity);
return entity;
}),
};
// Mock CrmLeadEntity Repository
const mockLeadRepo = {
find: jest.fn(async (options?: { where?: Partial<CrmLeadEntity> }) => {
if (!options?.where) return [...leads];
return leads.filter(l => {
const itemMap = l 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 (entities: CrmLeadEntity | CrmLeadEntity[]) => {
const list = Array.isArray(entities) ? entities : [entities];
for (const item of list) {
const idx = leads.findIndex(l => l.id === item.id);
if (idx >= 0) {
leads[idx] = Object.assign(leads[idx], item);
} else {
leads.push(item);
}
}
return Array.isArray(entities) ? list : list[0];
}),
};
// Mock CrmLeadAuditEntity Repository
const mockAuditRepo = {
save: jest.fn(async (entities: CrmLeadAuditEntity | CrmLeadAuditEntity[]) => {
const list = Array.isArray(entities) ? entities : [entities];
const savedList = list.map(item =>
Object.assign(new CrmLeadAuditEntity(), item, {
id: nextAuditId++,
createTime: new Date(),
})
);
audits.push(...savedList);
return Array.isArray(entities) ? savedList : savedList[0];
}),
};
// Mock TeamHandoverLogEntity Repository
const mockLogRepo = {
save: jest.fn(async (entity: TeamHandoverLogEntity) => {
const saved = Object.assign(new TeamHandoverLogEntity(), entity, {
id: nextLogId++,
createTime: new Date(),
});
handoverLogs.push(saved);
return saved;
}),
find: jest.fn(async () => {
return [...handoverLogs].reverse();
}),
findAndCount: jest.fn(async () => {
return [[...handoverLogs].reverse(), handoverLogs.length];
}),
};
service = new TeamHandoverService();
service.cardInfoEntity = mockCardRepo as unknown as TeamHandoverService['cardInfoEntity'];
service.crmLeadEntity = mockLeadRepo as unknown as TeamHandoverService['crmLeadEntity'];
service.crmLeadAuditEntity = mockAuditRepo as unknown as TeamHandoverService['crmLeadAuditEntity'];
service.teamHandoverLogEntity = mockLogRepo as unknown as TeamHandoverService['teamHandoverLogEntity'];
});
// ----------------------------------------------------
// 2.1 参数校验测试
// ----------------------------------------------------
describe('2.1 参数合法性校验', () => {
it('缺少离职人 fromUserId 时应抛出异常', async () => {
await expect(
service.executeHandover({
fromUserId: 0,
toUserId: 2002,
})
).rejects.toThrow(CoolCommException);
});
it('接任人与离职人相同时应抛出异常', async () => {
await expect(
service.executeHandover({
fromUserId: 1001,
toUserId: 1001,
leadAction: 'transfer',
})
).rejects.toThrow('接替人不能与原负责人相同');
});
it('指定 cardId 且该名片不属于离职人时应抛出安全异常', async () => {
await expect(
service.executeHandover({
fromUserId: 1001,
toUserId: 2002,
cardId: 202, // 202 属于 2002
})
).rejects.toThrow('名片不属于原负责人');
});
});
// ----------------------------------------------------
// 2.2 短链重定向测试 (接替人名片 vs 企业公共微官网)
// ----------------------------------------------------
describe('2.2 名片短链安全重定向机制', () => {
it('支持定向至接替人新名片 (redirectType=card)', async () => {
const clearCacheSpy = jest.spyOn(CardPortalService, 'clearAllCache');
const log = await service.executeHandover({
fromUserId: 1001,
toUserId: 2002,
cardId: 101,
redirectType: 'card',
redirectTarget: '202', // 重定向到接替人名片 ID
leadAction: 'none',
operatorId: 888,
remark: '离职交接名片定向至接替人',
});
expect(log.status).toBe('completed');
expect(log.redirectType).toBe('card');
expect(log.redirectTarget).toBe('202');
const card = cards.find(c => c.id === 101);
expect(card?.redirectType).toBe('card');
expect(card?.redirectTarget).toBe('202');
expect(card?.internalConfig).toContain('"redirectType":"card"');
expect(clearCacheSpy).toHaveBeenCalled();
});
it('支持定向至企业公共微官网 (redirectType=portal)', async () => {
const log = await service.executeHandover({
fromUserId: 1001,
cardId: 101,
redirectType: 'portal',
redirectTarget: 'https://company.omnicard.ai/portal',
leadAction: 'none',
operatorId: 888,
remark: '定向至企业统一微官网',
});
expect(log.redirectType).toBe('portal');
expect(log.redirectTarget).toBe('https://company.omnicard.ai/portal');
const card = cards.find(c => c.id === 101);
expect(card?.redirectType).toBe('portal');
expect(card?.redirectTarget).toBe('https://company.omnicard.ai/portal');
});
});
// ----------------------------------------------------
// 2.3 历史 CRM 客户线索流转 (批量转移 vs 划入公海池)
// ----------------------------------------------------
describe('2.3 历史客户线索批量原子迁移与公海划分', () => {
it('批量转移至接任人名下 (leadAction=transfer)', async () => {
const log = await service.executeHandover({
fromUserId: 1001,
toUserId: 2002,
leadAction: 'transfer',
operatorId: 888,
remark: '线索全量移交给接替销售',
});
expect(log.migratedLeadCount).toBe(2);
// 验证线索归属已全部转移给 2002
const updatedLeads = leads.filter(l => l.userId === 2002);
expect(updatedLeads.length).toBe(2);
expect(updatedLeads.every(l => l.isPool === false)).toBe(true);
// 验证审计日志记录
expect(audits.length).toBe(2);
expect(audits.every(a => a.action === 'assign')).toBe(true);
expect(audits[0].fromUserId).toBe(1001);
expect(audits[0].toUserId).toBe(2002);
expect(audits[0].operatorId).toBe(888);
});
it('历史线索直接批量划入公海池 (leadAction=pool)', async () => {
const log = await service.executeHandover({
fromUserId: 1001,
leadAction: 'pool',
operatorId: 888,
remark: '离职人员线索重回公海池重新流转',
});
expect(log.migratedLeadCount).toBe(2);
// 验证线索已重置为公海状态
const poolLeads = leads.filter(l => l.isPool === true);
expect(poolLeads.length).toBe(2);
expect(poolLeads.every(l => l.userId === null)).toBe(true);
// 验证审计日志
expect(audits.length).toBe(2);
expect(audits.every(a => a.action === 'recycle')).toBe(true);
expect(audits[0].fromUserId).toBe(1001);
expect(audits[0].toUserId).toBeNull();
});
it('无待迁移线索时,迁移计数为 0', async () => {
// 离职人员 3003 无线索
const log = await service.executeHandover({
fromUserId: 3003,
toUserId: 2002,
leadAction: 'transfer',
});
expect(log.migratedLeadCount).toBe(0);
expect(audits.length).toBe(0);
});
});
// ----------------------------------------------------
// 2.4 不可逆安全交接工单与审计存证
// ----------------------------------------------------
describe('2.4 安全工单流水与审计留痕 (team_handover_log)', () => {
it('应自动生成唯一不可逆工单流水号与交接快照', async () => {
const log = await service.executeHandover({
fromUserId: 1001,
toUserId: 2002,
cardId: 101,
redirectType: 'card',
redirectTarget: '202',
leadAction: 'transfer',
operatorId: 999,
remark: '正式交接归档留痕',
});
expect(log.handoverNo).toMatch(/^HO\d+/);
expect(log.snapshot).toBeDefined();
const parsedSnapshot = JSON.parse(log.snapshot!);
expect(parsedSnapshot.fromUserId).toBe(1001);
expect(parsedSnapshot.leadIds).toEqual([1, 2]);
// 工单可被查询获取
const logs = await service.getHandoverLogs();
expect(logs.length).toBe(1);
expect(logs[0].handoverNo).toBe(log.handoverNo);
});
it('兼容保留原有 transfer(req) 方法并同步写入工单历史', async () => {
const updatedCard = await service.transfer({
cardId: 101,
fromUserId: 1001,
toUserId: 2002,
});
expect(updatedCard.userId).toBe(2002);
expect(handoverLogs.length).toBe(1);
expect(handoverLogs[0].fromUserId).toBe(1001);
expect(handoverLogs[0].toUserId).toBe(2002);
});
});
});
// ==========================================
// 3. 管理后台控制器接口测试 (AdminTeamHandoverController)
// ==========================================
describe('3. 控制器接口调用测试 (AdminTeamHandoverController)', () => {
let controller: AdminTeamHandoverController;
let service: TeamHandoverService;
beforeEach(() => {
service = new TeamHandoverService();
service.executeHandover = jest.fn(async (dto: ExecuteHandoverDTO) => {
const log = new TeamHandoverLogEntity();
log.id = 1;
log.handoverNo = 'HO202609201001';
log.fromUserId = Number(dto.fromUserId);
log.toUserId = dto.toUserId ? Number(dto.toUserId) : null;
log.status = 'completed';
log.migratedLeadCount = 3;
return log;
});
service.getHandoverLogs = jest.fn(async () => {
const log = new TeamHandoverLogEntity();
log.id = 1;
log.handoverNo = 'HO202609201001';
return [log];
});
controller = new AdminTeamHandoverController();
controller.teamHandoverService = service;
controller.ok = (data: unknown) => ({
code: 1000,
message: 'success',
data,
});
(controller as unknown as { ctx: { admin: { userId: number } } }).ctx = {
admin: { userId: 99 },
};
});
it('POST /execute 应成功执行离职交接工单流转并注入操作人 ID', async () => {
const res = await controller.execute({
fromUserId: 1001,
toUserId: 2002,
cardId: 101,
redirectType: 'card',
redirectTarget: '202',
leadAction: 'transfer',
});
expect(res.code).toBe(1000);
expect(service.executeHandover).toHaveBeenCalledWith(
expect.objectContaining({
fromUserId: 1001,
toUserId: 2002,
operatorId: 99,
})
);
});
it('POST /logs 应能查询交接历史工单列表', async () => {
const res = await controller.logs({});
expect(res.code).toBe(1000);
expect(service.getHandoverLogs).toHaveBeenCalled();
});
});
});