132 lines
9.2 KiB
TypeScript
132 lines
9.2 KiB
TypeScript
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<string, string>) => ({ 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);
|
|
});
|
|
});
|