补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
262 行
9.0 KiB
TypeScript
262 行
9.0 KiB
TypeScript
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
|
|
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
|
|
import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar';
|
|
|
|
describe('Point 09: 全球访客雷达热力分布与大屏服务 (radar-map.test.ts)', () => {
|
|
let radarService: CrmRadarService;
|
|
let trackList: CrmTrackEntity[];
|
|
|
|
beforeEach(() => {
|
|
trackList = [];
|
|
|
|
// Mock TypeORM Repository
|
|
const mockRepo: any = {
|
|
find: jest.fn().mockImplementation(async (options?: any) => {
|
|
let list = [...trackList];
|
|
if (options?.where) {
|
|
const where = options.where;
|
|
if (where.cardId !== undefined && where.cardId !== null) {
|
|
list = list.filter((item) => Number(item.cardId) === Number(where.cardId));
|
|
}
|
|
if (where.createTime && typeof where.createTime === 'object') {
|
|
// 支持 TypeORM MoreThanOrEqual / Between 等操作符过滤
|
|
if (where.createTime._type === 'moreThanOrEqual' || where.createTime._value) {
|
|
const minTime = where.createTime._value || where.createTime;
|
|
list = list.filter((item) => new Date(item.createTime) >= new Date(minTime));
|
|
}
|
|
}
|
|
}
|
|
return list;
|
|
}),
|
|
createQueryBuilder: jest.fn().mockReturnValue(null),
|
|
};
|
|
|
|
radarService = new CrmRadarService();
|
|
radarService.crmTrackEntity = mockRepo;
|
|
});
|
|
|
|
describe('1. 国家分布统计 (getCountryDistribution)', () => {
|
|
it('空数据时应安全兜底返回空数组 []', async () => {
|
|
const result = await radarService.getCountryDistribution();
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('应准确按国家聚合访客数、计算占比与平均停留时长,并按人次降序排列', async () => {
|
|
trackList = [
|
|
{
|
|
id: 1,
|
|
cardId: 10,
|
|
country: 'United States',
|
|
stayDurationMs: 30000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
{
|
|
id: 2,
|
|
cardId: 10,
|
|
country: 'United States',
|
|
stayDurationMs: 10000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
{
|
|
id: 3,
|
|
cardId: 10,
|
|
country: 'Singapore',
|
|
stayDurationMs: 20000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
{
|
|
id: 4,
|
|
cardId: 10,
|
|
country: 'Germany',
|
|
stayDurationMs: 40000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
];
|
|
|
|
const result = await radarService.getCountryDistribution();
|
|
|
|
expect(result.length).toBe(3);
|
|
// US 有 2 次访问 (50%), Singapore 1 次 (25%), Germany 1 次 (25%)
|
|
expect(result[0].country).toBe('United States');
|
|
expect(result[0].visitorCount).toBe(2);
|
|
expect(result[0].percentage).toBe(50);
|
|
expect(result[0].avgStayDurationMs).toBe(20000);
|
|
|
|
expect(result[1].visitorCount).toBe(1);
|
|
expect(result[1].percentage).toBe(25);
|
|
});
|
|
|
|
it('当国家字段为空或缺失时,应统一兜底为 Unknown', async () => {
|
|
trackList = [
|
|
{
|
|
id: 1,
|
|
cardId: 10,
|
|
country: '',
|
|
stayDurationMs: 5000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
{
|
|
id: 2,
|
|
cardId: 10,
|
|
country: undefined,
|
|
stayDurationMs: 15000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
];
|
|
|
|
const result = await radarService.getCountryDistribution();
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].country).toBe('Unknown');
|
|
expect(result[0].visitorCount).toBe(2);
|
|
expect(result[0].percentage).toBe(100);
|
|
expect(result[0].avgStayDurationMs).toBe(10000);
|
|
});
|
|
|
|
it('应支持根据指定 cardId 过滤数据', async () => {
|
|
trackList = [
|
|
{
|
|
id: 1,
|
|
cardId: 10,
|
|
country: 'Japan',
|
|
stayDurationMs: 10000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
{
|
|
id: 2,
|
|
cardId: 20,
|
|
country: 'Japan',
|
|
stayDurationMs: 20000,
|
|
createTime: new Date(),
|
|
} as CrmTrackEntity,
|
|
];
|
|
|
|
const result = await radarService.getCountryDistribution(10);
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].visitorCount).toBe(1);
|
|
expect(result[0].country).toBe('Japan');
|
|
});
|
|
});
|
|
|
|
describe('2. 停留时长时段分布 (getHourlyStayStats)', () => {
|
|
it('返回结果必须严格包含 0 至 23 共 24 个时段,空时段以 0 填充兜底', async () => {
|
|
const stats = await radarService.getHourlyStayStats(7);
|
|
expect(stats.length).toBe(24);
|
|
for (let h = 0; h < 24; h++) {
|
|
expect(stats[h].hour).toBe(h);
|
|
expect(stats[h].visitorCount).toBe(0);
|
|
expect(stats[h].avgStayDurationMs).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('应能正确将足迹按创建时间的小时进行归类与停留时长均值统计', async () => {
|
|
const now = new Date();
|
|
const dateHour14 = new Date(now);
|
|
dateHour14.setHours(14, 30, 0, 0);
|
|
|
|
const dateHour14_2 = new Date(now);
|
|
dateHour14_2.setHours(14, 45, 0, 0);
|
|
|
|
trackList = [
|
|
{
|
|
id: 1,
|
|
cardId: 10,
|
|
stayDurationMs: 10000,
|
|
createTime: dateHour14,
|
|
} as CrmTrackEntity,
|
|
{
|
|
id: 2,
|
|
cardId: 10,
|
|
stayDurationMs: 30000,
|
|
createTime: dateHour14_2,
|
|
} as CrmTrackEntity,
|
|
];
|
|
|
|
const stats = await radarService.getHourlyStayStats(7);
|
|
expect(stats.length).toBe(24);
|
|
expect(stats[14].visitorCount).toBe(2);
|
|
expect(stats[14].avgStayDurationMs).toBe(20000);
|
|
expect(stats[0].visitorCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('3. 终端渠道分析 (getDeviceStats)', () => {
|
|
it('空数据时应安全返回空数组 []', async () => {
|
|
const result = await radarService.getDeviceStats();
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('应准确按 touchType (nfc, qr, link) 计算数量与占比', async () => {
|
|
trackList = [
|
|
{ id: 1, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity,
|
|
{ id: 2, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity,
|
|
{ id: 3, touchType: 'qr', createTime: new Date() } as CrmTrackEntity,
|
|
{ id: 4, touchType: 'link', createTime: new Date() } as CrmTrackEntity,
|
|
];
|
|
|
|
const result = await radarService.getDeviceStats();
|
|
const nfcItem = result.find((item) => item.type === 'nfc');
|
|
const qrItem = result.find((item) => item.type === 'qr');
|
|
const linkItem = result.find((item) => item.type === 'link');
|
|
|
|
expect(nfcItem?.count).toBe(2);
|
|
expect(nfcItem?.percentage).toBe(50);
|
|
expect(qrItem?.count).toBe(1);
|
|
expect(qrItem?.percentage).toBe(25);
|
|
expect(linkItem?.count).toBe(1);
|
|
expect(linkItem?.percentage).toBe(25);
|
|
});
|
|
});
|
|
|
|
describe('4. 控制器接口契约 (AdminCrmRadarController)', () => {
|
|
let controller: AdminCrmRadarController;
|
|
let mockService: any;
|
|
|
|
beforeEach(() => {
|
|
mockService = {
|
|
getCountryDistribution: jest.fn().mockResolvedValue([
|
|
{ country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 },
|
|
]),
|
|
getHourlyStayStats: jest.fn().mockResolvedValue([]),
|
|
getDeviceStats: jest.fn().mockResolvedValue([]),
|
|
getAnalytics: jest.fn().mockResolvedValue({
|
|
countryDistribution: [
|
|
{ country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 },
|
|
],
|
|
hourlyStats: [],
|
|
deviceStats: [],
|
|
summary: {
|
|
totalVisitors: 10,
|
|
avgStayDurationMs: 15000,
|
|
topCountry: 'Singapore',
|
|
countriesCount: 1,
|
|
},
|
|
}),
|
|
};
|
|
|
|
controller = new AdminCrmRadarController();
|
|
(controller as any).crmRadarService = mockService;
|
|
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
|
|
(controller as any).fail = (msg: string, code = 1001) => ({ code, message: msg });
|
|
});
|
|
|
|
it('GET /admin/crm/radar/distribution 应返回标准 1000 成功响应与国家分布列表', async () => {
|
|
const res: any = await controller.distribution({ cardId: 10 });
|
|
expect(res.code).toBe(1000);
|
|
expect(res.data[0].country).toBe('Singapore');
|
|
expect(mockService.getCountryDistribution).toHaveBeenCalledWith(10);
|
|
});
|
|
|
|
it('GET/POST /admin/crm/radar/analytics 应聚合返回大屏综合数据与 KPI 汇总', async () => {
|
|
const res: any = await controller.analytics({ cardId: 10, days: 7 });
|
|
expect(res.code).toBe(1000);
|
|
expect(res.data.countryDistribution).toBeDefined();
|
|
expect(res.data.hourlyStats).toBeDefined();
|
|
expect(res.data.deviceStats).toBeDefined();
|
|
expect(res.data.summary).toBeDefined();
|
|
expect(res.data.summary.totalVisitors).toBe(10);
|
|
expect(res.data.summary.topCountry).toBe('Singapore');
|
|
});
|
|
});
|
|
});
|