import { describe, test, expect, afterEach } from 'vitest'; import { analyzeTurnWithOpenRouter, generateSessionSummaryReport, generateScenarioWithOpenRouter, streamOpenRouterChat } from '../services/openRouterService'; import { DEFAULT_OPENROUTER_MODEL } from '../services/apiConfig'; describe('OpenRouter Service Integration', () => { const originalFetch = globalThis.fetch; const mockKey = 'sk-or-v1-mock-test-key-999'; afterEach(() => { globalThis.fetch = originalFetch; }); test('should throw error when no API key is provided and none in environment', async () => { const originalEnv = process.env.OPENROUTER_API_KEY; try { process.env.OPENROUTER_API_KEY = ''; await expect( analyzeTurnWithOpenRouter('turn', 'title', 'desc', ['Catastrophizing'], { apiKey: '' }) ).rejects.toThrow(/OpenRouter API key is missing/); } finally { if (originalEnv !== undefined) process.env.OPENROUTER_API_KEY = originalEnv; } }); test('analyzeTurnWithOpenRouter sends expected headers and uses custom model when specified', async () => { let capturedUrl = ''; let capturedHeaders: any = {}; let capturedBody: any = {}; globalThis.fetch = async (url: any, init: any) => { capturedUrl = url.toString(); capturedHeaders = init?.headers; capturedBody = JSON.parse(init?.body as string); const mockResponse = { choices: [ { message: { content: JSON.stringify({ analysis: { distortions: ['Mind Reading'], isAdaptive: false, evidence: 'User assumed negative intent.' }, rating: { score: 2, reason: 'High distortion presence' }, suggestions: { alternativeThought: 'Ask for clarification first.', behavioralStep: 'Send a follow-up text.' } }) } } ] }; return new Response(JSON.stringify(mockResponse), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const result = await analyzeTurnWithOpenRouter( 'User said: "They hate me."', 'Silent Friend', 'A friend has not replied.', ['Mind Reading'], { apiKey: mockKey, model: 'google/gemini-3.8-flash' } ); expect(capturedUrl).toBe('https://openrouter.ai/api/v1/chat/completions'); expect(capturedHeaders['Authorization']).toBe(`Bearer ${mockKey}`); expect(capturedHeaders['HTTP-Referer']).toBe('https://cognitive-clarity.app'); expect(capturedBody.model).toBe('google/gemini-3.8-flash'); expect(capturedBody.response_format?.type).toBe('json_object'); expect(result.analysis.isAdaptive).toBe(false); expect(result.analysis.distortions).toEqual(['Mind Reading']); expect(result.rating.score).toBe(2); }); test('generateScenarioWithOpenRouter defaults to DEFAULT_OPENROUTER_MODEL when model option is omitted', async () => { let capturedBody: any = {}; globalThis.fetch = async (url: any, init: any) => { capturedBody = JSON.parse(init?.body as string); const mockScenario = { id: 'mock-1', title: 'Workplace Feedback', characterName: 'Boss', description: 'Meeting about quarterly review', category: 'Workplace', difficulty: 'Intermediate', systemInstruction: 'INTERNAL CBT LOGIC: React defensively if blamed.', initialMessage: 'We need to discuss this report.', cbtFocus: ['All-or-Nothing Thinking'] }; return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(mockScenario) } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const scenario = await generateScenarioWithOpenRouter( 'Quarterly review', ['All-or-Nothing Thinking'], 'Intermediate', { apiKey: mockKey } ); expect(capturedBody.model).toBe(DEFAULT_OPENROUTER_MODEL); expect(scenario.title).toBe('Workplace Feedback'); expect(scenario.difficulty).toBe('Intermediate'); }); test('streamOpenRouterChat streams SSE tokens and handles completion', async () => { const sseChunks = [ 'data: {"choices":[{"delta":{"content":"I "}}]}\n\n', 'data: {"choices":[{"delta":{"content":"understand."}}]}\n\n', 'data: [DONE]\n\n' ]; const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { for (const chunk of sseChunks) { controller.enqueue(encoder.encode(chunk)); } controller.close(); } }); globalThis.fetch = async () => { return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); }; const collectedChunks: string[] = []; let completedText = ''; const result = await streamOpenRouterChat( [{ id: '1', role: 'user', text: 'Hello', timestamp: new Date() }], 'You are a peer.', { onChunk: (chunk) => collectedChunks.push(chunk), onComplete: (full) => { completedText = full; } }, { apiKey: mockKey, model: 'google/gemini-3.8-flash' } ); expect(result).toBe('I understand.'); expect(completedText).toBe('I understand.'); expect(collectedChunks).toEqual(['I ', 'understand.']); }); test('generateSessionSummaryReport sends all conversation turns and parses clinical report', async () => { let capturedBody: any = {}; globalThis.fetch = async (url: any, init: any) => { capturedBody = JSON.parse(init?.body as string); const mockReport = { analysis: { distortions: ['Catastrophizing'], isAdaptive: false, evidence: 'I am definitely going to get fired.' }, rating: { score: 3, reason: 'Showed initial panic but engaged in dialogue.' }, suggestions: { alternativeThought: 'Receiving negative feedback does not mean immediate termination.', behavioralStep: 'Schedule a clarifying 1-on-1 meeting next week.' } }; return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(mockReport) } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const messages = [ { id: '1', role: 'model' as const, text: 'Your performance was lacking this month.', timestamp: new Date() }, { id: '2', role: 'user' as const, text: 'I am definitely going to get fired.', timestamp: new Date() } ]; const report = await generateSessionSummaryReport( messages, 'Performance Review', 'Manager discusses quarterly output', { apiKey: mockKey, model: 'google/gemini-3.8-flash' } ); expect(capturedBody.model).toBe('google/gemini-3.8-flash'); expect(report.analysis.distortions).toEqual(['Catastrophizing']); expect(report.rating.score).toBe(3); expect(report.suggestions.alternativeThought).toContain('termination'); }); test('synthesizeSpeechWithOpenRouter returns audio ArrayBuffer on success', async () => { const mockAudioBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); let capturedUrl = ''; let capturedBody: any = {}; globalThis.fetch = async (url: any, init: any) => { capturedUrl = url.toString(); capturedBody = JSON.parse(init?.body as string); return new Response(mockAudioBytes.buffer, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } }); }; const { synthesizeSpeechWithOpenRouter } = await import('../services/openRouterService'); const buffer = await synthesizeSpeechWithOpenRouter( 'Good morning. How are you feeling today?', 'nova', mockKey ); expect(capturedUrl).toBe('https://openrouter.ai/api/v1/audio/speech'); expect(capturedBody.input).toBe('Good morning. How are you feeling today?'); expect(capturedBody.voice).toBe('nova'); expect(buffer.byteLength).toBe(8); }); test('synthesizeSpeechWithOpenRouter throws error on non-200 response', async () => { globalThis.fetch = async () => { return new Response('Unauthorized voice quota exceeded', { status: 401, headers: { 'Content-Type': 'text/plain' } }); }; const { synthesizeSpeechWithOpenRouter } = await import('../services/openRouterService'); await expect( synthesizeSpeechWithOpenRouter('Hello', 'alloy', mockKey) ).rejects.toThrow(/OpenRouter TTS synthesis failed with status 401/); }); });