文件
OmniCard/backend/test/modules/crm/radar.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

584 行
20 KiB
TypeScript

import { getMetadataArgsStorage } from 'typeorm';
import { BaseEntity } from '../../../src/comm/entity/base';
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
import { OpenCrmRadarController } from '../../../src/modules/crm/controller/open/radar';
import { RadarTrack, TouchType } from '../../../src/comm/types/omni';
import { CoolCommException } from '@cool-midway/core';
describe('Task 11: 后端访客雷达模块 (modules/crm) 单元测试', () => {
const metadata = getMetadataArgsStorage();
// ==========================================
// 1. 实体与契约测试 (CrmTrackEntity & BaseEntity)
// ==========================================
describe('1. 访客足迹实体定义与数据库映射规范 (CrmTrackEntity)', () => {
it('实体表名必须映射为 crm_track', () => {
const table = metadata.tables.find(t => t.target === CrmTrackEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('crm_track');
});
it('实体必须继承自 BaseEntity,具备底层租户隔离与审计时间戳能力', () => {
expect(CrmTrackEntity.prototype instanceof BaseEntity).toBe(true);
});
it('所有实体属性与数据库列必须遵循驼峰至下划线映射规范', () => {
const columns = metadata.columns.filter(c => c.target === CrmTrackEntity);
const expectedColumnMappings: Record<string, string> = {
cardId: 'card_id',
visitorId: 'visitor_id',
ip: 'ip',
country: 'country',
city: 'city',
touchType: 'touch_type',
stayDurationMs: 'stay_duration_ms',
scrollDepth: 'scroll_depth',
viewedBlocks: 'viewed_blocks',
};
for (const [prop, dbCol] of Object.entries(expectedColumnMappings)) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.name || col?.propertyName).toBe(dbCol);
}
});
it('关键查询字段 cardId, visitorId, country, touchType 应声明索引', () => {
const indexes = metadata.indices.filter(i => i.target === CrmTrackEntity);
const indexProps = indexes.flatMap(i =>
Array.isArray(i.columns) ? i.columns : []
);
expect(indexProps).toContain('cardId');
expect(indexProps).toContain('visitorId');
expect(indexProps).toContain('country');
expect(indexProps).toContain('touchType');
});
it('默认值应符合业务规范: touchType=\'link\', stayDurationMs=0, scrollDepth=0', () => {
const columns = metadata.columns.filter(c => c.target === CrmTrackEntity);
const touchTypeCol = columns.find(c => c.propertyName === 'touchType');
expect(touchTypeCol?.options.default).toBe('link');
const stayCol = columns.find(c => c.propertyName === 'stayDurationMs');
expect(stayCol?.options.default).toBe(0);
const scrollCol = columns.find(c => c.propertyName === 'scrollDepth');
expect(scrollCol?.options.default).toBe(0);
});
it('CrmTrackEntity 实例应完全符合 RadarTrack 契约结构 (包含 createdAt 属性)', () => {
const track = new CrmTrackEntity();
track.id = 1;
track.tenantId = 10;
track.cardId = 101;
track.visitorId = 'vis_abc_123';
track.ip = '8.8.8.8';
track.country = 'US';
track.city = 'Mountain View';
track.touchType = 'nfc';
track.stayDurationMs = 15000;
track.scrollDepth = 80;
track.viewedBlocks = ['hero', 'case_1', 'form'];
track.createTime = new Date('2026-09-20T12:00:00Z');
const contract: RadarTrack = track;
expect(contract.id).toBe(1);
expect(contract.cardId).toBe(101);
expect(contract.visitorId).toBe('vis_abc_123');
expect(contract.touchType).toBe('nfc');
expect(contract.viewedBlocks).toEqual(['hero', 'case_1', 'form']);
expect(contract.createdAt).toBeDefined();
});
});
// ==========================================
// 2. IP 解析与地理位置推断 (parseIpLocation)
// ==========================================
describe('2. IP 地理位置轻量解析机制 (parseIpLocation)', () => {
let radarService: CrmRadarService;
beforeEach(() => {
radarService = new CrmRadarService();
});
it('本地回环 IP (127.0.0.1 / ::1 / localhost) 应解析为 Local', () => {
expect(radarService.parseIpLocation('127.0.0.1')).toEqual({
country: 'Local',
city: 'Local',
});
expect(radarService.parseIpLocation('::1')).toEqual({
country: 'Local',
city: 'Local',
});
expect(radarService.parseIpLocation('localhost')).toEqual({
country: 'Local',
city: 'Local',
});
});
it('局域网私有 IP (192.168.x / 10.x / 172.16.x) 应解析为 Local', () => {
expect(radarService.parseIpLocation('192.168.1.100')).toEqual({
country: 'Local',
city: 'Local',
});
expect(radarService.parseIpLocation('10.0.8.1')).toEqual({
country: 'Local',
city: 'Local',
});
});
it('请求头携带 CDN 地理位置时 (cf-ipcountry / x-country) 应优先提取', () => {
const headers = { 'cf-ipcountry': 'AE', 'cf-ipcity': 'Dubai' };
const loc = radarService.parseIpLocation('185.220.101.5', headers);
expect(loc.country).toBe('AE');
expect(loc.city).toBe('Dubai');
});
it('未知公网 IP 且无地理请求头时应安全兜底为 Unknown', () => {
const loc = radarService.parseIpLocation('203.0.113.195');
expect(loc.country).toBe('Unknown');
expect(loc.city).toBe('Unknown');
});
});
// ==========================================
// 3. 访客轨迹埋点记录测试 (recordTrack)
// ==========================================
describe('3. 访客轨迹埋点记录服务 (recordTrack)', () => {
let radarService: CrmRadarService;
let savedTracks: CrmTrackEntity[] = [];
let idCounter = 1;
beforeEach(() => {
savedTracks = [];
idCounter = 1;
const mockRepo: any = {
save: jest.fn().mockImplementation(async (item: any) => {
if (!item.id) {
item.id = idCounter++;
item.createTime = new Date();
savedTracks.push(item);
} else {
const idx = savedTracks.findIndex(t => t.id === item.id);
if (idx >= 0) {
savedTracks[idx] = { ...savedTracks[idx], ...item };
} else {
savedTracks.push(item);
}
}
return { ...item };
}),
find: jest.fn().mockImplementation(async (options: any) => {
let list = [...savedTracks];
if (options?.where?.cardId) {
list = list.filter(t => t.cardId === Number(options.where.cardId));
}
if (options?.order?.id === 'DESC' || options?.order?.createTime === 'DESC') {
list.sort((a, b) => Number(b.id) - Number(a.id));
}
if (options?.take) {
list = list.slice(0, options.take);
}
return list;
}),
};
radarService = new CrmRadarService();
radarService.crmTrackEntity = mockRepo;
});
it('缺少 cardId 时应抛出 400 异常', async () => {
await expect(
radarService.recordTrack({ touchType: 'link' } as any)
).rejects.toThrow(CoolCommException);
});
it('正常埋点记录应成功保存并补全默认值与指纹', async () => {
const result = await radarService.recordTrack({
cardId: 101,
touchType: 'qr',
stayDurationMs: 45000,
scrollDepth: 75,
viewedBlocks: ['hero', 'cases', 'social'],
ip: '127.0.0.1',
});
expect(result.id).toBeDefined();
expect(result.cardId).toBe(101);
expect(result.touchType).toBe('qr');
expect(result.stayDurationMs).toBe(45000);
expect(result.scrollDepth).toBe(75);
expect(result.viewedBlocks).toEqual(['hero', 'cases', 'social']);
expect(result.country).toBe('Local');
expect(result.city).toBe('Local');
expect(result.visitorId).toBeTruthy();
});
it('未指定 touchType 或非法触达渠道应兜底为 link', async () => {
const track1 = await radarService.recordTrack({
cardId: 101,
touchType: 'invalid_touch' as any,
});
expect(track1.touchType).toBe('link');
const track2 = await radarService.recordTrack({
cardId: 101,
});
expect(track2.touchType).toBe('link');
});
it('数值边界纠偏: stayDurationMs 负数纠偏为 0, scrollDepth 限制在 0~100', async () => {
const result = await radarService.recordTrack({
cardId: 101,
stayDurationMs: -500,
scrollDepth: 150,
});
expect(result.stayDurationMs).toBe(0);
expect(result.scrollDepth).toBe(100);
});
it('viewedBlocks 支持 JSON 字符串自动解析', async () => {
const result = await radarService.recordTrack({
cardId: 101,
viewedBlocks: JSON.stringify(['hero', 'contact_btn']) as any,
});
expect(result.viewedBlocks).toEqual(['hero', 'contact_btn']);
});
it('外部显式传入地理位置时应优先尊重', async () => {
const result = await radarService.recordTrack({
cardId: 101,
country: 'Singapore',
city: 'Singapore',
ip: '10.0.0.1',
});
expect(result.country).toBe('Singapore');
expect(result.city).toBe('Singapore');
});
it('高停留足迹会走雷达 Push,普通足迹不推', async () => {
const published: any[] = [];
radarService.crmRadarPushService = {
publish: (track: any) => {
published.push(track);
return track;
},
} as any;
await radarService.recordTrack({
cardId: 101,
stayDurationMs: 5000,
viewedBlocks: ['hero'],
});
await radarService.recordTrack({
cardId: 101,
stayDurationMs: 180000,
viewedBlocks: ['hero'],
});
expect(published).toHaveLength(1);
expect(published[0].stayDurationMs).toBe(180000);
});
});
describe('P2 高意向雷达收件箱', () => {
it('hotInbox 只统计当前用户名片的高意向足迹', async () => {
const radarService = new CrmRadarService();
radarService.cardInfoEntity = {
find: jest.fn(async () => [{ id: 101, userId: 11 }]),
} as any;
radarService.crmTrackEntity = {
find: jest.fn(async () => [
{ id: 1, cardId: 101, stayDurationMs: 180000, viewedBlocks: [] },
{ id: 2, cardId: 101, stayDurationMs: 1000, viewedBlocks: ['hero'] },
{ id: 3, cardId: 202, stayDurationMs: 200000, viewedBlocks: [] },
]),
} as any;
const inbox = await radarService.hotInbox(11);
expect(inbox.unread).toBe(1);
expect(inbox.list[0].cardId).toBe(101);
});
});
// ==========================================
// R25. 语言占比 / 设备占比诚实聚合
// ==========================================
describe('R25. 语言占比与设备占比真实计数', () => {
let radarService: CrmRadarService;
let trackList: any[];
beforeEach(() => {
trackList = [];
radarService = new CrmRadarService();
radarService.crmTrackEntity = {
find: jest.fn(async (options?: any) => {
let list = [...trackList];
if (options?.where?.cardId !== undefined) {
list = list.filter((t) => Number(t.cardId) === Number(options.where.cardId));
}
return list;
}),
} as any;
});
it('无数据时 getLanguageStats / getDeviceStats 返回 [],analytics 同步为空', async () => {
const analytics = await radarService.getAnalytics();
expect(await radarService.getLanguageStats()).toEqual([]);
expect(await radarService.getDeviceStats()).toEqual([]);
expect(analytics.languageStats).toEqual([]);
expect(analytics.deviceStats).toEqual([]);
});
it('设备占比来自 touchType 计数,空足迹不返回固定 0% 渠道行', async () => {
expect(await radarService.getDeviceStats()).toEqual([]);
trackList = [
{ id: 1, touchType: 'qr' },
{ id: 2, touchType: 'qr' },
{ id: 3, touchType: 'link' },
];
const devices = await radarService.getDeviceStats();
expect(devices.find((d) => d.type === 'qr')?.percentage).toBeCloseTo(66.7, 0);
expect(devices.find((d) => d.type === 'link')?.percentage).toBeCloseTo(33.3, 0);
expect(devices.find((d) => d.type === 'nfc')).toBeUndefined();
});
});
// ==========================================
// 4. 最近足迹流水查询测试 (getRecentTracks)
// ==========================================
describe('4. 最近足迹流水查询 (getRecentTracks)', () => {
let radarService: CrmRadarService;
let savedTracks: CrmTrackEntity[] = [];
beforeEach(async () => {
savedTracks = [];
const mockRepo: any = {
save: jest.fn().mockImplementation(async (item: any) => {
savedTracks.push(item);
return item;
}),
find: jest.fn().mockImplementation(async (options: any) => {
let list = [...savedTracks];
if (options?.where?.cardId) {
list = list.filter(t => t.cardId === Number(options.where.cardId));
}
if (options?.order?.id === 'DESC' || options?.order?.createTime === 'DESC') {
list.sort((a, b) => Number(b.id) - Number(a.id));
}
if (options?.take) {
list = list.slice(0, options.take);
}
return list;
}),
};
radarService = new CrmRadarService();
radarService.crmTrackEntity = mockRepo;
// 预先存入 5 条足迹
for (let i = 1; i <= 5; i++) {
const t = new CrmTrackEntity();
t.id = i;
t.cardId = 101;
t.visitorId = `vis_${i}`;
t.touchType = 'nfc';
t.createTime = new Date(`2026-09-20T10:0${i}:00Z`);
savedTracks.push(t);
}
});
it('缺少 cardId 时应抛出 400 异常', async () => {
await expect(radarService.getRecentTracks(null as any)).rejects.toThrow(
CoolCommException
);
});
it('应按倒序返回指定名片的最近足迹列表', async () => {
const list = await radarService.getRecentTracks(101, 3);
expect(list.length).toBe(3);
expect(list[0].id).toBe(5);
expect(list[1].id).toBe(4);
expect(list[2].id).toBe(3);
});
it('limit 超限或非正整数时应自动修正约束在 1~100 区间', async () => {
const list1 = await radarService.getRecentTracks(101, 0);
expect(list1.length).toBe(1);
const list2 = await radarService.getRecentTracks(101, 500);
expect(list2.length).toBe(5);
});
});
// ==========================================
// 5. 公开免登录埋点控制器测试 (OpenCrmRadarController)
// ==========================================
describe('5. 公开免登录埋点控制器 (OpenCrmRadarController)', () => {
let controller: OpenCrmRadarController;
let mockRadarService: any;
beforeEach(() => {
mockRadarService = {
recordTrack: jest.fn().mockResolvedValue({
id: 999,
cardId: 101,
visitorId: 'vis_test',
country: 'Local',
city: 'Local',
}),
getRecentTracks: jest.fn().mockResolvedValue([
{ id: 999, cardId: 101 },
]),
};
controller = new OpenCrmRadarController();
controller.crmRadarService = mockRadarService;
(controller as any).ctx = {
ip: '127.0.0.1',
headers: {},
};
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
(controller as any).fail = (msg: string, code = 1001) => ({ code, message: msg });
});
it('POST /open/crm/radar/track 正常埋点返回成功响应 (code: 1000)', async () => {
const res = await controller.track({
cardId: 101,
touchType: 'nfc',
});
expect(res.code).toBe(1000);
expect((res as any).data.id).toBe(999);
expect(mockRadarService.recordTrack).toHaveBeenCalled();
});
it('埋点服务抛出异常时应捕获并返回 fail 响应', async () => {
mockRadarService.recordTrack.mockRejectedValueOnce(
new CoolCommException('缺少名片标识 cardId', 400)
);
const res = await controller.track({} as any);
expect(res.code).toBe(400);
expect(res.message).toContain('缺少名片标识 cardId');
});
it('GET /open/crm/radar/recent 正常查询最近足迹', async () => {
const res = await controller.recent(101, 10);
expect(res.code).toBe(1000);
expect((res as any).data.length).toBe(1);
expect(mockRadarService.getRecentTracks).toHaveBeenCalledWith(101, 10);
});
});
});
describe('雷达监控行补全', () => {
it('用线索姓名、内部用户头像和模块标题补全足迹,对不上则留空', () => {
const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar');
const now = Date.parse('2026-09-23T10:00:00.000Z');
const rows = decorateRadarTracks(
[
{
id: 8,
cardId: 12,
visitorId: 'vis_alex',
country: 'CN',
city: 'Shanghai',
ip: '8.8.8.8',
touchType: 'link',
stayDurationMs: 12000,
scrollDepth: 40,
viewedBlocks: ['2', 'hero'],
createTime: new Date(now - 30 * 1000).toISOString(),
},
{
id: 9,
cardId: 12,
visitorId: 'vis_anon',
country: 'Local',
city: 'Local',
touchType: 'qr',
stayDurationMs: 0,
scrollDepth: 0,
viewedBlocks: [],
createTime: new Date(now - 60 * 60 * 1000).toISOString(),
},
],
[
{
id: 3,
visitorId: 'vis_alex',
name: 'Alex',
company: 'Nexus',
phone: '13800000000',
},
],
[
{
phone: '13800000000',
avatarUrl: 'https://cdn.example.com/alex.png',
nickName: 'Alex Nick',
},
],
[
{ id: 2, cardId: 12, type: 'case', title: 'Middle East Hub' },
{ id: 1, cardId: 12, type: 'hero', title: 'Profile' },
],
now
);
expect(rows[0].visitorName).toBe('Alex');
expect(rows[0].visitorCompany).toBe('Nexus');
expect(rows[0].avatarUrl).toBe('https://cdn.example.com/alex.png');
expect(rows[0].online).toBe(true);
expect(rows[0].viewedContents).toEqual([
{ key: '2', title: 'Middle East Hub' },
{ key: 'hero', title: 'Profile' },
]);
expect(rows[1].visitorName).toBeUndefined();
expect(rows[1].avatarUrl).toBeUndefined();
expect(rows[1].online).toBe(false);
});
it('数据库蛇形字段也会归一成名片主可读的驼峰监控行', () => {
const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar');
const now = Date.parse('2026-09-23T10:00:00.000Z');
const rows = decorateRadarTracks(
[
{
id: 4,
card_id: '12',
visitor_id: 'vis_snake',
touch_type: 'nfc',
stay_duration_ms: 6400,
scroll_depth: 80,
viewed_blocks: ['hero'],
country: 'CN',
city: 'Shanghai',
ip: '::1',
create_time: '2026-09-23 17:59:00',
},
],
[],
[],
[],
now
);
expect(rows[0].visitorId).toBe('vis_snake');
expect(rows[0].cardId).toBe('12');
expect(rows[0].touchType).toBe('nfc');
expect(rows[0].stayDurationMs).toBe(6400);
expect(rows[0].scrollDepth).toBe(80);
expect(rows[0].viewedBlocks).toEqual(['hero']);
expect(String(rows[0].createdAt)).toContain('2026-09-23');
});
});