import { DeepSeekService } from '../../../src/modules/ai/service/deepseek'; import { AiDeepSeekConfigEntity } from '../../../src/modules/ai/entity/deepseek-config'; describe('T31 DeepSeek 大模型集成与适配服务 (deepseek.ts)', () => { let service: DeepSeekService; beforeEach(() => { // 先清理环境变量,防止跨测试用例污染 delete process.env.DEEPSEEK_API_KEY; delete process.env.DEEPSEEK_BASE_URL; delete process.env.DEEPSEEK_MODEL; service = new DeepSeekService(); }); describe('1. 配置管理与状态检测 (Config & Readiness)', () => { it('1.1 初始状态默认配置验证', () => { const config = service.getConfig(); expect(config.baseUrl).toBe('https://api.deepseek.com'); expect(config.model).toBe('deepseek-chat'); expect(config.temperature).toBe(0.7); expect(config.timeoutMs).toBe(30000); expect(service.isConfigured()).toBe(false); }); it('1.2 优先从环境变量读取 DEEPSEEK_API_KEY', () => { process.env.DEEPSEEK_API_KEY = 'sk-env-test-key-123'; process.env.DEEPSEEK_BASE_URL = 'https://custom.deepseek.com'; process.env.DEEPSEEK_MODEL = 'deepseek-reasoner'; const envService = new DeepSeekService(); expect(envService.isConfigured()).toBe(true); expect(envService.getConfig().apiKey).toBe('sk-env-test-key-123'); expect(envService.getConfig().baseUrl).toBe('https://custom.deepseek.com'); expect(envService.getConfig().model).toBe('deepseek-reasoner'); }); it('1.3 updateConfig 动态更新与合并配置', () => { service.updateConfig({ apiKey: 'sk-dynamic-key-456', model: 'deepseek-reasoner', temperature: 0.2, }); expect(service.isConfigured()).toBe(true); const cfg = service.getConfig(); expect(cfg.apiKey).toBe('sk-dynamic-key-456'); expect(cfg.model).toBe('deepseek-reasoner'); expect(cfg.temperature).toBe(0.2); expect(cfg.baseUrl).toBe('https://api.deepseek.com'); // 保留默认值 }); it('1.4 无 repository 时 setTenantConfig 仅写内存 Map 兜底', async () => { expect((service as any).deepSeekConfigEntity).toBeUndefined(); await service.setTenantConfig(42, { apiKey: 'sk-map-only-fallback-key', model: 'deepseek-reasoner', baseUrl: 'https://api.deepseek.com', }); expect(service.isConfigured(42)).toBe(true); expect(service.getTenantConfig(42).apiKey).toBe('sk-map-only-fallback-key'); expect(service.getTenantConfig(42).model).toBe('deepseek-reasoner'); // 不污染平台公共池 expect(service.isConfigured()).toBe(false); }); it('1.5 有 mock repository 时 setTenantConfig 落库且 resolve 优先读库', async () => { const store: AiDeepSeekConfigEntity[] = []; const mockRepo = { find: jest.fn(async () => [...store]), findOne: jest.fn(async ({ where }: { where: { tenantId: number } }) => { return store.find((r) => r.tenantId === where.tenantId) || null; }), save: jest.fn(async (row: AiDeepSeekConfigEntity) => { const idx = store.findIndex((r) => r.tenantId === row.tenantId); if (idx >= 0) { store[idx] = row; } else { store.push(row); } return row; }), }; (service as any).deepSeekConfigEntity = mockRepo; await service.setTenantConfig(1001, { apiKey: 'sk-persisted-tenant-key', model: 'deepseek-chat', baseUrl: 'https://api.deepseek.com', }); expect(mockRepo.save).toHaveBeenCalled(); expect(store).toHaveLength(1); expect(store[0].tenantId).toBe(1001); expect(store[0].apiKey).toBe('sk-persisted-tenant-key'); expect(store[0].enabled).toBe(true); // 清空内存 Map,验证 resolveTenantConfig 从 DB 读回 (service as any).tenantConfigs.clear(); const fromDb = await service.resolveTenantConfig(1001); expect(fromDb.apiKey).toBe('sk-persisted-tenant-key'); expect(service.getTenantConfig(1001).apiKey).toBe('sk-persisted-tenant-key'); }); it('1.6 DB 抛错时静默回退内存 Map,不中断业务', async () => { const mockRepo = { find: jest.fn(async () => { throw new Error('db down'); }), findOne: jest.fn(async () => { throw new Error('db down'); }), save: jest.fn(async () => { throw new Error('db down'); }), }; (service as any).deepSeekConfigEntity = mockRepo; await expect( service.setTenantConfig(9, { apiKey: 'sk-survive-db-outage' }) ).resolves.toMatchObject({ apiKey: 'sk-survive-db-outage' }); expect(service.getTenantConfig(9).apiKey).toBe('sk-survive-db-outage'); }); }); describe('2. 提示词动态拼装 (Prompt Builders)', () => { it('2.1 buildSystemPrompt 组装名片主、人设与知识库切片', () => { const card = { name: '张总', company: '华强全球科技', title: '国际商务总监', }; const concierge = { personaType: 'trade_expert' as const, toneStyle: 'professional' as const, promptTemplate: '你是一名资深国际贸易专家,精通中东与东南亚市场。', }; const chunks = [ { index: 1, content: '支持信用证结算与 FOB 迪拜港条款。' }, { index: 2, content: '最小起订量 (MOQ) 为 500 台。' }, ]; const systemPrompt = service.buildSystemPrompt(card, concierge, chunks); expect(systemPrompt).toContain('张总'); expect(systemPrompt).toContain('华强全球科技'); expect(systemPrompt).toContain('国际商务总监'); expect(systemPrompt).toContain('你是一名资深国际贸易专家'); expect(systemPrompt).toContain('支持信用证结算与 FOB 迪拜港条款。'); expect(systemPrompt).toContain('最小起订量 (MOQ) 为 500 台。'); }); it('2.2 buildLocalizePrompt 组装多语言润色与术语库指令', () => { const prompt = service.buildLocalizePrompt( '欢迎参观我们位于迪拜的展位', 'en-US', 'professional', ['展位 -> Booth', '迪拜 -> Dubai'] ); expect(prompt).toContain('欢迎参观我们位于迪拜的展位'); expect(prompt).toContain('en-US'); expect(prompt).toContain('professional'); expect(prompt).toContain('Booth'); expect(prompt).toContain('Dubai'); }); it('2.3 buildCopilotPrompt 组装销售破冰首语生成指令', () => { const lead = { name: 'Ahmed', company: 'Gulf Energy', phone: '+971501234567', demand: '储能集装箱报价', }; const tracks = [ { module: 'case_energy', duration: 180 }, { module: 'price_table', duration: 90 }, ]; const prompt = service.buildCopilotPrompt(lead, tracks); expect(prompt).toContain('Ahmed'); expect(prompt).toContain('Gulf Energy'); expect(prompt).toContain('储能集装箱报价'); expect(prompt).toContain('case_energy'); }); }); describe('3. 文本补全调用与高可用降级 (chatCompletion & Fallback)', () => { it('3.1 未配置 API Key 时,提供 fallbackReply 则安全返回兜底内容', async () => { expect(service.isConfigured()).toBe(false); const result = await service.chatCompletion( { messages: [{ role: 'user', content: '你们的产品价格是多少?' }], }, '这是本地规则引擎的兜底回复' ); expect(result).toBe('这是本地规则引擎的兜底回复'); }); it('3.2 未配置 API Key 且未提供 fallbackReply 时安全降级或抛出清晰提示', async () => { await expect( service.chatCompletion({ messages: [{ role: 'user', content: '你好' }], }) ).rejects.toThrow('DeepSeek API Key 未配置'); }); it('3.3 模拟正常调用 DeepSeek 官方接口返回生成文本', async () => { service.updateConfig({ apiKey: 'sk-mock-valid-key' }); // 模拟底层 HTTP client const mockPost = jest.fn().mockResolvedValue({ status: 200, data: { id: 'chatcmpl-test-123', model: 'deepseek-chat', choices: [ { index: 0, message: { role: 'assistant', content: '您好!我是华强科技的 AI 顾问,很高兴为您服务。', }, finish_reason: 'stop', }, ], }, }); (service as any).httpClient = { post: mockPost }; const reply = await service.chatCompletion({ messages: [{ role: 'user', content: '您好' }], }); expect(reply).toBe('您好!我是华强科技的 AI 顾问,很高兴为您服务。'); expect(mockPost).toHaveBeenCalledWith( 'https://api.deepseek.com/chat/completions', expect.objectContaining({ model: 'deepseek-chat', messages: [{ role: 'user', content: '您好' }], }), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer sk-mock-valid-key', }), }) ); }); it('3.4 API 调用异常且提供 fallbackReply 时平滑降级', async () => { service.updateConfig({ apiKey: 'sk-mock-valid-key' }); const mockPost = jest.fn().mockRejectedValue(new Error('Network Timeout')); (service as any).httpClient = { post: mockPost }; const reply = await service.chatCompletion( { messages: [{ role: 'user', content: '报价' }] }, '网络异常时的平滑降级回复' ); expect(reply).toBe('网络异常时的平滑降级回复'); }); }); describe('4. 连通性测试 (testConnection)', () => { it('4.1 未配置 API Key 且未传入临时 Key 时测试失败', async () => { const res = await service.testConnection(); expect(res.success).toBe(false); expect(res.message).toContain('API Key 未提供'); }); it('4.2 传入测试 Key 成功握手并统计延迟', async () => { const mockPost = jest.fn().mockResolvedValue({ status: 200, data: { choices: [ { message: { content: 'pong' }, finish_reason: 'stop' }, ], }, }); (service as any).httpClient = { post: mockPost }; const res = await service.testConnection('sk-test-tmp-key'); expect(res.success).toBe(true); expect(typeof res.latencyMs).toBe('number'); expect(res.latencyMs).toBeGreaterThanOrEqual(0); expect(res.model).toBe('deepseek-chat'); }); it('4.3 官方 API 返回 401 鉴权失败捕获', async () => { const mockPost = jest.fn().mockRejectedValue({ response: { status: 401, data: { error: { message: 'Authentication Fails (invalid api key)' } }, }, }); (service as any).httpClient = { post: mockPost }; const res = await service.testConnection('sk-invalid-key'); expect(res.success).toBe(false); expect(res.message).toContain('Authentication Fails'); }); }); describe('5. Token 用量仅进程内存,不得冒充账单/落库 (R29)', () => { /** 内存用量标记:storage:'memory' 或 persisted:false 均可 */ function assertMemoryOnlyUsage(result: { storage?: string; persisted?: boolean; totalTokens: number; promptTokens: number; completionTokens: number; callCount: number; }) { const markedMemory = result.storage === 'memory' || result.persisted === false; expect(markedMemory).toBe(true); // 不得伪装成已落库账单用量 expect(result.storage).not.toBe('db'); expect(result.storage).not.toBe('persisted'); expect(result.persisted).not.toBe(true); } it('5.1 无任何记录时 getTenantTokenUsage 返回全 0,并标明内存存储', () => { const usage = service.getTenantTokenUsage(9001); expect(usage.totalTokens).toBe(0); expect(usage.promptTokens).toBe(0); expect(usage.completionTokens).toBe(0); expect(usage.callCount).toBe(0); assertMemoryOnlyUsage(usage); // 禁止造示例/占位用量 expect(usage.totalTokens).not.toBeGreaterThan(0); }); it('5.2 recordTokenUsage 返回值须带 storage:memory(或 persisted:false)', () => { const recorded = service.recordTokenUsage(9002, { promptTokens: 10, completionTokens: 5, totalTokens: 15, }); expect(recorded.totalTokens).toBe(15); expect(recorded.promptTokens).toBe(10); expect(recorded.completionTokens).toBe(5); expect(recorded.callCount).toBe(1); assertMemoryOnlyUsage(recorded); }); it('5.3 累计后 getTenantTokenUsage 仍标明内存,数值仅反映进程内账本', () => { service.recordTokenUsage(9003, { totalTokens: 100 }); service.recordTokenUsage(9003, { promptTokens: 20, completionTokens: 30, totalTokens: 50, }); const usage = service.getTenantTokenUsage(9003); expect(usage.totalTokens).toBe(150); expect(usage.promptTokens).toBe(20); expect(usage.completionTokens).toBe(30); expect(usage.callCount).toBe(2); assertMemoryOnlyUsage(usage); }); it('5.4 不同租户用量隔离;未写入租户仍为 0 且标明内存', () => { service.recordTokenUsage(9004, { totalTokens: 77 }); const other = service.getTenantTokenUsage(9005); expect(other.totalTokens).toBe(0); expect(other.callCount).toBe(0); assertMemoryOnlyUsage(other); assertMemoryOnlyUsage(service.getTenantTokenUsage(9004)); }); }); });