From cb3a50a920f32d037d8f18aa41742263aebba0fc Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 3 Mar 2026 20:17:57 +0000 Subject: [PATCH] test(app): add unit tests for useAI composable 16 tests covering provider selection, context injection, sendMessage streaming, error handling, stop generation, and web search integration. Mocks fetch/SSE and IDB for isolated testing. Co-Authored-By: Claude Opus 4.6 --- packages/app/src/__tests__/useAI.test.ts | 353 +++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 packages/app/src/__tests__/useAI.test.ts diff --git a/packages/app/src/__tests__/useAI.test.ts b/packages/app/src/__tests__/useAI.test.ts new file mode 100644 index 00000000..bd30a622 --- /dev/null +++ b/packages/app/src/__tests__/useAI.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +// Mock web search before importing useAI +vi.mock('@/composables/useWebSearch', () => ({ + searchWeb: vi.fn().mockResolvedValue([]), +})) + +// Mock mock data to avoid importing large fixture files +vi.mock('@/mocks/films', () => ({ + mockFilms: [ + { id: 'f1', title: 'Test Film', year: 2020, director: 'Test Dir', genres: ['Drama'], rating: 8, sources: [{ type: 'stream' }] }, + ], +})) +vi.mock('@/mocks/songs', () => ({ + mockSongs: [ + { id: 's1', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', year: 2020, genres: ['Rock'], sources: [{ type: 'jamendo' }] }, + ], +})) +vi.mock('@/mocks/podcasts', () => ({ + mockPodcasts: [ + { id: 'p1', title: 'Test Podcast', host: 'Test Host', year: 2020, genres: ['Tech'], sources: [{ type: 'rss' }] }, + ], +})) + +// Mock idb-storage to avoid IndexedDB access in tests +vi.mock('@/utils/idb-storage', () => ({ + saveConversation: vi.fn().mockResolvedValue(undefined), + loadAllConversations: vi.fn().mockResolvedValue(new Map()), + deleteConversation: vi.fn().mockResolvedValue(undefined), + isIDBAvailable: vi.fn().mockReturnValue(false), +})) + +import { useAI } from '@/composables/useAI' +import { useChatStore } from '@/stores/chat' +import { searchWeb } from '@/composables/useWebSearch' + +const originalFetch = globalThis.fetch + +// Helper to create a mock SSE readable stream +function createSSEStream(events: string[]): ReadableStream { + const encoder = new TextEncoder() + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index < events.length) { + controller.enqueue(encoder.encode(events[index])) + index++ + } else { + controller.close() + } + }, + }) +} + +function mockClaudeResponse(events: string[]) { + return { + ok: true, + body: createSSEStream(events), + text: () => Promise.resolve(''), + } +} + +describe('useAI', () => { + beforeEach(() => { + setActivePinia(createPinia()) + // Reset provider state (module-level ref) back to claude + const { setProvider } = useAI() + setProvider('claude') + // Re-mock searchWeb + vi.mocked(searchWeb).mockResolvedValue([]) + // Default fetch mock (catches loadServerChats and any stray calls) + globalThis.fetch = originalFetch + }) + + describe('provider selection', () => { + it('defaults to claude provider', () => { + const { activeProvider } = useAI() + expect(activeProvider.value).toBe('claude') + }) + + it('switches provider via setProvider', () => { + const { setProvider, activeProvider, activeModel } = useAI() + setProvider('openrouter') + expect(activeProvider.value).toBe('openrouter') + expect(activeModel.value).toBe('meta-llama/llama-4-maverick') + }) + + it('switches to mock provider', () => { + const { setProvider, activeProvider, activeModel } = useAI() + setProvider('mock') + expect(activeProvider.value).toBe('mock') + expect(activeModel.value).toBe('echo') + }) + + it('lists available providers with models', () => { + const { availableProviders } = useAI() + expect(availableProviders.value.length).toBe(3) + const ids = availableProviders.value.map(p => p.id) + expect(ids).toContain('claude') + expect(ids).toContain('openrouter') + expect(ids).toContain('mock') + }) + + it('sets model directly via setModel', () => { + const { setModel, activeModel } = useAI() + setModel('claude-sonnet-4') + expect(activeModel.value).toBe('claude-sonnet-4') + }) + }) + + describe('context injection', () => { + it('includes film library in system prompt', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('hello') + + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + expect(claudeCall).toBeDefined() + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('Test Film') + expect(body.system).toContain("user's film library") + }) + + it('includes song library in system prompt', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('hello') + + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('Test Song') + expect(body.system).toContain("user's song library") + }) + + it('includes content tag format instructions', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('hello') + + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('[[film_ext:') + expect(body.system).toContain('[[song_ext:') + expect(body.system).toContain('[[book_ext:') + }) + }) + + describe('sendMessage', () => { + it('adds user message to store', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"ok"}}\n\n']) + ) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('test message') + + const userMsg = chatStore.messages.find(m => m.role === 'user') + expect(userMsg).toBeDefined() + expect(userMsg!.content).toBe('test message') + }) + + it('creates assistant message placeholder', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n']) + ) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('test') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('hi') + }) + + it('sets isStreaming to true during stream and false after', async () => { + const streamingStates: boolean[] = [] + + globalThis.fetch = vi.fn().mockImplementation(() => { + streamingStates.push(useChatStore().isStreaming) + return Promise.resolve( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hello"}}\n\n']) + ) + }) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + expect(chatStore.isStreaming).toBe(false) + await sendMessage('hi') + expect(chatStore.isStreaming).toBe(false) + // During the fetch call, isStreaming should have been true + expect(streamingStates[0]).toBe(true) + }) + + it('handles stream errors gracefully', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error'), + }) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage, setProvider } = useAI() + setProvider('claude') // Ensure claude provider + + await sendMessage('test') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('⚠') + expect(chatStore.isStreaming).toBe(false) + }) + + it('handles connection errors gracefully', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure')) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage, setProvider } = useAI() + setProvider('claude') // Ensure claude provider + + await sendMessage('test') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('Connection error') + expect(chatStore.isStreaming).toBe(false) + }) + + it('uses mock provider when set to mock', async () => { + const { sendMessage, setProvider } = useAI() + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + + setProvider('mock') + await sendMessage('echo this') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('echo this') + expect(assistantMsg!.content).toContain('echo mode') + }) + }) + + describe('stopGeneration', () => { + it('aborts active stream and sets isStreaming to false', async () => { + // Create a fetch that returns a stream which rejects on abort + globalThis.fetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + const signal = init?.signal + return Promise.resolve({ + ok: true, + body: new ReadableStream({ + start(controller) { + // Send first chunk so readSSE enters its loop + const encoder = new TextEncoder() + controller.enqueue(encoder.encode('data: {"type":"content_block_delta","delta":{"text":"h"}}\n\n')) + // When aborted, close the stream + if (signal) { + signal.addEventListener('abort', () => { + try { controller.close() } catch { /* already closed */ } + }) + } + }, + }), + text: () => Promise.resolve(''), + }) + }) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage, stopGeneration, setProvider } = useAI() + setProvider('claude') + + const sendPromise = sendMessage('test') + + // Wait for fetch to start and first chunk to process + await new Promise(r => setTimeout(r, 50)) + + expect(chatStore.isStreaming).toBe(true) + + stopGeneration() + expect(chatStore.isStreaming).toBe(false) + + await sendPromise + }) + }) + + describe('web search integration', () => { + it('injects web results into system prompt when enabled', async () => { + const mockResults = [ + { title: 'Result 1', url: 'https://example.com', content: 'Some content' }, + ] + vi.mocked(searchWeb).mockResolvedValue(mockResults) + + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"answer"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = true + const { sendMessage } = useAI() + + await sendMessage('latest bitcoin news') + + // Find the Claude API call (not any dev-chats call) + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + expect(claudeCall).toBeDefined() + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('Web search results') + expect(body.system).toContain('Result 1') + expect(body.webSearch).toBe(true) + }) + }) +})