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'
)
})