import { QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx' import { maskedKey } from './api.ts' const MODELS = { models: [ { id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }, { id: 'claude-haiku-4-5', display_name: 'Claude Haiku' }, ], } const DEFAULT_PROMPT = 'You are MealQuest, the default persona.' type Responder = (body: unknown) => Response | Promise // stubAPI answers "METHOD path" from responders, each called with the // request's decoded JSON body; GET /settings/ai and /settings/ai/models // answer AI_SETTINGS and MODELS unless overridden. Any other request fails // the test. function stubAPI(responders: Record = {}): Mock { const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), 'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS), 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), ...responders, } const fetchMock = vi.fn((input, init) => { const key = `${init?.method ?? 'GET'} ${String(input)}` const respond = Object.hasOwn(all, key) ? all[key] : undefined if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined return Promise.resolve(respond(body)) }) vi.stubGlobal('fetch', fetchMock) return fetchMock } // sent lists the requests other than GETs, as "METHOD path" and body. function sent(fetchMock: Mock) { return fetchMock.mock.calls .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') .map(([input, init]) => ({ request: `${init?.method} ${String(input)}`, body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined, })) } afterEach(() => { vi.unstubAllGlobals() }) function CurrentPath() { return

{useLocation().pathname}

} // renderPage loads /me (for the CSRF token every write needs) and renders // the page at /settings/ai in a data router, so its leave guard works. async function renderPage() { const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() render( } /> } /> , ) await screen.findByRole('option', { name: 'Claude Sonnet' }) } const provider = () => screen.getByRole('combobox', { name: 'Provider' }) const model = () => screen.getByRole('combobox', { name: 'Model' }) const prompt = () => screen.getByRole('textbox', { name: 'System prompt' }) const slider = () => screen.getByRole('slider', { name: 'Creativity (temperature)' }) const save = () => screen.getByRole('button', { name: 'Save changes' }) describe('AISettingsPage', () => { it('loads the settings as mock 1o lays them out', async () => { stubAPI() await renderPage() expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument() expect(screen.getByText('Settings · Admin')).toBeInTheDocument() expect(screen.getByRole('heading', { level: 1, name: 'AI assistant' })).toBeInTheDocument() expect(provider()).toHaveValue('anthropic') expect(within(provider()).getByRole('option', { selected: true })).toHaveTextContent('Anthropic') expect(model()).toHaveValue('claude-sonnet-4-5') expect(within(model()).getAllByRole('option').map((o) => o.textContent)).toEqual([ 'Claude Sonnet', 'Claude Haiku', 'Type a model id', ]) expect(screen.getByText('••••••••3f9a')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument() expect(screen.getByText('Verified · Oct 1')).toBeInTheDocument() expect(slider()).toHaveValue('0.7') expect(slider()).toHaveAttribute('step', '0.1') expect(screen.getByText('0.7')).toBeInTheDocument() expect(screen.getByText('Familiar')).toBeInTheDocument() expect(screen.getByText('Adventurous')).toBeInTheDocument() expect(prompt()).toHaveValue(AI_SETTINGS.system_prompt) expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled() expect(save()).toBeDisabled() }) it('reports a load failure with a retry', async () => { stubAPI({ 'GET /api/v1/settings/ai': () => errorResponse(500, 'internal') }) const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) render( , ) expect(await screen.findByRole('alert')).toHaveTextContent('AI settings could not load: internal message') expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() }) it('saves only the edited fields', async () => { const fetchMock = stubAPI({ 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), }) await renderPage() fireEvent.change(model(), { target: { value: 'claude-haiku-4-5' } }) fireEvent.change(slider(), { target: { value: '0.4' } }) expect(screen.getByText('0.4')).toBeInTheDocument() fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } }) expect(save()).toBeEnabled() fireEvent.click(save()) expect(await screen.findByText('Changes saved.')).toBeInTheDocument() expect(sent(fetchMock)).toEqual([ { request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-haiku-4-5', temperature: 0.4, system_prompt: 'Keep it cozy.' }, }, ]) expect(save()).toBeDisabled() expect(model()).toHaveValue('claude-haiku-4-5') }) it('saves a typed model id', async () => { const fetchMock = stubAPI({ 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), }) await renderPage() expect(screen.queryByRole('textbox', { name: 'Model id' })).not.toBeInTheDocument() fireEvent.change(model(), { target: { value: within(model()).getByRole('option', { name: 'Type a model id' }).getAttribute('value') } }) fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'claude-opus-5-5' } }) fireEvent.click(save()) await screen.findByText('Changes saved.') expect(sent(fetchMock)).toEqual([{ request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-opus-5-5' } }]) }) it('explains that a stored key must be removed before switching provider', async () => { stubAPI({ 'PATCH /api/v1/settings/ai': () => jsonResponse(400, { error: { code: 'invalid_settings', message: 'invalid AI settings: provider: cannot change while an API key is stored: remove the key first', retryable: false, }, }), }) await renderPage() fireEvent.change(provider(), { target: { value: 'openai' } }) expect(screen.getByText(/An API key is stored\. Remove it \(Replace → Remove key\)/)).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } }) fireEvent.click(save()) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored') expect(alert).toHaveTextContent('Remove it (Replace → Remove key) before changing the provider or base URL.') }) it('gives the same guidance when a stored key locks the base URL', async () => { stubAPI({ 'GET /api/v1/settings/ai': () => jsonResponse(200, { ...AI_SETTINGS, provider: 'openai_compatible', base_url: 'https://llm.example.com/v1' }), 'PATCH /api/v1/settings/ai': () => jsonResponse(400, { error: { code: 'invalid_settings', message: 'invalid AI settings: base_url: cannot change while an API key is stored: remove the key first', retryable: false, }, }), }) await renderPage() fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'https://other.example.com/v1' } }) expect(screen.getByText(/An API key is stored\. Remove it/)).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() fireEvent.click(save()) expect(await screen.findByRole('alert')).toHaveTextContent( 'Remove it (Replace → Remove key) before changing the provider or base URL.', ) }) it('recovers a provider switch by removing the stored key, without undoing the switch', async () => { const fetchMock = stubAPI({ 'PUT /api/v1/settings/ai/api-key': () => jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }), 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null, ...(body as object) }), }) await renderPage() fireEvent.change(provider(), { target: { value: 'ollama' } }) fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'http://localhost:11434' } }) fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'llama3.2' } }) // The stored key stays in view, and removable, while Ollama is chosen. expect(screen.getByText('••••••••3f9a')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Replace' })) fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' })) expect(await screen.findByText('Ollama takes no API key.')).toBeInTheDocument() expect(screen.queryByText(/An API key is stored/)).not.toBeInTheDocument() expect(provider()).toHaveValue('ollama') fireEvent.click(save()) expect(await screen.findByText('Changes saved.')).toBeInTheDocument() expect(sent(fetchMock)).toEqual([ { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }, { request: 'PATCH /api/v1/settings/ai', body: { provider: 'ollama', base_url: 'http://localhost:11434', model: 'llama3.2' }, }, ]) }) it('replaces the key in a sheet, then verifies it', async () => { const verifiedAt = '2026-10-06T14:00:00.000Z' const fetchMock = stubAPI({ 'PUT /api/v1/settings/ai/api-key': () => jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), 'POST /api/v1/settings/ai/verify': () => jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: verifiedAt }), }) await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Replace' })) const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) const input = within(sheet).getByLabelText('API key') expect(input).toHaveAttribute('type', 'password') fireEvent.change(input, { target: { value: 'sk-ant-secret-b7c1' } }) fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) expect(await screen.findByText(`Verified · ${formatVerifiedDate(verifiedAt)}`)).toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.getByText('••••••••b7c1')).toBeInTheDocument() expect(sent(fetchMock)).toEqual([ { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } }, { request: 'POST /api/v1/settings/ai/verify', body: undefined }, ]) expect(document.body).not.toHaveTextContent('sk-ant-secret-b7c1') }) it('still verifies the key when the sheet is dismissed while it is being stored', async () => { let answerPut: (response: Response) => void = () => { throw new Error('PUT not sent') } const fetchMock = stubAPI({ 'PUT /api/v1/settings/ai/api-key': () => new Promise((resolve) => (answerPut = resolve)), 'POST /api/v1/settings/ai/verify': () => jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: '2026-10-06T14:00:00.000Z' }), }) await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Replace' })) const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-ant-secret-b7c1' } }) fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) await waitFor(() => expect(within(sheet).getByRole('button', { name: 'Saving…' })).toBeDisabled()) fireEvent.keyDown(sheet, { key: 'Escape' }) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled() act(() => answerPut(jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }))) expect(await screen.findByText('Verified · Oct 6')).toBeInTheDocument() expect(screen.getByText('••••••••b7c1')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() expect(sent(fetchMock)).toEqual([ { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } }, { request: 'POST /api/v1/settings/ai/verify', body: undefined }, ]) }) it('shows the verify error and verifies again on request', async () => { let verifyCalls = 0 stubAPI({ 'PUT /api/v1/settings/ai/api-key': () => jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), 'POST /api/v1/settings/ai/verify': () => { verifyCalls++ return verifyCalls === 1 ? errorResponse(502, 'auth_failed') : jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1' }) }, }) await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Replace' })) const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-wrong-b7c1' } }) fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) expect(await screen.findByRole('alert')).toHaveTextContent('Verification failed: the provider rejected this key.') expect(screen.queryByText(/^Verified ·/)).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Try again' })) expect(await screen.findByText('Verified · Oct 1')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('shows a refused key in the sheet', async () => { stubAPI({ 'PUT /api/v1/settings/ai/api-key': () => jsonResponse(400, { error: { code: 'invalid_settings', message: 'api_key: must be at least 8 characters', retryable: false }, }), }) await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Replace' })) const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'short' } }) fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) expect(await within(sheet).findByRole('alert')).toHaveTextContent( 'The key was not stored: api_key: must be at least 8 characters', ) }) it('removes the stored key from the sheet', async () => { const fetchMock = stubAPI({ 'PUT /api/v1/settings/ai/api-key': () => jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }), }) await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Replace' })) fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' })) expect(await screen.findByText('No key stored')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument() expect(sent(fetchMock)).toEqual([{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }]) }) it('resets the system prompt after confirming', async () => { const fetchMock = stubAPI({ 'POST /api/v1/settings/ai/reset-prompt': () => jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }), }) await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Reset' })) const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' }) fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' })) expect(sent(fetchMock)).toEqual([]) fireEvent.click(screen.getByRole('button', { name: 'Reset' })) fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Reset prompt' })) await waitFor(() => expect(prompt()).toHaveValue(DEFAULT_PROMPT)) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/ai/reset-prompt', body: undefined }]) expect(save()).toBeDisabled() }) it('asks before leaving with unsaved changes', async () => { stubAPI() await renderPage() fireEvent.change(prompt(), { target: { value: 'Unsaved.' } }) fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' }) fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) expect(prompt()).toHaveValue('Unsaved.') fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' })) expect(await screen.findByTestId('path')).toHaveTextContent('/settings') }) it('leaves without asking when nothing changed', async () => { stubAPI() await renderPage() fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) expect(await screen.findByTestId('path')).toHaveTextContent('/settings') }) it('guards a page unload only while there are unsaved changes', async () => { stubAPI() await renderPage() const unload = () => { const event = new Event('beforeunload', { cancelable: true }) act(() => void window.dispatchEvent(event)) return event.defaultPrevented } expect(unload()).toBe(false) fireEvent.change(slider(), { target: { value: '0.2' } }) expect(unload()).toBe(true) }) it('locks the form while a save is pending, and guards leaving until the save lands', async () => { let answer: (response: Response) => void = () => {} stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise((resolve) => (answer = resolve)) }) await renderPage() const unload = () => { const event = new Event('beforeunload', { cancelable: true }) act(() => void window.dispatchEvent(event)) return event.defaultPrevented } fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } }) fireEvent.click(save()) expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled() for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled() expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() expect(unload()).toBe(true) answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' })) expect(await screen.findByText('Changes saved.')).toBeInTheDocument() expect(prompt()).toBeEnabled() expect(prompt()).toHaveValue('Keep it cozy.') await waitFor(() => expect(unload()).toBe(false)) }) it('locks the form while a prompt reset is pending, even with the sheet closed', async () => { let answer: (response: Response) => void = () => {} stubAPI({ 'POST /api/v1/settings/ai/reset-prompt': () => new Promise((resolve) => (answer = resolve)), }) await renderPage() const unload = () => { const event = new Event('beforeunload', { cancelable: true }) act(() => void window.dispatchEvent(event)) return event.defaultPrevented } fireEvent.change(slider(), { target: { value: '0.2' } }) fireEvent.click(screen.getByRole('button', { name: 'Reset' })) const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' }) fireEvent.click(within(confirm).getByRole('button', { name: 'Reset prompt' })) expect(await within(confirm).findByRole('button', { name: 'Resetting…' })).toBeDisabled() fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled() expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() expect(save()).toBeDisabled() expect(unload()).toBe(true) answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT })) await waitFor(() => expect(prompt()).toBeEnabled()) expect(prompt()).toHaveValue(DEFAULT_PROMPT) expect(slider()).toHaveValue('0.2') expect(unload()).toBe(true) fireEvent.change(prompt(), { target: { value: 'After the reset.' } }) expect(prompt()).toHaveValue('After the reset.') }) it('keeps 44 px tap targets', async () => { stubAPI() await renderPage() for (const control of [ screen.getByRole('button', { name: 'Back to Settings' }), screen.getByRole('button', { name: 'Replace' }), provider(), model(), slider(), screen.getByRole('button', { name: 'Reset' }), save(), ]) { expectMinHitArea(control) } }) }) // apiError is an API error response carrying message. function apiError(status: number, code: string, message: string): Response { return jsonResponse(status, { error: { code, message, retryable: false } }) } const BRAVE_SETTINGS = { ...RECIPE_SEARCH_SETTINGS, provider: 'brave', api_key_set: true, api_key_last4: 'b7c2' } describe('Recipe search card', () => { const card = () => screen.getByRole('region', { name: 'Recipe search' }) const searxng = () => within(card()).getByRole('radio', { name: 'SearXNG' }) const brave = () => within(card()).getByRole('radio', { name: 'Brave' }) const url = () => within(card()).getByRole('textbox', { name: 'SearXNG URL' }) const saveSearch = () => within(card()).getByRole('button', { name: 'Save recipe search' }) const testSearch = () => within(card()).getByRole('button', { name: 'Test' }) async function renderCard() { await renderPage() await within(card()).findByRole('radiogroup', { name: 'Recipe search provider' }) } // addKey enters key in the Brave key sheet. function addKey(button: string, key: string) { fireEvent.click(within(card()).getByRole('button', { name: button })) const sheet = screen.getByRole('dialog') fireEvent.change(within(sheet).getByLabelText('Brave API key'), { target: { value: key } }) fireEvent.click(within(sheet).getByRole('button', { name: 'Use this key' })) } it('shows the saved SearXNG URL', async () => { stubAPI() await renderCard() expect(searxng()).toHaveAttribute('aria-checked', 'true') expect(url()).toHaveValue('http://searxng:8080') expect(within(card()).queryByText('Brave API key')).not.toBeInTheDocument() expect(saveSearch()).toBeDisabled() expect(testSearch()).toBeEnabled() }) const UNCONFIGURED = { provider: null, base_url: null, default_base_url: 'http://searxng:8080', api_key_set: false, api_key_last4: null, updated_at: null, } it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => { const fetchMock = stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED), 'PATCH /api/v1/settings/recipe-search': (body) => jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }), }) await renderCard() expect(searxng()).toHaveAttribute('aria-checked', 'true') expect(url()).toHaveValue('http://searxng:8080') expect(testSearch()).toBeDisabled() expect(saveSearch()).toBeEnabled() fireEvent.click(saveSearch()) expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() expect(sent(fetchMock)).toEqual([ { request: 'PATCH /api/v1/settings/recipe-search', body: { provider: 'searxng', base_url: 'http://searxng:8080' }, }, ]) }) it('starts with an empty URL when the deployment names no SearXNG', async () => { stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }), }) await renderCard() expect(url()).toHaveValue('') }) it('shows the saved URL, not the deployment\'s', async () => { stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }), }) await renderCard() expect(url()).toHaveValue('https://search.example') expect(saveSearch()).toBeDisabled() }) it('switches providers, keeping each provider\'s field', async () => { stubAPI() await renderCard() fireEvent.change(url(), { target: { value: 'https://search.example' } }) fireEvent.click(brave()) expect(brave()).toHaveAttribute('aria-checked', 'true') expect(within(card()).queryByRole('textbox', { name: 'SearXNG URL' })).not.toBeInTheDocument() expect(within(card()).getByText('No key stored')).toBeInTheDocument() expect(within(card()).getByRole('button', { name: 'Add key' })).toBeInTheDocument() fireEvent.click(searxng()) expect(url()).toHaveValue('https://search.example') }) it('saves a new SearXNG URL', async () => { const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': (body) => jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }), }) await renderCard() fireEvent.change(url(), { target: { value: 'https://search.example' } }) expect(testSearch()).toBeDisabled() expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() fireEvent.click(saveSearch()) expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() expect(sent(fetchMock)).toEqual([ { request: 'PATCH /api/v1/settings/recipe-search', body: { base_url: 'https://search.example' } }, ]) expect(saveSearch()).toBeDisabled() expect(testSearch()).toBeEnabled() }) it('saves a switch to Brave with its key, then shows only the key\'s last four characters', async () => { const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) await renderCard() fireEvent.click(brave()) addKey('Add key', 'BSA-secret-key-b7c2') expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() expect(within(card()).getByText('New key not saved yet')).toBeInTheDocument() expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2') fireEvent.click(saveSearch()) expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() expect(sent(fetchMock)).toEqual([ { request: 'PATCH /api/v1/settings/recipe-search', body: { provider: 'brave', api_key: 'BSA-secret-key-b7c2' } }, ]) expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() expect(within(card()).queryByText('New key not saved yet')).not.toBeInTheDocument() expect(within(card()).getByRole('button', { name: 'Replace' })).toBeInTheDocument() expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2') }) it('masks a stored Brave key and replaces it', async () => { const fetchMock = stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS), 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, { ...BRAVE_SETTINGS, api_key_last4: '9d0e' }), }) await renderCard() expect(brave()).toHaveAttribute('aria-checked', 'true') expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() expect(saveSearch()).toBeDisabled() addKey('Replace', 'BSA-other-key-9d0e') fireEvent.click(within(card()).getByRole('button', { name: 'Discard' })) expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() expect(saveSearch()).toBeDisabled() addKey('Replace', 'BSA-other-key-9d0e') fireEvent.click(saveSearch()) expect(await within(card()).findByText('••••••••9d0e')).toBeInTheDocument() expect(sent(fetchMock)).toEqual([ { request: 'PATCH /api/v1/settings/recipe-search', body: { api_key: 'BSA-other-key-9d0e' } }, ]) }) it('reports a save the server refuses', async () => { stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => apiError(400, 'invalid_settings', 'invalid recipe search settings: api_key: must be at least 8 characters'), }) await renderCard() fireEvent.click(brave()) addKey('Add key', 'short') fireEvent.click(saveSearch()) expect(await within(card()).findByRole('alert')).toHaveTextContent( 'Not saved: invalid recipe search settings: api_key: must be at least 8 characters.', ) }) it('shows how many recipes the test found', async () => { const fetchMock = stubAPI({ 'POST /api/v1/settings/recipe-search/test': () => jsonResponse(200, { query: 'chicken tikka masala', count: 12 }), }) await renderCard() fireEvent.click(testSearch()) expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument() expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/recipe-search/test', body: undefined }]) }) it('shows the error when the test fails', async () => { stubAPI({ 'POST /api/v1/settings/recipe-search/test': () => apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'), }) await renderCard() fireEvent.click(testSearch()) expect(await within(card()).findByRole('alert')).toHaveTextContent( 'Test failed: recipes: SearXNG answered 403 Forbidden', ) }) it('clears a test result when the settings are edited', async () => { stubAPI({ 'POST /api/v1/settings/recipe-search/test': () => jsonResponse(200, { query: 'chicken tikka masala', count: 12 }), }) await renderCard() fireEvent.click(testSearch()) expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument() fireEvent.change(url(), { target: { value: 'https://search.example' } }) expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument() }) it('clears a test error when the settings are edited', async () => { stubAPI({ 'POST /api/v1/settings/recipe-search/test': () => apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'), }) await renderCard() fireEvent.click(testSearch()) expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed') fireEvent.click(brave()) expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() expect(within(card()).queryByRole('alert')).not.toBeInTheDocument() }) it('asks before leaving with an unsaved recipe search change', async () => { stubAPI() await renderCard() fireEvent.change(url(), { target: { value: 'https://search.example' } }) fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument() }) it('does not count the suggested SearXNG URL as an unsaved change until it is edited', async () => { stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) }) await renderCard() const unload = () => { const event = new Event('beforeunload', { cancelable: true }) act(() => void window.dispatchEvent(event)) return event.defaultPrevented } expect(saveSearch()).toBeEnabled() expect(unload()).toBe(false) fireEvent.change(url(), { target: { value: 'https://search.example' } }) expect(unload()).toBe(true) fireEvent.change(url(), { target: { value: 'http://searxng:8080' } }) expect(unload()).toBe(false) }) it('leaves the untouched first-use card without asking', async () => { stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) }) await renderCard() fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) expect(await screen.findByTestId('path')).toHaveTextContent('/settings') }) it('locks the card while a save is pending, and guards leaving until the save lands', async () => { let answer: (response: Response) => void = () => {} stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise((resolve) => (answer = resolve)) }) await renderCard() const unload = () => { const event = new Event('beforeunload', { cancelable: true }) act(() => void window.dispatchEvent(event)) return event.defaultPrevented } fireEvent.change(url(), { target: { value: 'https://search.example' } }) fireEvent.click(saveSearch()) expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled() for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled() expect(unload()).toBe(true) answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' })) expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() expect(url()).toBeEnabled() expect(url()).toHaveValue('https://search.example') await waitFor(() => expect(unload()).toBe(false)) }) it('keeps 44 px tap targets', async () => { stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) await renderCard() for (const control of [ searxng(), brave(), within(card()).getByRole('button', { name: 'Replace' }), testSearch(), saveSearch(), ]) { expectMinHitArea(control) } }) }) describe('maskedKey', () => { it('shows only the last four characters', () => { expect(maskedKey('3f9a')).toBe('••••••••3f9a') }) })