test: add unit tests for ModelCombobox, useProviderModels and providers

This commit is contained in:
lugnicca 2025-08-20 16:22:21 +02:00
parent 5d9c3ab462
commit 3339629747
3 changed files with 340 additions and 2 deletions

View File

@ -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(<ModelCombobox {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('placeholder', 'Type or select a model...')
})
it('should render custom placeholder', () => {
render(<ModelCombobox {...defaultProps} placeholder="Choose a model" />)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'Choose a model')
})
it('should render dropdown trigger button', () => {
render(<ModelCombobox {...defaultProps} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should display current value in input', () => {
render(<ModelCombobox {...defaultProps} value="gpt-4" />)
const input = screen.getByDisplayValue('gpt-4')
expect(input).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(
<ModelCombobox {...defaultProps} className="custom-class" />
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
})
describe('Disabled State', () => {
it('should disable input when disabled prop is true', () => {
render(<ModelCombobox {...defaultProps} disabled />)
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(<ModelCombobox {...defaultProps} disabled />)
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(<ModelCombobox {...defaultProps} loading />)
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(<ModelCombobox {...defaultProps} loading />)
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(<ModelCombobox {...defaultProps} onChange={mockOnChange} />)
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(<ModelCombobox {...defaultProps} onChange={mockOnChange} />)
const input = screen.getByRole('textbox')
await mockUser.type(input, 'test')
expect(input).toHaveValue('test')
})
it('should handle input focus', async () => {
render(<ModelCombobox {...defaultProps} />)
const input = screen.getByRole('textbox')
await mockUser.click(input)
expect(input).toHaveFocus()
})
})
describe('Props Validation', () => {
it('should render with empty models array', () => {
render(<ModelCombobox {...defaultProps} models={[]} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should render with models array', () => {
render(<ModelCombobox {...defaultProps} models={['model1', 'model2']} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should render with all props', () => {
render(
<ModelCombobox
{...defaultProps}
loading
error="Error message"
onRefresh={vi.fn()}
placeholder="Custom placeholder"
disabled
/>
)
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(<ModelCombobox {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
unmount()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle props changes', () => {
const { rerender } = render(<ModelCombobox {...defaultProps} value="" />)
expect(screen.getByDisplayValue('')).toBeInTheDocument()
rerender(<ModelCombobox {...defaultProps} value="gpt-4" />)
expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument()
})
it('should handle models array changes', () => {
const { rerender } = render(<ModelCombobox {...defaultProps} models={[]} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
rerender(<ModelCombobox {...defaultProps} models={['model1', 'model2']} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
})

View File

@ -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()
})
})

View File

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