Files
clarity-helper/test/openRouterService.test.ts
T
jpremill 1feea40684 feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice
- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer
- Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support
- Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline
- Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots)
- Migrate core application to Svelte 5 runes (, , ) with reactive stores
- Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
2026-09-29 23:35:05 -04:00

247 lines
8.4 KiB
TypeScript

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/);
});
});