文件
OmniCard/backend/test/modules/crm/lead.test.ts

337 行
12 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import { getMetadataArgsStorage } from 'typeorm';
import { BaseEntity } from '../../../src/comm/entity/base';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { CrmLeadService } from '../../../src/modules/crm/service/lead';
import { OpenCrmLeadController } from '../../../src/modules/crm/controller/open/lead';
import { LeadEntity, LeadStatus } from '../../../src/comm/types/omni';
import { CoolCommException } from '@cool-midway/core';
describe('Task 12: 后端潜客线索模块 (modules/crm) 单元测试', () => {
const metadata = getMetadataArgsStorage();
// ==========================================
// 1. 实体与契约测试 (Lead Entity & BaseEntity)
// ==========================================
describe('1. 潜客线索实体定义与数据库映射规范 (CrmLeadEntity)', () => {
it('实体表名必须映射为 crm_lead', () => {
const table = metadata.tables.find(t => t.target === CrmLeadEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('crm_lead');
});
it('实体必须继承自 BaseEntity,具备底层租户隔离与审计能力', () => {
expect(CrmLeadEntity.prototype instanceof BaseEntity).toBe(true);
});
it('所有实体属性与数据库列必须遵循驼峰至下划线映射', () => {
const columns = metadata.columns.filter(c => c.target === CrmLeadEntity);
const expectedColumnMappings: Record<string, string> = {
cardId: 'card_id',
visitorId: 'visitor_id',
name: 'name',
company: 'company',
phone: 'phone',
email: 'email',
status: 'status',
intentScore: 'intent_score',
source: 'source',
notes: 'notes',
};
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, status 应声明索引', () => {
const indexes = metadata.indices.filter(i => i.target === CrmLeadEntity);
const indexProps = indexes.flatMap(i =>
Array.isArray(i.columns) ? i.columns : []
);
expect(indexProps).toContain('cardId');
expect(indexProps).toContain('visitorId');
expect(indexProps).toContain('status');
});
it('默认值应符合业务规范: status=\'new\', intentScore=0, source=\'link\'', () => {
const columns = metadata.columns.filter(c => c.target === CrmLeadEntity);
const statusCol = columns.find(c => c.propertyName === 'status');
expect(statusCol?.options.default).toBe('new');
const scoreCol = columns.find(c => c.propertyName === 'intentScore');
expect(scoreCol?.options.default).toBe(0);
const sourceCol = columns.find(c => c.propertyName === 'source');
expect(sourceCol?.options.default).toBe('link');
});
it('CrmLeadEntity 实例应完全符合 LeadEntity 契约结构', () => {
const lead = new CrmLeadEntity();
lead.id = 1;
lead.tenantId = 10;
lead.cardId = 101;
lead.visitorId = 'vis_xyz_789';
lead.name = 'John Doe';
lead.company = 'Global Tech FZE';
lead.phone = '+971-50-1234567';
lead.email = 'john@globaltech.ae';
lead.status = 'new';
lead.intentScore = 85;
lead.source = 'nfc';
lead.notes = '在迪拜 GITEX 展会展台触碰 NFC 卡留存';
const contract: LeadEntity = lead;
expect(contract.name).toBe('John Doe');
expect(contract.status).toBe('new');
expect(contract.intentScore).toBe(85);
expect(contract.source).toBe('nfc');
});
});
// ==========================================
// 2. 意向度评分计算引擎测试
// ==========================================
describe('2. 智能意向度评分计算引擎 (calculateIntentScore)', () => {
let leadService: CrmLeadService;
beforeEach(() => {
leadService = new CrmLeadService();
});
it('零行为指标时应输出 0 分且判定等级为 low', () => {
const result = leadService.calculateIntentScore(0, 0, 0);
expect(result.score).toBe(0);
expect(result.level).toBe('low');
});
it('达到满额指标(60秒停留、100% 滚动、5 个模块以上)应获得满分 100 且判定等级为 hot', () => {
const result = leadService.calculateIntentScore(60000, 100, 5);
expect(result.score).toBe(100);
expect(result.level).toBe('hot');
});
it('超额指标(如 120 秒、超界滚动深度等)得分上限应严格限制在 100 分', () => {
const result = leadService.calculateIntentScore(120000, 150, 10);
expect(result.score).toBe(100);
expect(result.level).toBe('hot');
});
it('中等行为指标应正确加权计算并在 40~74 区间内判定等级为 medium', () => {
// 停留 30 秒 => 20分,滚动 50% => 15分,浏览 3 个块 => 18分,总计 53分
const result = leadService.calculateIntentScore(30000, 50, 3);
expect(result.score).toBe(53);
expect(result.level).toBe('medium');
});
it('负数或异常参数输入应有防御性兜底,最小分为 0', () => {
const result = leadService.calculateIntentScore(-1000, -20, -5);
expect(result.score).toBe(0);
expect(result.level).toBe('low');
});
it('临界阈值判定: 75 分为 hot 分界线,40 分为 medium 分界线', () => {
// 刚好 75 分 => hot
// 停留 45 秒 (30分) + 滚动 70% (21分) + 浏览 4 块 (24分) = 75分
const hotResult = leadService.calculateIntentScore(45000, 70, 4);
expect(hotResult.score).toBe(75);
expect(hotResult.level).toBe('hot');
// 停留 39 分 => low
// 停留 30 秒 (20分) + 滚动 30% (9分) + 浏览 1 块 (6分) = 35分
const lowResult = leadService.calculateIntentScore(30000, 30, 1);
expect(lowResult.score).toBe(35);
expect(lowResult.level).toBe('low');
});
});
// ==========================================
// 3. 线索留资沉淀与流转测试 (submitLead & updateStatus)
// ==========================================
describe('3. 线索留资沉淀与状态流转业务逻辑 (submitLead & updateStatus)', () => {
let leadService: CrmLeadService;
let savedLeads: CrmLeadEntity[] = [];
let idCounter = 1;
beforeEach(() => {
savedLeads = [];
idCounter = 1;
const mockRepo: any = {
save: jest.fn().mockImplementation(async (item: any) => {
if (!item.id) {
item.id = idCounter++;
savedLeads.push(item);
} else {
const idx = savedLeads.findIndex(l => l.id === item.id);
if (idx >= 0) {
savedLeads[idx] = { ...savedLeads[idx], ...item };
} else {
savedLeads.push(item);
}
}
return { ...item };
}),
findOne: jest.fn().mockImplementation(async (options: any) => {
const where = options?.where;
if (!where) return null;
return savedLeads.find(l => l.id === Number(where.id)) || null;
}),
};
leadService = new CrmLeadService();
leadService.crmLeadEntity = mockRepo;
});
describe('3.1 submitLead 留资校验与沉淀', () => {
it('缺少 cardId 时应拒绝并抛出 400 异常', async () => {
await expect(
leadService.submitLead({ name: 'Alex', phone: '13800138000' } as any)
).rejects.toThrow(CoolCommException);
});
it('缺少姓名 name 时应拒绝并抛出 400 异常', async () => {
await expect(
leadService.submitLead({ cardId: 101, phone: '13800138000' } as any)
).rejects.toThrow(CoolCommException);
});
it('phone 与 email 均为空时应拒绝并抛出 400 异常', async () => {
await expect(
leadService.submitLead({ cardId: 101, name: 'Alex' } as any)
).rejects.toThrow(/联系方式/);
});
it('提供有效 phone 时留资应成功,默认来源为 link,状态为 new', async () => {
const lead = await leadService.submitLead({
cardId: 101,
name: 'Sarah Connor',
phone: '+1-555-0199',
company: 'Cyberdyne Systems',
});
expect(lead.id).toBeDefined();
expect(lead.name).toBe('Sarah Connor');
expect(lead.phone).toBe('+1-555-0199');
expect(lead.status).toBe('new');
expect(lead.source).toBe('link');
});
it('提供有效 email 且携带行为数据时,应自动计算意向度评分', async () => {
const lead = await leadService.submitLead({
cardId: 101,
name: 'David Miller',
email: 'david@miller.org',
stayDurationMs: 60000,
scrollDepth: 100,
blockCount: 5,
});
expect(lead.intentScore).toBe(100);
// 满分高意向自动将初始状态升级为 'hot'
expect(lead.status).toBe('hot');
});
it('外部显式指定 status 或 intentScore 时以传入数据为准', async () => {
const lead = await leadService.submitLead({
cardId: 101,
name: 'Emma Watson',
email: 'emma@watson.com',
status: 'contacted',
intentScore: 60,
source: 'nfc',
});
expect(lead.status).toBe('contacted');
expect(lead.intentScore).toBe(60);
expect(lead.source).toBe('nfc');
});
});
describe('3.2 updateStatus 线索状态流转', () => {
it('更新不存在的线索 ID 时应抛出 404 异常', async () => {
await expect(leadService.updateStatus(9999, 'contacted')).rejects.toThrow(
CoolCommException
);
});
it('更新为非法状态值时应抛出 400 参数异常', async () => {
const created = await leadService.submitLead({
cardId: 101,
name: 'Test Lead',
phone: '13800000000',
});
await expect(
leadService.updateStatus(created.id, 'invalid_status' as any)
).rejects.toThrow(/非法状态/);
});
it('正常流转状态并成功更新保存 (new -> contacted -> won)', async () => {
const created = await leadService.submitLead({
cardId: 101,
name: 'Enterprise Buyer',
email: 'buyer@corp.com',
});
expect(created.status).toBe('new');
const contacted = await leadService.updateStatus(created.id, 'contacted');
expect(contacted.status).toBe('contacted');
const won = await leadService.updateStatus(created.id, 'won');
expect(won.status).toBe('won');
});
});
});
// ==========================================
// 4. 控制器接口测试 (OpenCrmLeadController)
// ==========================================
describe('4. 公开免登录留资互换接口 (OpenCrmLeadController)', () => {
let controller: OpenCrmLeadController;
let leadService: CrmLeadService;
beforeEach(() => {
leadService = new CrmLeadService();
leadService.crmLeadEntity = {
save: jest.fn().mockImplementation(async item => ({
id: 88,
...item,
})),
findOne: jest.fn(),
} as any;
controller = new OpenCrmLeadController();
controller.crmLeadService = leadService;
});
it('POST /open/crm/lead/exchange 正常留资返回成功响应 (code: 1000)', async () => {
const response = await controller.exchange({
cardId: 101,
name: 'Michael Scott',
phone: '+1-202-555-0143',
company: 'Dunder Mifflin',
source: 'qr',
});
expect(response.code).toBe(1000);
expect(response.message).toBe('success');
expect((response as any).data.id).toBe(88);
expect((response as any).data.name).toBe('Michael Scott');
expect((response as any).data.source).toBe('qr');
});
it('缺少必要留资参数时应返回失败响应 (非 1000)', async () => {
const response = await controller.exchange({
cardId: 101,
// 缺少 name 和 联系方式
} as any);
expect(response.code).not.toBe(1000);
});
});
});