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

549 行
19 KiB
TypeScript

import { BaseController } from '@cool-midway/core';
import { BaseSysUserEntity } from '../../../src/modules/base/entity/sys/user';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
import {
AdminTeamMembersController,
adminTeamMembersCurdOption,
} from '../../../src/modules/team/controller/admin/members';
import {
TeamLiveRankService,
TEAM_LIVE_RANK_EVENT,
} from '../../../src/modules/team/service/live-rank';
import type {
TeamMemberRankItem,
TeamRankBroadcastPayload,
} from '../../../src/comm/types/omni';
describe('Point 28: 展会现场团队获客实时排行榜大屏广播 (Live Rank & Broadcast)', () => {
let service: TeamLiveRankService;
let users: BaseSysUserEntity[];
let cards: CardInfoEntity[];
let leads: CrmLeadEntity[];
let tracks: CrmTrackEntity[];
beforeEach(() => {
users = [];
cards = [];
leads = [];
tracks = [];
// Mock Repositories
const mockUserRepo = {
find: jest.fn(async (options?: { where?: Record<string, unknown> }) => {
if (options?.where?.tenantId !== undefined) {
return users.filter(
u => Number(u.tenantId) === Number(options.where?.tenantId)
);
}
return [...users];
}),
};
const mockCardRepo = {
find: jest.fn(async (options?: { where?: Record<string, unknown> }) => {
if (options?.where?.tenantId !== undefined) {
return cards.filter(
c => Number(c.tenantId) === Number(options.where?.tenantId)
);
}
return [...cards];
}),
};
const mockLeadRepo = {
find: jest.fn(async (options?: { where?: Record<string, unknown> }) => {
if (options?.where?.tenantId !== undefined) {
return leads.filter(
l => Number(l.tenantId) === Number(options.where?.tenantId)
);
}
return [...leads];
}),
};
const mockTrackRepo = {
find: jest.fn(async (options?: { where?: Record<string, unknown> }) => {
if (options?.where?.tenantId !== undefined) {
return tracks.filter(
t => Number(t.tenantId) === Number(options.where?.tenantId)
);
}
return [...tracks];
}),
};
service = new TeamLiveRankService();
service.baseSysUserEntity = mockUserRepo as unknown as typeof service.baseSysUserEntity;
service.cardInfoEntity = mockCardRepo as unknown as typeof service.cardInfoEntity;
service.crmLeadEntity = mockLeadRepo as unknown as typeof service.crmLeadEntity;
service.crmTrackEntity = mockTrackRepo as unknown as typeof service.crmTrackEntity;
});
// ==========================================
// 1. 排行榜加权计分与降序排序测试
// ==========================================
describe('1. 排行榜加权计分与降序排序 (Weighted Score & Ranking)', () => {
it('1.1 正确按每线索 10 分、每触碰 1 分计算总分并降序排列', async () => {
// 成员 1: Alex (User 101, Card 1)
const u1 = new BaseSysUserEntity();
u1.id = 101;
u1.name = 'Alex Rivera';
u1.tenantId = 1;
u1.departmentName = '出海一队';
const c1 = new CardInfoEntity();
c1.id = 1;
c1.userId = 101;
c1.tenantId = 1;
// 成员 2: Sarah (User 102, Card 2)
const u2 = new BaseSysUserEntity();
u2.id = 102;
u2.name = 'Sarah Wang';
u2.tenantId = 1;
u2.departmentName = '出海二队';
const c2 = new CardInfoEntity();
c2.id = 2;
c2.userId = 102;
c2.tenantId = 1;
// 成员 3: Tariq (User 103, Card 3)
const u3 = new BaseSysUserEntity();
u3.id = 103;
u3.name = 'Tariq Mansour';
u3.tenantId = 1;
u3.departmentName = '中东中枢';
const c3 = new CardInfoEntity();
c3.id = 3;
c3.userId = 103;
c3.tenantId = 1;
users.push(u1, u2, u3);
cards.push(c1, c2, c3);
// Alex: 2 leads (20分), 5 touches (5分) => 25分
leads.push(
Object.assign(new CrmLeadEntity(), { id: 1, cardId: 1, userId: 101, tenantId: 1 }),
Object.assign(new CrmLeadEntity(), { id: 2, cardId: 1, userId: 101, tenantId: 1 })
);
for (let i = 0; i < 5; i++) {
tracks.push(
Object.assign(new CrmTrackEntity(), { id: 10 + i, cardId: 1, tenantId: 1 })
);
}
// Sarah: 5 leads (50分), 12 touches (12分) => 62分
for (let i = 0; i < 5; i++) {
leads.push(
Object.assign(new CrmLeadEntity(), { id: 20 + i, cardId: 2, userId: 102, tenantId: 1 })
);
}
for (let i = 0; i < 12; i++) {
tracks.push(
Object.assign(new CrmTrackEntity(), { id: 30 + i, cardId: 2, tenantId: 1 })
);
}
// Tariq: 0 leads (0分), 8 touches (8分) => 8分
for (let i = 0; i < 8; i++) {
tracks.push(
Object.assign(new CrmTrackEntity(), { id: 50 + i, cardId: 3, tenantId: 1 })
);
}
const ranks = await service.getTeamLiveRank('1');
expect(ranks).toHaveLength(3);
// 第 1 名: Sarah (62分)
expect(ranks[0].memberId).toBe(102);
expect(ranks[0].memberName).toBe('Sarah Wang');
expect(ranks[0].leads).toBe(5);
expect(ranks[0].touches).toBe(12);
expect(ranks[0].score).toBe(62);
expect(ranks[0].rank).toBe(1);
// 第 2 名: Alex (25分)
expect(ranks[1].memberId).toBe(101);
expect(ranks[1].memberName).toBe('Alex Rivera');
expect(ranks[1].leads).toBe(2);
expect(ranks[1].touches).toBe(5);
expect(ranks[1].score).toBe(25);
expect(ranks[1].rank).toBe(2);
// 第 3 名: Tariq (8分)
expect(ranks[2].memberId).toBe(103);
expect(ranks[2].memberName).toBe('Tariq Mansour');
expect(ranks[2].leads).toBe(0);
expect(ranks[2].touches).toBe(8);
expect(ranks[2].score).toBe(8);
expect(ranks[2].rank).toBe(3);
});
it('1.2 成员关联多张名片时,正确合并统计多名片的线索与触碰', async () => {
const u = new BaseSysUserEntity();
u.id = 201;
u.name = 'Multi Card Member';
u.tenantId = 1;
users.push(u);
const cA = new CardInfoEntity();
cA.id = 11;
cA.userId = 201;
cA.tenantId = 1;
const cB = new CardInfoEntity();
cB.id = 12;
cB.userId = 201;
cB.tenantId = 1;
cards.push(cA, cB);
leads.push(
Object.assign(new CrmLeadEntity(), { id: 101, cardId: 11, userId: 201, tenantId: 1 }),
Object.assign(new CrmLeadEntity(), { id: 102, cardId: 12, userId: 201, tenantId: 1 })
);
tracks.push(
Object.assign(new CrmTrackEntity(), { id: 201, cardId: 11, tenantId: 1 }),
Object.assign(new CrmTrackEntity(), { id: 202, cardId: 12, tenantId: 1 }),
Object.assign(new CrmTrackEntity(), { id: 203, cardId: 12, tenantId: 1 })
);
const ranks = await service.getTeamLiveRank('1');
expect(ranks).toHaveLength(1);
expect(ranks[0].leads).toBe(2);
expect(ranks[0].touches).toBe(3);
expect(ranks[0].score).toBe(2 * 10 + 3 * 1); // 23分
});
});
// ==========================================
// 2. 同分并列规则测试
// ==========================================
describe('2. 同分并列规则测试 (Tie-Break & Shared Rank Rules)', () => {
it('2.1 当两位成员总分相同时,应获得相同 rank 并标记 isTied', async () => {
const u1 = Object.assign(new BaseSysUserEntity(), { id: 301, name: 'Alice', tenantId: 2 });
const u2 = Object.assign(new BaseSysUserEntity(), { id: 302, name: 'Bob', tenantId: 2 });
const u3 = Object.assign(new BaseSysUserEntity(), { id: 303, name: 'Charlie', tenantId: 2 });
users.push(u1, u2, u3);
const c1 = Object.assign(new CardInfoEntity(), { id: 31, userId: 301, tenantId: 2 });
const c2 = Object.assign(new CardInfoEntity(), { id: 32, userId: 302, tenantId: 2 });
const c3 = Object.assign(new CardInfoEntity(), { id: 33, userId: 303, tenantId: 2 });
cards.push(c1, c2, c3);
// Alice: 3 leads (30), 10 touches (10) => 40 分
for (let i = 0; i < 3; i++) {
leads.push(Object.assign(new CrmLeadEntity(), { id: 300 + i, cardId: 31, userId: 301, tenantId: 2 }));
}
for (let i = 0; i < 10; i++) {
tracks.push(Object.assign(new CrmTrackEntity(), { id: 400 + i, cardId: 31, tenantId: 2 }));
}
// Bob: 4 leads (40), 0 touches (0) => 40 分 (与 Alice 同分!)
for (let i = 0; i < 4; i++) {
leads.push(Object.assign(new CrmLeadEntity(), { id: 310 + i, cardId: 32, userId: 302, tenantId: 2 }));
}
// Charlie: 1 lead (10), 5 touches (5) => 15 分
leads.push(Object.assign(new CrmLeadEntity(), { id: 320, cardId: 33, userId: 303, tenantId: 2 }));
for (let i = 0; i < 5; i++) {
tracks.push(Object.assign(new CrmTrackEntity(), { id: 420 + i, cardId: 33, tenantId: 2 }));
}
const ranks = await service.getTeamLiveRank('2');
expect(ranks).toHaveLength(3);
// 前两名总分都是 40 分
expect(ranks[0].score).toBe(40);
expect(ranks[1].score).toBe(40);
// 并列第 1 名
expect(ranks[0].rank).toBe(1);
expect(ranks[1].rank).toBe(1);
expect(ranks[0].isTied).toBe(true);
expect(ranks[1].isTied).toBe(true);
// 第 3 位 Charlie 的排名递推为第 3 名 (标准竞赛并列: 1, 1, 3)
expect(ranks[2].score).toBe(15);
expect(ranks[2].rank).toBe(3);
expect(ranks[2].isTied).toBe(false);
});
it('2.2 中间位置同分并列:第 2 名并列时第 4 名为 rank 4', async () => {
const u1 = Object.assign(new BaseSysUserEntity(), { id: 401, name: 'Leader', tenantId: 3 });
const u2 = Object.assign(new BaseSysUserEntity(), { id: 402, name: 'Runner1', tenantId: 3 });
const u3 = Object.assign(new BaseSysUserEntity(), { id: 403, name: 'Runner2', tenantId: 3 });
const u4 = Object.assign(new BaseSysUserEntity(), { id: 404, name: 'Fourth', tenantId: 3 });
users.push(u1, u2, u3, u4);
cards.push(
Object.assign(new CardInfoEntity(), { id: 41, userId: 401, tenantId: 3 }),
Object.assign(new CardInfoEntity(), { id: 42, userId: 402, tenantId: 3 }),
Object.assign(new CardInfoEntity(), { id: 43, userId: 403, tenantId: 3 }),
Object.assign(new CardInfoEntity(), { id: 44, userId: 404, tenantId: 3 })
);
// Leader: 10 leads = 100分
for (let i = 0; i < 10; i++) {
leads.push(Object.assign(new CrmLeadEntity(), { id: 500 + i, cardId: 41, userId: 401, tenantId: 3 }));
}
// Runner1: 5 leads = 50分
for (let i = 0; i < 5; i++) {
leads.push(Object.assign(new CrmLeadEntity(), { id: 520 + i, cardId: 42, userId: 402, tenantId: 3 }));
}
// Runner2: 50 touches = 50分
for (let i = 0; i < 50; i++) {
tracks.push(Object.assign(new CrmTrackEntity(), { id: 600 + i, cardId: 43, tenantId: 3 }));
}
// Fourth: 1 lead = 10分
leads.push(Object.assign(new CrmLeadEntity(), { id: 550, cardId: 44, userId: 404, tenantId: 3 }));
const ranks = await service.getTeamLiveRank('3');
expect(ranks[0].rank).toBe(1);
expect(ranks[0].score).toBe(100);
expect(ranks[1].rank).toBe(2);
expect(ranks[1].score).toBe(50);
expect(ranks[1].isTied).toBe(true);
expect(ranks[2].rank).toBe(2);
expect(ranks[2].score).toBe(50);
expect(ranks[2].isTied).toBe(true);
expect(ranks[3].rank).toBe(4);
expect(ranks[3].score).toBe(10);
expect(ranks[3].isTied).toBe(false);
});
});
// ==========================================
// 3. 展会事件 (eventId) 筛选测试
// ==========================================
describe('3. 展会现场事件筛选 (Event Filtering)', () => {
it('3.1 传入 eventId 时,仅聚合该展会事件的线索与触碰量', async () => {
const u1 = Object.assign(new BaseSysUserEntity(), { id: 501, name: 'Exhibitor', tenantId: 4 });
users.push(u1);
const c1 = Object.assign(new CardInfoEntity(), { id: 51, userId: 501, tenantId: 4 });
cards.push(c1);
// CES-2026 线索 (2条) 与 触碰 (3条)
const leadCes1 = Object.assign(new CrmLeadEntity(), {
id: 701,
cardId: 51,
userId: 501,
tenantId: 4,
notes: 'Met at #CES-2026 Booth 808',
});
(leadCes1 as unknown as { eventId?: string }).eventId = 'CES-2026';
const leadCes2 = Object.assign(new CrmLeadEntity(), {
id: 702,
cardId: 51,
userId: 501,
tenantId: 4,
notes: '#CES-2026 Client',
});
(leadCes2 as unknown as { eventId?: string }).eventId = 'CES-2026';
// MWC-2026 线索 (1条,不同展会)
const leadMwc = Object.assign(new CrmLeadEntity(), {
id: 703,
cardId: 51,
userId: 501,
tenantId: 4,
notes: '#MWC-2026 Lead',
});
(leadMwc as unknown as { eventId?: string }).eventId = 'MWC-2026';
leads.push(leadCes1, leadCes2, leadMwc);
// 触碰: 3条 CES-2026, 2条 MWC-2026
for (let i = 0; i < 3; i++) {
const t = Object.assign(new CrmTrackEntity(), { id: 800 + i, cardId: 51, tenantId: 4 });
(t as unknown as { eventId?: string }).eventId = 'CES-2026';
tracks.push(t);
}
for (let i = 0; i < 2; i++) {
const t = Object.assign(new CrmTrackEntity(), { id: 810 + i, cardId: 51, tenantId: 4 });
(t as unknown as { eventId?: string }).eventId = 'MWC-2026';
tracks.push(t);
}
// 按 CES-2026 查询
const ranksCes = await service.getTeamLiveRank('4', 'CES-2026');
expect(ranksCes).toHaveLength(1);
expect(ranksCes[0].leads).toBe(2);
expect(ranksCes[0].touches).toBe(3);
expect(ranksCes[0].score).toBe(2 * 10 + 3 * 1); // 23分
// 按 MWC-2026 查询
const ranksMwc = await service.getTeamLiveRank('4', 'MWC-2026');
expect(ranksMwc).toHaveLength(1);
expect(ranksMwc[0].leads).toBe(1);
expect(ranksMwc[0].touches).toBe(2);
expect(ranksMwc[0].score).toBe(1 * 10 + 2 * 1); // 12分
// 不传 eventId 时全部统计
const ranksAll = await service.getTeamLiveRank('4');
expect(ranksAll[0].leads).toBe(3);
expect(ranksAll[0].touches).toBe(5);
expect(ranksAll[0].score).toBe(35);
});
});
// ==========================================
// 4. 空数据容错测试
// ==========================================
describe('4. 空数据与边界容错测试 (Empty Data Tolerance)', () => {
it('4.1 租户无任何成员时安全返回空数组,不报错', async () => {
const ranks = await service.getTeamLiveRank('9999');
expect(Array.isArray(ranks)).toBe(true);
expect(ranks).toHaveLength(0);
});
it('4.2 成员没有任何名片、线索与触碰时,全零并列第 1 名', async () => {
const u = Object.assign(new BaseSysUserEntity(), { id: 901, name: 'Newbie', tenantId: 5 });
users.push(u);
const ranks = await service.getTeamLiveRank('5');
expect(ranks).toHaveLength(1);
expect(ranks[0].memberId).toBe(901);
expect(ranks[0].leads).toBe(0);
expect(ranks[0].touches).toBe(0);
expect(ranks[0].score).toBe(0);
expect(ranks[0].rank).toBe(1);
});
});
// ==========================================
// 5. WebSocket 广播事件载荷格式测试
// ==========================================
describe('5. WebSocket 广播事件载荷生成 (formatRankBroadcastPayload)', () => {
it('5.1 正确生成包含 event、timestamp、ranks、data 与 meta 的结构化广播载荷', () => {
const mockRanks: TeamMemberRankItem[] = [
{
memberId: 101,
memberName: 'Sarah',
departmentName: 'BD',
cardId: 1,
leads: 6,
touches: 10,
score: 70,
rank: 1,
isTied: false,
},
{
memberId: 102,
memberName: 'Alex',
departmentName: 'BD',
cardId: 2,
leads: 3,
touches: 10,
score: 40,
rank: 2,
isTied: false,
},
];
const before = Date.now();
const payload: TeamRankBroadcastPayload = service.formatRankBroadcastPayload(
mockRanks,
{ tenantId: '10', eventId: 'GITEX_2026' }
);
const after = Date.now();
expect(payload.event).toBe(TEAM_LIVE_RANK_EVENT);
expect(payload.event).toBe('team.live_rank');
expect(payload.timestamp).toBeGreaterThanOrEqual(before);
expect(payload.timestamp).toBeLessThanOrEqual(after);
expect(payload.total).toBe(2);
expect(payload.ranks).toEqual(mockRanks);
expect(payload.data).toEqual(mockRanks);
expect(payload.meta?.tenantId).toBe('10');
expect(payload.meta?.eventId).toBe('GITEX_2026');
expect(payload.meta?.topScore).toBe(70);
expect(payload.meta?.totalLeads).toBe(9);
expect(payload.meta?.totalTouches).toBe(20);
});
it('5.2 空数组 ranks 能够安全生成结构化载荷,各项指标为 0', () => {
const payload = service.formatRankBroadcastPayload([]);
expect(payload.event).toBe('team.live_rank');
expect(payload.total).toBe(0);
expect(payload.ranks).toEqual([]);
expect(payload.meta?.topScore).toBe(0);
expect(payload.meta?.totalLeads).toBe(0);
expect(payload.meta?.totalTouches).toBe(0);
});
});
// ==========================================
// 6. 控制器方法与 CRUD 配置测试
// ==========================================
describe('6. 控制器接口暴露与调用测试 (AdminTeamMembersController /live-rank)', () => {
let controller: AdminTeamMembersController;
let mockLiveRankService: TeamLiveRankService;
beforeEach(() => {
mockLiveRankService = new TeamLiveRankService();
mockLiveRankService.getTeamLiveRank = jest.fn(async () => [
{
memberId: 1,
memberName: 'Champion',
leads: 10,
touches: 5,
score: 105,
rank: 1,
},
]);
controller = new AdminTeamMembersController();
controller.teamLiveRankService = mockLiveRankService;
controller.ok = (data: unknown) => ({
code: 1000,
message: 'success',
data,
});
});
it('6.1 adminTeamMembersCurdOption 包含 liveRank 接口说明', () => {
const methods = (adminTeamMembersCurdOption.serviceApis || []).map(
(api: { method: string }) => api.method
);
expect(methods).toContain('liveRank');
});
it('6.2 postLiveRank 接口正常响应 code 1000 并返回排行榜', async () => {
const res = (await controller.postLiveRank({
tenantId: '8',
eventId: 'EXPO-2026',
})) as unknown as { code: number; message: string; data: TeamMemberRankItem[] };
expect(res.code).toBe(1000);
expect(mockLiveRankService.getTeamLiveRank).toHaveBeenCalledWith(
'8',
'EXPO-2026'
);
const data = res.data;
expect(data).toHaveLength(1);
expect(data[0].score).toBe(105);
});
it('6.3 postLiveRank 在未传参时默认兜底 tenantId 为 1', async () => {
const res = (await controller.postLiveRank()) as unknown as {
code: number;
message: string;
};
expect(res.code).toBe(1000);
expect(mockLiveRankService.getTeamLiveRank).toHaveBeenCalledWith(
'1',
undefined
);
});
});
});