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

272 行
8.9 KiB
TypeScript

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

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

import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { CurdOption } from '@cool-midway/core';
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
import { CrmLeadService } from '../../../src/modules/crm/service/lead';
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
import { CoolCommException } from '@cool-midway/core';
/**
* CoolController 元数据(脚本路径解析为异步)
*/
interface CoolControllerMeta {
prefix?: string;
curdOption?: CurdOption;
}
/**
* 等待 CoolController 将 CRUD 配置写入类元数据
*/
async function waitCoolMeta(
target: new (...args: never[]) => unknown
): Promise<CoolControllerMeta> {
const started = Date.now();
while (Date.now() - started < 2000) {
const meta = getClassMetadata(CONTROLLER_KEY, target) as
| CoolControllerMeta
| undefined;
if (meta?.curdOption) {
return meta;
}
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
}
function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] {
return apis.map(api => (typeof api === 'string' ? api : api.method));
}
describe('CRM 管理后台 CRUD 控制器 (admin/crm)', () => {
describe('AdminCrmLeadController /admin/crm/lead', () => {
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminCrmLeadController);
option = meta.curdOption as CurdOption;
});
it('路由前缀应为 /admin/crm/lead', () => {
expect(meta.prefix).toBe('/admin/crm/lead');
});
it('应开放完整 CRUD:add / delete / update / info / list / page', () => {
expect(option.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('应绑定线索实体与线索服务', () => {
expect(option.entity).toBe(CrmLeadEntity);
expect(option.service).toBe(CrmLeadService);
});
it('分页查询应支持姓名/公司/邮箱/电话模糊检索', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.keyWordLikeFields).toEqual([
'a.name',
'a.company',
'a.email',
'a.phone',
]);
});
it('分页查询应支持状态/来源/名片精确筛选,并按创建时间倒序', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.fieldEq).toEqual([
'a.status',
'a.source',
{ column: 'a.card_id', requestParam: 'cardId' },
]);
expect(pageQueryOp.addOrderBy).toEqual({ create_time: 'desc' });
});
it('应将 updateStatus 注册为 serviceApi(线索状态流转)', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toContain('updateStatus');
expect(methods).toContain('batchExport');
const named = (option.serviceApis || []).find(
api => typeof api !== 'string' && api.method === 'updateStatus'
) as { method: string; summary: string } | undefined;
expect(named?.summary).toBe('线索状态流转');
});
});
describe('AdminCrmRadarController /admin/crm/radar', () => {
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminCrmRadarController);
option = meta.curdOption as CurdOption;
});
it('路由前缀应为 /admin/crm/radar', () => {
expect(meta.prefix).toBe('/admin/crm/radar');
});
it('足迹只读管理:开放 info / list / page / delete,不含 add', () => {
expect(option.api).toEqual(['info', 'list', 'page', 'delete']);
expect(option.api).not.toContain('add');
});
it('应绑定足迹实体与雷达服务', () => {
expect(option.entity).toBe(CrmTrackEntity);
expect(option.service).toBe(CrmRadarService);
});
it('分页查询应支持国家/城市/访客/IP 模糊检索', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: unknown[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.keyWordLikeFields).toEqual([
'a.country',
'a.city',
'a.visitor_id',
'a.ip',
]);
});
it('分页查询应支持名片/触达方式/国家精确筛选,并按 id 倒序', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: unknown[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.fieldEq).toEqual([
{ column: 'a.card_id', requestParam: 'cardId' },
{ column: 'a.touch_type', requestParam: 'touchType' },
'a.country',
]);
expect(pageQueryOp.addOrderBy).toEqual({ id: 'desc' });
});
it('应将 getRecentTracks 注册为 serviceApi(最近足迹)', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toContain('getRecentTracks');
const named = (option.serviceApis || []).find(
api => typeof api !== 'string' && api.method === 'getRecentTracks'
) as { method: string; summary: string } | undefined;
expect(named?.summary).toBe('最近足迹');
});
});
describe('serviceApis POST body 单参数对象转调', () => {
it('updateStatus 接收 { id, status } 时应转调已有双参签名', async () => {
const saved: CrmLeadEntity[] = [];
const leadService = new CrmLeadService();
leadService.crmLeadEntity = {
save: jest.fn(async (item: CrmLeadEntity) => {
if (!item.id) {
item.id = 1;
}
const idx = saved.findIndex(row => row.id === item.id);
if (idx >= 0) {
saved[idx] = item;
} else {
saved.push(item);
}
return item;
}),
findOne: jest.fn(async (options: { where?: { id?: number } }) => {
const id = options?.where?.id;
return saved.find(row => row.id === Number(id)) || null;
}),
} as unknown as CrmLeadService['crmLeadEntity'];
const created = await leadService.submitLead({
cardId: 101,
name: 'Admin Flow',
email: 'admin@example.com',
});
expect(created.status).toBe('new');
const updated = await leadService.updateStatus({
id: Number(created.id),
status: 'contacted',
});
expect(updated.status).toBe('contacted');
});
it('getRecentTracks 接收 { cardId, limit } 时应转调已有双参签名', async () => {
const saved: CrmTrackEntity[] = [];
for (let i = 1; i <= 4; i++) {
const track = new CrmTrackEntity();
track.id = i;
track.cardId = 202;
track.visitorId = `vis_${i}`;
track.touchType = 'link';
saved.push(track);
}
const radarService = new CrmRadarService();
radarService.crmTrackEntity = {
find: jest.fn(
async (options: {
where?: { cardId?: number };
order?: { id?: string };
take?: number;
}) => {
let list = saved.filter(
row => row.cardId === Number(options?.where?.cardId)
);
if (options?.order?.id === 'DESC') {
list = [...list].sort((a, b) => Number(b.id) - Number(a.id));
}
if (options?.take) {
list = list.slice(0, options.take);
}
return list;
}
),
} as unknown as CrmRadarService['crmTrackEntity'];
const list = await radarService.getRecentTracks({
cardId: 202,
limit: 2,
});
expect(list).toHaveLength(2);
expect(list[0].id).toBe(4);
expect(list[1].id).toBe(3);
});
it('updateStatus 旧双参签名仍可用,非法状态仍拒绝', async () => {
const leadService = new CrmLeadService();
leadService.crmLeadEntity = {
save: jest.fn(async (item: CrmLeadEntity) => item),
findOne: jest.fn(async () => {
const lead = new CrmLeadEntity();
lead.id = 7;
lead.status = 'new';
return lead;
}),
} as unknown as CrmLeadService['crmLeadEntity'];
const updated = await leadService.updateStatus(7, 'won');
expect(updated.status).toBe('won');
await expect(
leadService.updateStatus(7, 'invalid' as 'new')
).rejects.toThrow(CoolCommException);
});
});
});