diff --git a/web-app/src/containers/__tests__/ModelCombobox.test.tsx b/web-app/src/containers/__tests__/ModelCombobox.test.tsx new file mode 100644 index 000000000..88158f268 --- /dev/null +++ b/web-app/src/containers/__tests__/ModelCombobox.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom/vitest' +import { ModelCombobox } from '../ModelCombobox' +import React from 'react' + +describe('ModelCombobox', () => { + const defaultProps = { + value: '', + onChange: vi.fn(), + models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku'], + } + + const mockUser = userEvent.setup() + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Basic Rendering', () => { + it('should render input field with placeholder', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('placeholder', 'Type or select a model...') + }) + + it('should render custom placeholder', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('placeholder', 'Choose a model') + }) + + it('should render dropdown trigger button', () => { + render() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('should display current value in input', () => { + render() + + const input = screen.getByDisplayValue('gpt-4') + expect(input).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render( + + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + }) + }) + + describe('Disabled State', () => { + it('should disable input when disabled prop is true', () => { + render() + + const input = screen.getByRole('textbox') + const button = screen.getByRole('button') + + expect(input).toBeDisabled() + expect(button).toBeDisabled() + }) + + it('should not open dropdown when disabled', async () => { + render() + + const input = screen.getByRole('textbox') + await mockUser.click(input) + + expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument() + }) + }) + + describe('Loading State', () => { + it('should show loading spinner in trigger button', () => { + render() + + const button = screen.getByRole('button') + const spinner = button.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + + it('should show loading spinner when loading prop is true', () => { + render() + + const spinner = screen.getByRole('button').querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + }) + + describe('Input Interactions', () => { + it('should call onChange when typing', async () => { + const mockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await mockUser.type(input, 'g') + + expect(mockOnChange).toHaveBeenCalledWith('g') + }) + + it('should update input value when typing', async () => { + const mockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await mockUser.type(input, 'test') + + expect(input).toHaveValue('test') + }) + + it('should handle input focus', async () => { + render() + + const input = screen.getByRole('textbox') + await mockUser.click(input) + + expect(input).toHaveFocus() + }) + }) + + describe('Props Validation', () => { + it('should render with empty models array', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) + + it('should render with models array', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) + + it('should render with all props', () => { + render( + + ) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toBeDisabled() + }) + }) + + describe('Component Lifecycle', () => { + it('should handle mount and unmount without errors', () => { + const { unmount } = render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + + unmount() + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should handle props changes', () => { + const { rerender } = render() + + expect(screen.getByDisplayValue('')).toBeInTheDocument() + + rerender() + + expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument() + }) + + it('should handle models array changes', () => { + const { rerender } = render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + + rerender() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) +}) diff --git a/web-app/src/hooks/__tests__/useProviderModels.test.ts b/web-app/src/hooks/__tests__/useProviderModels.test.ts new file mode 100644 index 000000000..3d107b9f8 --- /dev/null +++ b/web-app/src/hooks/__tests__/useProviderModels.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { useProviderModels } from '../useProviderModels' + +// Mock the providers service +vi.mock('@/services/providers', () => ({ + fetchModelsFromProvider: vi.fn(), +})) + +import { fetchModelsFromProvider } from '@/services/providers' +const mockFetchModelsFromProvider = vi.mocked(fetchModelsFromProvider) + +// Mock ModelProvider type +type MockModelProvider = { + active: boolean + provider: string + base_url?: string + api_key?: string + settings: any[] + models: any[] +} + +describe('useProviderModels', () => { + const mockProvider: MockModelProvider = { + active: true, + provider: 'openai', + base_url: 'https://api.openai.com/v1', + api_key: 'test-api-key', + settings: [], + models: [], + } + + const mockModels = ['gpt-4', 'gpt-3.5-turbo', 'gpt-4-turbo'] + + beforeEach(() => { + vi.clearAllMocks() + // Reset the cache by clearing any previous state + mockFetchModelsFromProvider.mockClear() + }) + + it('should initialize with empty state', () => { + const { result } = renderHook(() => useProviderModels()) + + expect(result.current.models).toEqual([]) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(null) + expect(typeof result.current.refetch).toBe('function') + }) + + it('should not fetch models when provider is undefined', () => { + renderHook(() => useProviderModels(undefined)) + expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + }) + + it('should not fetch models when provider has no base_url', () => { + const providerWithoutUrl = { ...mockProvider, base_url: undefined } + renderHook(() => useProviderModels(providerWithoutUrl)) + expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + }) + + it('should fetch and sort models', async () => { + mockFetchModelsFromProvider.mockResolvedValueOnce(mockModels) + + const { result } = renderHook(() => useProviderModels(mockProvider)) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + // Should be sorted alphabetically + expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) + expect(result.current.error).toBe(null) + expect(mockFetchModelsFromProvider).toHaveBeenCalledWith(mockProvider) + }) + + it('should clear models when switching to invalid provider', async () => { + mockFetchModelsFromProvider.mockResolvedValueOnce(mockModels) + + const { result, rerender } = renderHook( + ({ provider }) => useProviderModels(provider), + { initialProps: { provider: mockProvider } } + ) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) + + // Switch to invalid provider + rerender({ provider: { ...mockProvider, base_url: undefined } }) + + expect(result.current.models).toEqual([]) + expect(result.current.error).toBe(null) + expect(result.current.loading).toBe(false) + }) + + it('should not refetch when provider is undefined', () => { + const { result } = renderHook(() => useProviderModels(undefined)) + + result.current.refetch() + + expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/web-app/src/services/__tests__/providers.test.ts b/web-app/src/services/__tests__/providers.test.ts index 6660ffa30..c7b041cd5 100644 --- a/web-app/src/services/__tests__/providers.test.ts +++ b/web-app/src/services/__tests__/providers.test.ts @@ -222,7 +222,7 @@ describe('providers service', () => { ) }) - it('should throw error when API response is not ok', async () => { + it('should throw error when API response is not ok (404)', async () => { const mockResponse = { ok: false, status: 404, @@ -236,7 +236,43 @@ describe('providers service', () => { } await expect(fetchModelsFromProvider(provider)).rejects.toThrow( - 'Cannot connect to custom at https://api.custom.com. Please check that the service is running and accessible.' + 'Models endpoint not found for custom. Check the base URL configuration.' + ) + }) + + it('should throw error when API response is not ok (403)', async () => { + const mockResponse = { + ok: false, + status: 403, + statusText: 'Forbidden', + } + vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) + + const provider = { + provider: 'custom', + base_url: 'https://api.custom.com', + } as ModelProvider + + await expect(fetchModelsFromProvider(provider)).rejects.toThrow( + 'Access forbidden: Check your API key permissions for custom' + ) + }) + + it('should throw error when API response is not ok (401)', async () => { + const mockResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + } + vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) + + const provider = { + provider: 'custom', + base_url: 'https://api.custom.com', + } as ModelProvider + + await expect(fetchModelsFromProvider(provider)).rejects.toThrow( + 'Authentication failed: API key is required or invalid for custom' ) })