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 { 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(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; }; expect(pageQueryOp.keyWordLikeFields).toEqual([ 'a.name', 'a.company', 'a.email', 'a.phone', ]); }); it('分页查询应支持状态/来源/名片精确筛选,并按创建时间倒序', () => { const pageQueryOp = option.pageQueryOp as { keyWordLikeFields?: string[]; fieldEq?: string[]; addOrderBy?: Record; }; 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; }; 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; }; 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); }); }); });