import type { ConfigService } from '@nestjs/config'; import { ChatService } from '../chat/chat.service'; import { AgentService } from './agent.service'; import { AiProviderService } from './ai-provider.service'; import { getCopilotConfiguration } from './copilot-backend'; const config = (values: Record) => ({ get: (key: string, fallback?: string) => values[key] ?? fallback }) as ConfigService; const configuration = { AI_PROVIDER: 'futuredrive', FUTUREDRIVE_LLM_BASE_URL: 'https://shared-ai.test/', FUTUREDRIVE_LLM_API_KEY: 'server-only-secret', FUTUREDRIVE_LLM_LANG: 'ko', }; const plan = { summary: '분석 완료', explanation: '확인한 파일을 기준으로 답변합니다.', changes: [], commands: [], createModule: null }; describe('managed A-Copilot backend', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; jest.restoreAllMocks(); }); it('uses exactly the same FutureDrive transport and server key as the AI tab', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ answer: JSON.stringify(plan) }) }); global.fetch = fetchMock as typeof fetch; const settings = config(configuration); const chat = Object.assign(Object.create(ChatService.prototype), { configService: settings }); await chat.requestAiChatCompletion('ignored-model', [{ role: 'user', content: '학습 질문' }], '공식 교육 내용'); const provider = new AiProviderService(settings); await provider.complete({ provider: 'A_COPILOT', model: 'untrusted-model', apiKey: 'untrusted-key', endpointUrl: 'https://untrusted.test', system: 'Return an agent JSON plan', json: true, messages: [{ role: 'user', content: '워크스페이스 문맥' }], }); for (const [url, request] of fetchMock.mock.calls) { expect(url).toBe('https://shared-ai.test/api/chat/complete'); expect(request.headers).toEqual({ 'X-API-Key': 'server-only-secret', 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': '1' }); const body = JSON.parse(request.body); expect(Object.keys(body).sort()).toEqual(['lang', 'messages']); expect(body.lang).toBe('ko'); expect(request.body).not.toMatch(/server-only-secret|untrusted/); } expect(JSON.parse(fetchMock.mock.calls[0][1].body).messages[0].content).toBe('공식 교육 내용'); const messages = JSON.parse(fetchMock.mock.calls[1][1].body).messages; expect(messages[0].content).toContain('Return an agent JSON plan'); expect(messages[1]).toEqual({ role: 'user', content: '워크스페이스 문맥' }); expect(messages.at(-1).content).toContain('answer 본문'); }); it('follows the AI tab server selection when OpenAI is configured, without accepting course overrides', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify(plan) } }] }) }); global.fetch = fetchMock as typeof fetch; const provider = new AiProviderService(config({ AI_PROVIDER: 'openai', OPENAI_API_KEY: 'shared-openai-key', OPENAI_CHAT_MODEL: 'gpt-6-astra' })); await provider.complete({ provider: 'A_COPILOT', model: 'ignored', apiKey: 'ignored', endpointUrl: 'https://untrusted.test', system: 'agent instructions', json: true, messages: [{ role: 'user', content: 'test' }] }); expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/chat/completions'); expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer shared-openai-key'); expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ model: 'gpt-6-astra', reasoning_effort: 'low', response_format: { type: 'json_object' }, messages: [{ role: 'system', content: 'agent instructions' }, { role: 'user', content: 'test' }] }); }); it('never falls back to a different server provider when its configured key is missing', async () => { const settings = config({ AI_PROVIDER: 'futuredrive', OPENAI_API_KEY: 'other-provider-key' }); expect(getCopilotConfiguration(settings)).toMatchObject({ backend: 'futuredrive', configured: false }); const fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch; const provider = new AiProviderService(settings); await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: 'course-key', system: 'test', messages: [] })).rejects.toThrow(/서버 연결/); expect(fetchMock).not.toHaveBeenCalled(); }); it.each([ { ok: false, status: 503, json: async () => ({ message: 'upstream unavailable' }) }, { ok: true, status: 200, json: async () => ({ answer: '' }) }, { ok: true, status: 200, json: async () => { throw Error('invalid JSON'); } }, ])('rejects upstream failures or empty answers instead of inventing a successful plan', async response => { global.fetch = jest.fn().mockResolvedValue(response) as typeof fetch; const provider = new AiProviderService(config(configuration)); await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [] })).rejects.toThrow(); }); it('does not silently send images to the text-only API', async () => { const fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch; const provider = new AiProviderService(config(configuration)); await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [{ role: 'user', content: 'test', images: [{ mimeType: 'image/png', data: 'test' }] }] })).rejects.toThrow(/Qwen/); expect(fetchMock).not.toHaveBeenCalled(); }); it('normalizes a real RAG-style answer containing prose and one agent plan without a repair call', async () => { const answer = `모듈 구성표를 먼저 조회합니다.\n\n\`\`\`json\n${JSON.stringify(plan)}\n\`\`\``; const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ answer }) }); global.fetch = fetchMock as typeof fetch; const provider = new AiProviderService(config(configuration)); const result = await provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'JSON plan only', json: true, messages: [{ role: 'user', content: JSON.stringify({ request: '모듈 목록 확인' }) }] }); expect(JSON.parse(result.content)).toEqual(plan); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('distinguishes an empty upstream answer and retains its request ID without inventing a response', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ answer: '', request_id: 'fixture-request-123' }) }) as typeof fetch; const provider = new AiProviderService(config(configuration)); try { await provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [] }); throw Error('Expected an upstream error'); } catch (error) { expect((error as { getResponse: () => unknown }).getResponse()).toEqual(expect.objectContaining({ code: 'COPILOT_EMPTY_ANSWER', upstreamRequestId: 'fixture-request-123', message: expect.stringContaining('API 연결은 성공'), })); } }); it('creates and repairs an agent JSON plan using server credentials, without a course credential', async () => { const fetchMock = jest.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ answer: 'invalid plan JSON' }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ answer: JSON.stringify(plan) }) }); global.fetch = fetchMock as typeof fetch; const decrypt = jest.fn(); const setting = { provider: 'A_COPILOT', model: 'a-copilot', enabled: true, encryptedApiKey: null, endpointUrl: null }; const provider = new AiProviderService(config(configuration)); const service = Object.assign(Object.create(AgentService.prototype), { getLmsUser: jest.fn().mockResolvedValue({ id: 'student' }), getStudentCourse: jest.fn().mockResolvedValue({ id: 'course', title: '실습' }), prisma: { courseAiSetting: { findUnique: jest.fn().mockResolvedValue(setting) } }, credentialService: { decrypt }, providerService: provider, }) as AgentService; await expect(service.status('token', 'course')).resolves.toEqual({ enabled: true, provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }); const result = await service.createPlan('token', { courseId: 'course', request: '파일 분석', modules: [{ name: 'NAV2-Control', files: [{ path: 'config/control.yaml', content: 'velocity: 0.5' }] }], toolProtocol: 1 }); expect(result).toMatchObject({ provider: 'A_COPILOT', model: 'A-Copilot', plan }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(decrypt).not.toHaveBeenCalled(); const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.messages[0].content).toContain('JSON'); expect(body.messages[1].content).toContain('velocity: 0.5'); expect(JSON.stringify(result)).not.toContain('server-only-secret'); jest.spyOn(provider, 'copilotConfigured').mockReturnValue(false); await expect(service.status('token', 'course')).resolves.toMatchObject({ enabled: false, imagesEnabled: false }); await expect(service.createPlan('token', { courseId: 'course', request: 'test', modules: [] })).rejects.toThrow(/설정되어 있지/); expect(fetchMock).toHaveBeenCalledTimes(2); }); });