import * as fs from 'fs'; import * as path from 'path'; import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core'; import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core'; import { getMetadataArgsStorage, Repository } from 'typeorm'; import { BaseEntity } from '../../../src/comm/entity/base'; import { AiConciergeEntity } from '../../../src/modules/ai/entity/concierge'; import { AiConciergeService } from '../../../src/modules/ai/service/concierge'; import { AdminAiConciergeController, adminAiConciergeCurdOption, } from '../../../src/modules/ai/controller/admin/concierge'; import { AiConciergeConfig, ConciergePersonaType } from '../../../src/comm/types/omni'; interface CoolControllerMeta { prefix?: string; curdOption?: CurdOption; } 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('Point 05: 后台名片 AI 顾问人设与意向留资策略配置', () => { describe('1. 实体定义与表结构 (AiConciergeEntity)', () => { const metadata = getMetadataArgsStorage(); it('实体表名必须为 ai_concierge 并继承 BaseEntity', () => { const table = metadata.tables.find(t => t.target === AiConciergeEntity); expect(table).toBeDefined(); expect(table?.name).toBe('ai_concierge'); expect(AiConciergeEntity.prototype).toBeInstanceOf(BaseEntity); }); it('数据库列名必须严格采用下划线命名', () => { const columns = metadata.columns.filter(c => c.target === AiConciergeEntity); const expectedColumnMappings: Record = { cardId: 'card_id', personaType: 'persona_type', welcomeMsg: 'welcome_msg', triggerLeadThreshold: 'trigger_lead_threshold', promptTemplate: 'prompt_template', toneStyle: 'tone_style', isEnabled: 'is_enabled', }; 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 字段必须声明索引', () => { const indexes = metadata.indices.filter(i => i.target === AiConciergeEntity); const cardIndex = indexes.find(i => { if (Array.isArray(i.columns)) { return i.columns.includes('cardId') || i.columns.includes('card_id'); } return false; }); expect(cardIndex).toBeDefined(); }); it('实体实例能赋值并满足 AiConciergeConfig 契约', () => { const entity = new AiConciergeEntity(); entity.id = 1; entity.cardId = 101; entity.personaType = 'trade_expert'; entity.welcomeMsg = 'Hello! How can I assist your business?'; entity.triggerLeadThreshold = 75; entity.promptTemplate = 'You are an export trade consultant.'; entity.toneStyle = 'professional'; entity.isEnabled = true; const config: AiConciergeConfig = entity; expect(config.cardId).toBe(101); expect(config.personaType).toBe('trade_expert'); expect(config.triggerLeadThreshold).toBe(75); expect(config.isEnabled).toBe(true); }); }); describe('2. 服务逻辑与校验 (AiConciergeService)', () => { let service: AiConciergeService; let mockRepo: Partial>; let savedStore: Map; beforeEach(() => { savedStore = new Map(); mockRepo = { findOneBy: jest.fn(async (where: { cardId?: number }) => { if (!where.cardId) return null; return savedStore.get(where.cardId) || null; }), save: jest.fn(async (entity: any) => { const toSave = { ...entity, id: entity.id || Date.now() }; savedStore.set(Number(toSave.cardId), toSave); return toSave; }), }; service = new AiConciergeService(); (service as any).aiConciergeEntity = mockRepo; }); it('getDefaultPersona 应支持 trade_expert / business_rep / tech_advisor 并返回预设配置', () => { const expert = service.getDefaultPersona('trade_expert'); expect(expert.personaType).toBe('trade_expert'); expect(expert.welcomeMsg.length).toBeGreaterThan(0); expect(expert.promptTemplate.length).toBeGreaterThan(0); expect(expert.toneStyle).toBe('professional'); expect(expert.triggerLeadThreshold).toBeGreaterThanOrEqual(50); expect(expert.triggerLeadThreshold).toBeLessThanOrEqual(90); const rep = service.getDefaultPersona('business_rep'); expect(rep.personaType).toBe('business_rep'); expect(rep.welcomeMsg.length).toBeGreaterThan(0); const tech = service.getDefaultPersona('tech_advisor'); expect(tech.personaType).toBe('tech_advisor'); expect(tech.welcomeMsg.length).toBeGreaterThan(0); }); it('validate 校验非法入参应抛出 CoolCommException', () => { // 缺少 cardId expect(() => service.validate({ welcomeMsg: 'hi', triggerLeadThreshold: 60, }) ).toThrow(CoolCommException); // welcomeMsg 为空 expect(() => service.validate({ cardId: 101, welcomeMsg: ' ', triggerLeadThreshold: 60, }) ).toThrow(CoolCommException); // triggerLeadThreshold 超出 0~100 范围 expect(() => service.validate({ cardId: 101, welcomeMsg: 'hi', triggerLeadThreshold: -5, }) ).toThrow(CoolCommException); expect(() => service.validate({ cardId: 101, welcomeMsg: 'hi', triggerLeadThreshold: 105, }) ).toThrow(CoolCommException); }); it('validate 校验合法入参应正常通过', () => { expect(() => service.validate({ cardId: 101, personaType: 'trade_expert', welcomeMsg: 'Hello', triggerLeadThreshold: 70, promptTemplate: 'Be polite.', toneStyle: 'professional', isEnabled: true, }) ).not.toThrow(); }); it('getByCardId 在无记录时返回默认人设配置,有记录时返回持久化数据', async () => { // 无记录 const defaultConfig = await service.getByCardId(888); expect(defaultConfig.cardId).toBe(888); expect(defaultConfig.personaType).toBe('trade_expert'); expect(defaultConfig.isEnabled).toBe(true); expect(defaultConfig.welcomeMsg).toBeTruthy(); // 保存记录 await service.saveByCardId(888, { welcomeMsg: 'Welcome to our international showroom.', triggerLeadThreshold: 80, }); // 再次读取 const fetched = await service.getByCardId(888); expect(fetched.welcomeMsg).toBe('Welcome to our international showroom.'); expect(fetched.triggerLeadThreshold).toBe(80); }); it('saveByCardId 更新现有记录与新增记录', async () => { const saved = await service.saveByCardId(999, { personaType: 'tech_advisor', welcomeMsg: 'Tech support online', triggerLeadThreshold: 65, promptTemplate: 'Assist developer integration', toneStyle: 'direct', isEnabled: false, }); expect(saved.cardId).toBe(999); expect(saved.personaType).toBe('tech_advisor'); expect(saved.isEnabled).toBe(false); // 更新 const updated = await service.saveByCardId(999, { welcomeMsg: 'Updated support message', triggerLeadThreshold: 60, }); expect(updated.welcomeMsg).toBe('Updated support message'); expect(updated.triggerLeadThreshold).toBe(60); expect(updated.personaType).toBe('tech_advisor'); }); }); describe('3. 控制器路由与契约 (AdminAiConciergeController)', () => { const controllerFile = path.join( __dirname, '../../../src/modules/ai/controller/admin/concierge.ts' ); let meta: CoolControllerMeta; let option: CurdOption; beforeAll(async () => { meta = await waitCoolMeta(AdminAiConciergeController); option = meta.curdOption as CurdOption; }); it('控制器文件路径与路由前缀必须为 /admin/ai/concierge', () => { expect(fs.existsSync(controllerFile)).toBe(true); expect(meta.prefix).toBe('/admin/ai/concierge'); }); it('控制器必须继承 BaseController,绑定 AiConciergeService 与 AiConciergeEntity', () => { expect(AdminAiConciergeController.prototype).toBeInstanceOf(BaseController); expect(option.service).toBe(AiConciergeService); expect(option.entity).toBe(AiConciergeEntity); expect(adminAiConciergeCurdOption.service).toBe(AiConciergeService); }); it('CRUD API 应包含 add, delete, update, info, list, page', () => { const apis = option.api || []; expect(apis).toContain('add'); expect(apis).toContain('delete'); expect(apis).toContain('update'); expect(apis).toContain('info'); expect(apis).toContain('list'); expect(apis).toContain('page'); }); it('serviceApis 应注册 getDefaultPersona, getByCardId, saveByCardId', () => { const methods = serviceApiMethods(option.serviceApis); expect(methods).toContain('getDefaultPersona'); expect(methods).toContain('getByCardId'); expect(methods).toContain('saveByCardId'); }); }); });