import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ModelsAdmin from './ModelsAdmin'
import api from '../api/client'
vi.mock('../api/client', () => ({
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
}))
vi.mock('../hooks/useDialog', () => ({
useDialog: () => ({ dialogProps: {}, openConfirm: () => Promise.resolve(true) }),
}))
vi.mock('./Dialog', () => ({ default: () => null }))
const MODELS = [
{ id: 1, name: 'Haiku', model_id: 'claude-haiku-4-5', task: 'teach', is_active: true, is_default: true },
{ id: 2, name: 'Sonnet', model_id: 'claude-sonnet-5', task: 'teach', is_active: true, is_default: false },
{ id: 3, name: 'Opus', model_id: 'claude-opus-5', task: 'extraction', is_active: true, is_default: true },
]
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: MODELS })
api.put.mockResolvedValue({ data: {} })
api.delete.mockResolvedValue({})
api.post.mockImplementation(url => {
if (url === '/admin/litellm/models') {
return Promise.resolve({ data: { models: ['claude-haiku-4-5', 'gpt-5', 'gemini-3'] } })
}
if (url === '/admin/models') {
return Promise.resolve({ data: { id: 9, name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false } })
}
return Promise.resolve({ data: { message: 'Answered' } })
})
})
describe('model selection', () => {
it('asks one question per job rather than showing rows of buttons', async () => {
render()
// The old screen needed you to find a card, find a row, and press Set
// Default on it — four buttons per row to express one fact.
expect(await screen.findByRole('combobox', { name: 'Model for Tutor' })).toHaveValue('1')
expect(screen.queryByRole('button', { name: 'Set Default' })).toBeNull()
})
it('changes a job by choosing, and moves the choice off the old model', async () => {
render()
const pick = await screen.findByRole('combobox', { name: 'Model for Tutor' })
api.get.mockClear()
await userEvent.selectOptions(pick, '2')
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/models/2',
{ is_default: true, is_active: true }))
expect(pick).toHaveValue('2')
// Updated in place, not by refetching the section.
expect(api.get).not.toHaveBeenCalled()
})
it('offers no choice for a job with one model, because there is none', async () => {
render()
await screen.findByRole('combobox', { name: 'Model for Tutor' })
expect(screen.queryByRole('combobox', { name: 'Model for Extraction' })).toBeNull()
expect(screen.getByText('Opus')).toBeInTheDocument()
})
it('says when a job has nothing configured instead of showing an empty box', async () => {
render()
await screen.findByRole('combobox', { name: 'Model for Tutor' })
expect(screen.getAllByText('Falls back to the site default').length).toBeGreaterThan(0)
})
it('tests the model a job actually uses', async () => {
render()
await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tutor' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/1/test'))
expect(await screen.findByText(/Answered/)).toBeInTheDocument()
})
it('names the tool model as a job of its own, and tests it like any other', async () => {
// The model that covers what another model cannot do — reading an image,
// today — is chosen the same way as the tutor's or the extractor's.
api.get.mockResolvedValue({ data: [...MODELS,
{ id: 4, name: 'gpt-4.1', model_id: 'gpt-4.1', task: 'tool', is_active: true, is_default: true }] })
render()
await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tool' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/4/test'))
})
it('keeps the allow-list folded away, since it is set once', async () => {
render()
await screen.findByRole('combobox', { name: 'Model for Tutor' })
expect(screen.queryByRole('button', { name: /List what the proxy offers/ })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
expect(screen.getByRole('button', { name: /List what the proxy offers/ })).toBeInTheDocument()
})
it('will not offer to allow a model that is already allowed', async () => {
render()
await screen.findByRole('combobox', { name: 'Model for Tutor' })
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ }))
const offered = await screen.findByRole('button', { name: 'Allow gpt-5' })
expect(offered).toBeInTheDocument()
// Already allowed for Tutor, so there is nothing to press.
expect(screen.queryByRole('button', { name: 'Allow claude-haiku-4-5' })).toBeNull()
})
it('adds an allowed model without reloading the section', async () => {
render()
await screen.findByRole('combobox', { name: 'Model for Tutor' })
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ }))
api.get.mockClear()
await userEvent.click(await screen.findByRole('button', { name: 'Allow gpt-5' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models', {
name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false,
}))
expect(api.get).not.toHaveBeenCalled()
})
})
it('turns a model off without losing it, and allows an id the proxy never listed', async () => {
// Two things ped-ai's page has and this one did not. Taking a model out
// while the proxy misbehaves used to mean removing it and setting it up
// again afterwards, losing the default flag and any key on it. And the
// roster could only ever hold what /model/info happened to advertise,
// though the gateway will route aliases it does not list.
api.get.mockImplementation(url => Promise.resolve({
data: url === '/admin/models'
? [{ id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction',
is_active: true, is_default: true }]
: {},
}))
api.put.mockResolvedValue({ data: {
id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction',
is_active: false, is_default: true } })
api.post.mockResolvedValue({ data: {
id: 2, name: 'brand-new-alias', model_id: 'brand-new-alias',
task: 'extraction', is_active: true, is_default: false } })
render()
// The roster is behind its own disclosure.
await userEvent.click(await screen.findByRole('button', { name: /Which models this site may use/ }))
// The roster shows one job at a time; ds-flash is an extraction model.
await userEvent.selectOptions(
await screen.findByLabelText('Job to allow a model for'), 'extraction')
await userEvent.click(await screen.findByRole('button', { name: /Turn off ds-flash/ }))
expect(api.put).toHaveBeenCalledWith('/admin/models/1', { is_active: false })
// Still listed, still the default — off, not gone.
expect(await screen.findByText('off')).toBeInTheDocument()
expect(screen.getByText('in use')).toBeInTheDocument()
await userEvent.type(
screen.getByLabelText('A model id the proxy does not list'), 'brand-new-alias')
await userEvent.click(screen.getByRole('button', { name: 'Allow it' }))
expect(api.post).toHaveBeenCalledWith('/admin/models', expect.objectContaining({
model_id: 'brand-new-alias', task: 'extraction', is_active: true,
}))
})