test: add unit tests for ModelCombobox, useProviderModels and providers
This commit is contained in:
parent
5d9c3ab462
commit
3339629747
197
web-app/src/containers/__tests__/ModelCombobox.test.tsx
Normal file
197
web-app/src/containers/__tests__/ModelCombobox.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
105
web-app/src/hooks/__tests__/useProviderModels.test.ts
Normal file
105
web-app/src/hooks/__tests__/useProviderModels.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 = {
|
const mockResponse = {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
@ -236,7 +236,43 @@ describe('providers service', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await expect(fetchModelsFromProvider(provider)).rejects.toThrow(
|
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'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user