From cb42ffa280013086580d29ec7b50eb56c18c9dc0 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 10:27:16 -0400 Subject: [PATCH 1/3] Recipe search card on the AI assistant screen Add a Recipe search card to /settings/ai: a SearXNG | Brave segmented control, the SearXNG URL pre-filled from the saved settings, or the Brave API key shown masked (last four characters) with Replace. The provider, URL and new key are a draft saved together with PATCH /api/v1/settings/recipe-search; Test calls the recipe-search test endpoint and shows "Found N recipes for 'chicken tikka masala'" or the error. The leave-without-saving guard moves to page level so it covers both the AI assistant form and the card; the shell test now renders in a data router as main.tsx does. Maestro task: 0698ae2c-f65e-408e-a9c6-198edc2c5696 Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 3 +- web/src/settings/AISettingsPage.module.css | 30 +++ web/src/settings/AISettingsPage.test.tsx | 192 ++++++++++++++- web/src/settings/AISettingsPage.tsx | 71 +++--- web/src/settings/RecipeSearchCard.tsx | 259 +++++++++++++++++++++ web/src/settings/api.ts | 107 +++++++++ web/src/shell/AppShell.test.tsx | 6 +- web/src/test/ApiProviders.tsx | 11 + 8 files changed, 646 insertions(+), 33 deletions(-) create mode 100644 web/src/settings/RecipeSearchCard.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f144369..77ff5db 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' -import { AI_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { InDataRouter } from './test/dataRouter.tsx' afterEach(() => { @@ -80,6 +80,7 @@ describe('App routes', () => { '/api/v1/me': () => jsonResponse(200, ME), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), '/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }), + '/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), }) renderAt('/settings/ai') const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' }) diff --git a/web/src/settings/AISettingsPage.module.css b/web/src/settings/AISettingsPage.module.css index a004dbe..9c477dc 100644 --- a/web/src/settings/AISettingsPage.module.css +++ b/web/src/settings/AISettingsPage.module.css @@ -251,3 +251,33 @@ justify-content: flex-end; gap: 10px; } + +/* Recipe search card, below the AI assistant's actions. */ +.card { + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid var(--color-neutral-300); +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.cardLoading { + height: 160px; + border-radius: var(--radius-lg); +} + +.cardActions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 10px; +} + +.cardActions > button { + min-height: 52px; +} diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx index 4ddfc9f..af9ed55 100644 --- a/web/src/settings/AISettingsPage.test.tsx +++ b/web/src/settings/AISettingsPage.test.tsx @@ -4,11 +4,12 @@ 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 } from '../test/ApiProviders.tsx' +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, maskedKey } from './AISettingsPage.tsx' +import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx' +import { maskedKey } from './api.ts' const MODELS = { models: [ @@ -30,6 +31,7 @@ function stubAPI(responders: Record = {}): Mock '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) => { @@ -422,6 +424,192 @@ describe('AISettingsPage', () => { }) }) +// 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() + }) + + it('starts on SearXNG with an empty URL before recipe search is set up', async () => { + stubAPI({ + 'GET /api/v1/settings/recipe-search': () => + jsonResponse(200, { provider: null, base_url: null, api_key_set: false, api_key_last4: null, updated_at: null }), + }) + await renderCard() + expect(searxng()).toHaveAttribute('aria-checked', 'true') + expect(url()).toHaveValue('') + expect(testSearch()).toBeDisabled() + expect(saveSearch()).toBeEnabled() + }) + + 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('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('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') diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx index 3aecb69..18b8432 100644 --- a/web/src/settings/AISettingsPage.tsx +++ b/web/src/settings/AISettingsPage.tsx @@ -14,6 +14,7 @@ import { TextInput } from '../ui/TextInput.tsx' import { aiKeyPolicy, aiProviderLabels, + maskedKey, providersWithBaseURL, useAIModels, useAISettings, @@ -26,6 +27,7 @@ import { type AISettingsPatch, } from './api.ts' import styles from './AISettingsPage.module.css' +import { RecipeSearchCard } from './RecipeSearchCard.tsx' // TYPE_MODEL is the Model dropdown's "Type a model id" option. const TYPE_MODEL = '\u0000type' @@ -33,9 +35,12 @@ const TYPE_MODEL = '\u0000type' // Admin · AI assistant (handoff mock 1o). Provider, model, temperature and // system prompt are edited as a draft and saved together; the API key is // stored (and verified) on its own, straight away. The client only ever -// sees the key's last four characters. +// sees the key's last four characters. Recipe search is its own card, saved +// on its own. export function AISettingsPage() { const settings = useAISettings() + const [aiDirty, setAIDirty] = useState(false) + const [recipeSearchDirty, setRecipeSearchDirty] = useState(false) return (
@@ -45,11 +50,38 @@ export function AISettingsPage() { AI settings could not load: {settings.error.message} )} - {settings.isSuccess && } + {settings.isSuccess && } + +
) } +// LeaveGuard asks before leaving the screen with unsaved changes. +function LeaveGuard({ dirty }: { dirty: boolean }) { + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname, + ) + useEffect(() => { + if (!dirty) return + const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault() + window.addEventListener('beforeunload', onBeforeUnload) + return () => window.removeEventListener('beforeunload', onBeforeUnload) + }, [dirty]) + return ( + blocker.reset?.()} + onConfirm={() => blocker.proceed?.()} + > + Your changes on this screen have not been saved. + + ) +} + function Header() { const navigate = useNavigate() return ( @@ -95,7 +127,13 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch { return patch } -function AISettingsForm({ saved }: { saved: AISettings }) { +type AISettingsFormProps = { + saved: AISettings + // Called with whether the form holds unsaved changes. + onDirtyChange: (dirty: boolean) => void +} + +function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) { const [draft, setDraft] = useState(() => draftOf(saved)) const [typingModel, setTypingModel] = useState(false) const [confirmingReset, setConfirmingReset] = useState(false) @@ -116,15 +154,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) { const listed = canList && models.isSuccess ? models.data : [] const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model)) - const blocker = useBlocker( - ({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname, - ) useEffect(() => { - if (!dirty) return - const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault() - window.addEventListener('beforeunload', onBeforeUnload) - return () => window.removeEventListener('beforeunload', onBeforeUnload) - }, [dirty]) + onDirtyChange(dirty) + }, [dirty, onDirtyChange]) + useEffect(() => () => onDirtyChange(false), [onDirtyChange]) function edit(change: Partial) { save.reset() @@ -285,26 +318,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) { > This restores MealQuest's default system prompt for everyone in the household. - blocker.reset?.()} - onConfirm={() => blocker.proceed?.()} - > - Your changes to the AI assistant have not been saved. - ) } -// maskedKey shows the stored key as dots and its last four characters, all -// the client ever knows of it. -export function maskedKey(last4: string): string { - return `••••••••${last4}` -} - // formatVerifiedDate turns key_verified_at into the handoff's "Oct 1". export function formatVerifiedDate(timestamp: string): string { const date = new Date(timestamp) diff --git a/web/src/settings/RecipeSearchCard.tsx b/web/src/settings/RecipeSearchCard.tsx new file mode 100644 index 0000000..1aa12ad --- /dev/null +++ b/web/src/settings/RecipeSearchCard.tsx @@ -0,0 +1,259 @@ +import { useEffect, useId, useState } from 'react' +import { ApiError } from '../api/index.ts' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { + maskedKey, + recipeSearchProviderLabels, + useRecipeSearchSettings, + useSaveRecipeSearchSettings, + useTestRecipeSearch, + type RecipeSearchProvider, + type RecipeSearchSettings, + type RecipeSearchSettingsPatch, +} from './api.ts' +import styles from './AISettingsPage.module.css' + +const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({ + value: value as RecipeSearchProvider, + label, +})) + +type RecipeSearchCardProps = { + // Called with whether the card holds unsaved changes. + onDirtyChange: (dirty: boolean) => void +} + +// RecipeSearchCard is where recipes are searched for: a SearXNG instance or +// Brave. The provider, SearXNG URL and a new Brave key are edited as a draft +// and saved together; the client only ever sees the stored key's last four +// characters. Test searches with the saved settings. +export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) { + const settings = useRecipeSearchSettings() + const titleId = useId() + return ( + +

+ Recipe search +

+

Where MealQuest looks for real recipes behind each Side quest.

+ {settings.isPending && } + {settings.isError && ( + void settings.refetch()}> + Recipe search settings could not load: {settings.error.message} + + )} + {settings.isSuccess && } +
+ ) +} + +type Draft = { + provider: RecipeSearchProvider + base_url: string + // A new Brave key to store, null when the stored one stays. + api_key: string | null +} + +// draftOf starts a household that has not set up recipe search on SearXNG, +// the sidecar every deployment runs. +function draftOf(saved: RecipeSearchSettings): Draft { + return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? '', api_key: null } +} + +// patchOf is the PATCH body for what draft changes. Only the chosen +// provider's field is sent: the other provider's is left as it is. +function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch { + const patch: RecipeSearchSettingsPatch = {} + if (draft.provider !== saved.provider) patch.provider = draft.provider + if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url + if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key + return patch +} + +function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) { + const [draft, setDraft] = useState(() => draftOf(saved)) + const save = useSaveRecipeSearchSettings() + const test = useTestRecipeSearch() + const patch = patchOf(saved, draft) + const dirty = Object.keys(patch).length > 0 + + useEffect(() => { + onDirtyChange(dirty) + }, [dirty, onDirtyChange]) + useEffect(() => () => onDirtyChange(false), [onDirtyChange]) + + function edit(change: Partial) { + save.reset() + setDraft((current) => ({ ...current, ...change })) + } + + function onSave() { + save.mutate(patch, { + onSuccess: (settings) => { + setDraft(draftOf(settings)) + test.reset() + }, + }) + } + + return ( + <> + edit({ provider })} + /> + {draft.provider === 'searxng' ? ( + edit({ base_url: event.target.value })} + /> + ) : ( + edit({ api_key })} + /> + )} + +
+ {save.isSuccess && !dirty && 'Recipe search saved.'} + {test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`} +
+ {save.isError && ( +

+ {saveErrorMessage(save.error)} +

+ )} + {test.isError && ( +

+ {testErrorMessage(test.error)} +

+ )} + {dirty && saved.provider !== null &&

Save your changes to test them.

} + +
+ + +
+ + ) +} + +type BraveKeyProps = { + last4: string | null + newKey: string | null + onKey: (key: string | null) => void +} + +// BraveKey shows the stored key masked, or the new one waiting to be saved, +// and takes a new key in a sheet. The key is sent only when the card is +// saved. +function BraveKey({ last4, newKey, onKey }: BraveKeyProps) { + const [replacing, setReplacing] = useState(false) + const [key, setKey] = useState('') + const labelId = useId() + const sheetTitleId = useId() + + function close() { + setReplacing(false) + setKey('') + } + + let shown = 'No key stored' + if (newKey !== null) shown = maskedKey(newKey.slice(-4)) + else if (last4 !== null) shown = maskedKey(last4) + + return ( +
+ + Brave API key + +
+ {shown} + +
+ {newKey !== null && ( +
+

New key not saved yet

+ +
+ )} + + +
{ + event.preventDefault() + onKey(key) + close() + }} + > +

+ {last4 === null ? 'Add Brave API key' : 'Replace Brave API key'} +

+

+ The key is stored on the server when you save recipe search. This device only ever sees its last four + characters. +

+ setKey(event.target.value)} + /> +
+ + +
+ +
+
+ ) +} + +function saveErrorMessage(error: Error): string { + if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.` + return `Not saved: ${error.message}` +} + +// testErrorMessage says why POST /settings/recipe-search/test failed, by +// the error code the server names (internal/server/recipesearch.go). +function testErrorMessage(error: Error): string { + if (error instanceof ApiError && error.code === 'not_configured') { + return 'Test failed: recipe search is not set up yet.' + } + return `Test failed: ${error.message}` +} diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 399dc70..ea7b0ed 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -8,6 +8,8 @@ export const AI_MODELS_PATH = '/api/v1/settings/ai/models' export const AI_KEY_PATH = '/api/v1/settings/ai/api-key' export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify' export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt' +export const RECIPE_SEARCH_PATH = '/api/v1/settings/recipe-search' +export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test' // TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's // dinners read API. Until it lands the server answers 404 and the Regular // dinners row says the count is unavailable. @@ -192,6 +194,105 @@ export function useResetAIPrompt() { return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined) } +// recipeSearchProviderLabels names every recipe search provider the server +// accepts (internal/recipes/searchsettings.go). +export const recipeSearchProviderLabels = { + searxng: 'SearXNG', + brave: 'Brave', +} as const + +export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels + +// RecipeSearchSettings is GET /api/v1/settings/recipe-search. The Brave API +// key itself is never sent: only whether one is stored and its last four +// characters. +export type RecipeSearchSettings = { + // Null until recipe search is first set up. + provider: RecipeSearchProvider | null + // The SearXNG instance's URL, null when none is set. + base_url: string | null + api_key_set: boolean + api_key_last4: string | null + updated_at: string | null +} + +// RecipeSearchSettingsPatch is the PATCH /api/v1/settings/recipe-search +// body: only the fields that change. api_key is the Brave key in plain text. +export type RecipeSearchSettingsPatch = { + provider?: RecipeSearchProvider + base_url?: string + api_key?: string +} + +// RecipeSearchTest is POST /api/v1/settings/recipe-search/test: how many +// recipes the saved provider found for query. +export type RecipeSearchTest = { query: string; count: number } + +function isRecipeSearchProvider(value: string): value is RecipeSearchProvider { + return Object.hasOwn(recipeSearchProviderLabels, value) +} + +function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSettings { + if ( + !isRecord(body) || + !isStringOrNull(body.provider) || + !isStringOrNull(body.base_url) || + typeof body.api_key_set !== 'boolean' || + !isStringOrNull(body.api_key_last4) || + !isStringOrNull(body.updated_at) + ) { + throw new Error(`${where}: response is not the recipe search settings`) + } + if (body.provider !== null && !isRecipeSearchProvider(body.provider)) { + throw new Error(`${where}: unknown recipe search provider "${body.provider}"`) + } + return { + provider: body.provider, + base_url: body.base_url, + api_key_set: body.api_key_set, + api_key_last4: body.api_key_last4, + updated_at: body.updated_at, + } +} + +const RECIPE_SEARCH_KEY = ['settings', 'recipe-search'] as const + +export function useRecipeSearchSettings() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: RECIPE_SEARCH_KEY, + queryFn: async () => + parseRecipeSearchSettings(`GET ${RECIPE_SEARCH_PATH}`, await apiFetch(RECIPE_SEARCH_PATH)), + }) +} + +export function useSaveRecipeSearchSettings() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (patch: RecipeSearchSettingsPatch) => + parseRecipeSearchSettings( + `PATCH ${RECIPE_SEARCH_PATH}`, + await apiFetch(RECIPE_SEARCH_PATH, { method: 'PATCH', body: patch }), + ), + onSuccess: (settings) => queryClient.setQueryData(RECIPE_SEARCH_KEY, settings), + }) +} + +// useTestRecipeSearch searches with the saved settings. +export function useTestRecipeSearch() { + const { apiFetch } = useApi() + return useMutation({ + mutationFn: async () => { + const body = await apiFetch(RECIPE_SEARCH_TEST_PATH, { method: 'POST' }) + if (!isRecord(body) || typeof body.query !== 'string' || typeof body.count !== 'number') { + throw new Error(`POST ${RECIPE_SEARCH_TEST_PATH}: response is not a search test result`) + } + return { query: body.query, count: body.count } satisfies RecipeSearchTest + }, + }) +} + const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ // useHouseholdSettings refuses a dinner time that is not HH:MM, so @@ -321,6 +422,12 @@ export function notificationsStatus(status: NotificationsStatus | null): string return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}` } +// maskedKey shows a stored key as dots and its last four characters, all +// the client ever knows of it. +export function maskedKey(last4: string): string { + return `••••••••${last4}` +} + // formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm". export function formatClockTime(time: string): string { const match = clockTime.exec(time) diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 153c5de..1f2076d 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -1,9 +1,9 @@ import { render, screen, within } from '@testing-library/react' -import { MemoryRouter } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { AppRoutes } from '../App.tsx' import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx' import { declaredStyle, outerSize } from '../test/cssRules.ts' +import { InDataRouter } from '../test/dataRouter.tsx' import { expectMinHitArea } from '../test/hitArea.ts' import { stubFetchByPath } from '../test/fetchStub.ts' import { mockViewport } from '../test/matchMedia.ts' @@ -25,11 +25,11 @@ function renderAt(path: string, width: number) { const resize = mockViewport(width) stubFetchByPath(SETTINGS_API) render( - + - , + , ) return resize } diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index b0c1650..bad365d 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -16,6 +16,16 @@ export const AI_SETTINGS = { updated_at: '2026-10-01T15:00:00.000Z', } +// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the +// SearXNG sidecar, no Brave key stored. +export const RECIPE_SEARCH_SETTINGS = { + provider: 'searxng', + base_url: 'http://searxng:8080', + api_key_set: false, + api_key_last4: null, + updated_at: '2026-10-01T15:00:00.000Z', +} + // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as // today's server does: the dinners, Google Calendar and notifications status // endpoints do not exist yet. @@ -25,6 +35,7 @@ export const SETTINGS_API = { '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), '/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + '/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), } // ApiProviders gives the pages under test what App gives them: an API client -- 2.55.0 From 938b408deb3702a3a4eadfbf5f7039262349922b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 10:41:31 -0400 Subject: [PATCH 2/3] Pre-fill the SearXNG URL from the deployment default The service now reads the optional MEALQUEST_SEARXNG_URL, validates it at startup like any SearXNG URL, and returns it in the recipe search settings as the read-only default_base_url. base_url stays the saved URL only, and PATCH is unchanged: nothing is stored until the household saves. The recipe search card pre-fills the SearXNG URL from default_base_url while no URL is saved, and an edit now clears the last Test result so a stale count or error never sits beside "Save your changes to test them." Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++- cmd/server/main.go | 4 +- deploy/.env.example | 8 +-- deploy/RUNBOOK.md | 24 +++++--- docs/ARCHITECTURE.md | 3 + internal/config/config.go | 11 ++++ internal/config/config_test.go | 5 ++ internal/recipes/searchsettings.go | 57 ++++++++++++------ internal/recipes/searchsettings_test.go | 77 +++++++++++++++++++++++- internal/server/recipesearch_test.go | 2 +- internal/server/server_test.go | 2 +- web/src/settings/AISettingsPage.test.tsx | 72 ++++++++++++++++++++-- web/src/settings/RecipeSearchCard.tsx | 12 +++- web/src/settings/api.ts | 8 ++- web/src/test/ApiProviders.tsx | 1 + 15 files changed, 250 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f9f43e2..97d7c82 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset # trusts no peer, which is right for local development. # export MEALQUEST_TRUSTED_PROXY=10.60.1.33 +# Optional: a SearXNG instance the Settings screen suggests as the recipe +# search URL until one is saved. +# export MEALQUEST_SEARXNG_URL=http://localhost:8888 export MEALQUEST_DEFAULT_TZ=America/New_York make dev ``` @@ -53,8 +56,9 @@ make dev http://localhost:5173. Configuration is environment-only and fails fast: every variable above is required except the commented-out session lifetimes (when set they must be positive, with MAX at least IDLE), -trusted proxy list (every entry must be an IP or CIDR) and -`MEALQUEST_DEFAULT_TZ`, and +trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL +(an absolute `http://` or `https://` URL without credentials, a query or a +fragment) and `MEALQUEST_DEFAULT_TZ`, and secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the endpoints the issuer advertises must be `https://`; plain `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. diff --git a/cmd/server/main.go b/cmd/server/main.go index c23b5db..4b78236 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -79,9 +79,9 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } - search, err := recipes.NewSearchSettingsService(db, box) + search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL) if err != nil { - return err + return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err) } ai, err := aisettings.New(db, box, cfg.PublicURL.String()) diff --git a/deploy/.env.example b/deploy/.env.example index a21cf4a..a908b3f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h # MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret # The Authentik provider's client secret. # MEALQUEST_SEARXNG_URL=http://searxng:8080 -# The SearXNG recipe-search sidecar on the compose network. Planned; not -# read by the service yet. The app takes its recipe-search provider and -# base URL from PATCH /api/v1/settings/recipe-search; see -# deploy/RUNBOOK.md "Recipe search (SearXNG)". +# The SearXNG recipe-search sidecar on the compose network. The Settings +# screen pre-fills it as the SearXNG URL until one is saved; the saved +# provider and base URL come from PATCH /api/v1/settings/recipe-search. +# See deploy/RUNBOOK.md "Recipe search (SearXNG)". # --- Planned; not read by the service yet ------------------------------------- # diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index fe12662..14adb79 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG instance for recipe search. On the compose network it is reachable at `http://searxng:8080`; that is the address to configure in the app. -Starting the sidecar does not connect the app to it. The service does not -read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future -wiring, and today it does nothing. The recipe-search provider and base URL -live in the `recipe_search_settings` row and are set through -`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings -screen's recipe search; the screen itself is not built yet). Until that row -exists, recipe search answers 409 `not_configured`. +Starting the sidecar does not connect the app to it. The recipe-search +provider and base URL live in the `recipe_search_settings` row and are set +through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the +recipe search card on Settings → AI assistant). Until that row exists, +recipe search answers 409 `not_configured`. -After the first deploy, from a signed-in session (every non-`GET` request +`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`. +The service only suggests it: `GET` returns it as `default_base_url`, and +the recipe search card pre-fills the SearXNG URL with it while no URL is +saved. Nothing is stored until the card is saved, and a saved `base_url` +always wins. A set value must be an absolute `http://` or `https://` URL +without credentials, a query or a fragment, or the service refuses to +start; unset, `default_base_url` is `null`. + +To set it up, open Settings → AI assistant, check that the recipe search +card shows SearXNG with `http://searxng:8080`, select **Save recipe search** +and then **Test**. Or, from a signed-in session (every non-`GET` request needs the `X-MealQuest-CSRF` header set to `csrfToken` from `GET /api/v1/me`): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c6e0e82..226ab04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -149,6 +149,9 @@ automatic** (D-021). the Brave key (`api_key`, write-only: responses carry only `api_key_set` and `api_key_last4`; an empty string removes it). The result must be usable: SearXNG needs a base_url, Brave a key. + Responses also carry the read-only `default_base_url`, the deployment's + `MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no + base_url is saved; only a PATCH of base_url stores a URL. `POST /api/v1/settings/recipe-search/test` searches "chicken tikka masala" and returns `{query, count}`, 409 `not_configured`, or 502 `search_failed` with the provider's error. diff --git a/internal/config/config.go b/internal/config/config.go index 1c1dfd7..c838255 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,8 @@ const ( EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ" + + EnvSearXNGURL = "MEALQUEST_SEARXNG_URL" ) // Session lifetime defaults (D-004): long enough that the kitchen tablet @@ -73,6 +75,13 @@ type Config struct { // validates it only while the settings do not exist yet, and ignores it // once they do. Empty when unset. DefaultTZ string + + // SearXNGURL is the deployment's SearXNG instance, which the Settings + // screen suggests while no recipe-search URL is saved. It is optional + // and validated by recipes.NewSearchSettingsService: the compose + // network's http://searxng:8080 is neither https nor loopback, so the + // secure-URL rule does not apply. Empty when unset. + SearXNGURL string } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { TrustedProxy: r.trustedProxy(EnvTrustedProxy), DefaultTZ: r.optional(EnvDefaultTZ), + + SearXNGURL: r.optional(EnvSearXNGURL), } if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 937474d..91e24c7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) { EnvOIDCAllowedGroups: " mealquest-admin ,family", EnvDefaultTZ: "Europe/London", + + EnvSearXNGURL: "http://searxng:8080", } cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) if err != nil { @@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) { if cfg.DefaultTZ != "Europe/London" { t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ) } + if cfg.SearXNGURL != "http://searxng:8080" { + t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL) + } } func TestSecretRedacted(t *testing.T) { diff --git a/internal/recipes/searchsettings.go b/internal/recipes/searchsettings.go index 55d148b..39c8f5c 100644 --- a/internal/recipes/searchsettings.go +++ b/internal/recipes/searchsettings.go @@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure type SearchSettings struct { // Provider is "searxng" or "brave", nil until first chosen. Provider *string `json:"provider"` - // BaseURL is the SearXNG instance's URL, nil when not set. - BaseURL *string `json:"base_url"` - APIKeySet bool `json:"api_key_set"` - APIKeyLast4 *string `json:"api_key_last4"` + // BaseURL is the saved SearXNG instance's URL, nil when not set. + BaseURL *string `json:"base_url"` + // DefaultBaseURL is the deployment's SearXNG URL + // (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is + // not set. It is never saved by itself: only a PATCH of base_url saves + // a URL. Nil when the deployment names none. + DefaultBaseURL *string `json:"default_base_url"` + APIKeySet bool `json:"api_key_set"` + APIKeyLast4 *string `json:"api_key_last4"` // UpdatedAt is nil until the settings are first saved. UpdatedAt *string `json:"updated_at"` } @@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string { type SearchSettingsService struct { st *store.Store box *secretbox.Box + // defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none. + defaultBaseURL string // newProvider is NewSearchProvider; tests point Brave elsewhere. newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error) } // NewSearchSettingsService returns the service over st, sealing keys with -// box. -func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) { +// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set +// one must be a valid SearXNG URL. +func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) { if st == nil { return nil, errors.New("recipes: nil store") } if box == nil { return nil, errors.New("recipes: nil secret box") } - return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil + if defaultBaseURL != "" { + if _, err := NewSearXNG(defaultBaseURL); err != nil { + return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: ")) + } + } + return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil } -// Get returns the settings; before any are saved every field is unset. +// Get returns the settings; before any are saved every field but +// DefaultBaseURL is unset. func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) { row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx) if errors.Is(err, sql.ErrNoRows) { - return SearchSettings{}, nil + return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil } if err != nil { return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err) } - return searchSettingsFrom(row), nil + return s.settingsFrom(row), nil +} + +func (s *SearchSettingsService) defaultBase() *string { + if s.defaultBaseURL == "" { + return nil + } + u := s.defaultBaseURL + return &u } // Update applies patch and returns the settings it leaves. The first update @@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings if err != nil { return SearchSettings{}, err } - return searchSettingsFrom(saved), nil + return s.settingsFrom(saved), nil } // apply returns row with patch applied, validated. @@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) { return len(cands), nil } -func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings { +func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings { provider, updated := row.Provider, row.UpdatedAt return SearchSettings{ - Provider: &provider, - BaseURL: nullString(row.BaseUrl), - APIKeySet: len(row.ApiKeyCt) > 0, - APIKeyLast4: nullString(row.ApiKeyLast4), - UpdatedAt: &updated, + Provider: &provider, + BaseURL: nullString(row.BaseUrl), + DefaultBaseURL: s.defaultBase(), + APIKeySet: len(row.ApiKeyCt) > 0, + APIKeyLast4: nullString(row.ApiKeyLast4), + UpdatedAt: &updated, } } diff --git a/internal/recipes/searchsettings_test.go b/internal/recipes/searchsettings_test.go index 0de2ad5..d2dcc66 100644 --- a/internal/recipes/searchsettings_test.go +++ b/internal/recipes/searchsettings_test.go @@ -19,6 +19,13 @@ import ( const testBraveKey = "BSA-secret-brave-key-9f3c" func newTestSearchSettings(t *testing.T) *SearchSettingsService { + t.Helper() + return newTestSearchSettingsWithDefault(t, "") +} + +// newTestSearchSettingsWithDefault is newTestSearchSettings with +// defaultBaseURL as the deployment's SearXNG URL. +func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService { t.Helper() st, err := store.Open(t.Context(), t.TempDir()) if err != nil { @@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService { if err != nil { t.Fatal(err) } - svc, err := NewSearchSettingsService(st, box) + svc, err := NewSearchSettingsService(st, box, defaultBaseURL) if err != nil { t.Fatal(err) } @@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) { } } +func TestSearchSettingsDefaultBaseURL(t *testing.T) { + t.Parallel() + svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080") + got, err := svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil { + t.Errorf("Get before any update = %+v, want only default_base_url set", got) + } + if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) { + t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err) + } + + // The default is a suggestion: SearXNG still needs a saved URL. + if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil { + t.Error("choosing SearXNG without a URL succeeded with a default set") + } + + got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")}) + if err != nil { + t.Fatal(err) + } + if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" { + t.Errorf("Update result = %+v, want the saved URL beside the default", got) + } + got, err = svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + if *got.BaseURL != "https://search.example" { + t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL) + } + row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context()) + if err != nil { + t.Fatal(err) + } + if row.BaseUrl.String != "https://search.example" { + t.Errorf("stored base_url = %q", row.BaseUrl.String) + } +} + +func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) { + t.Parallel() + st, err := store.Open(t.Context(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} { + if _, err := NewSearchSettingsService(st, box, u); err == nil { + t.Errorf("NewSearchSettingsService(%q) succeeded", u) + } else if strings.Contains(err.Error(), "pw") { + t.Errorf("error for %q echoes the URL: %v", u, err) + } + } +} + func TestSearchSettingsTestSearch(t *testing.T) { t.Parallel() searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 4b60f41..09ec29e 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) { } rec, body := call(http.MethodGet, recipeSearchPath, "") - if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false { + if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil { t.Errorf("GET unconfigured = %d %v", rec.Code, body) } rec, body = call(http.MethodPost, recipeSearchTestPath, "") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8e16ff6..1704af7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -132,7 +132,7 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache { // configured, over a migrated temp database and a random secret box. func newTestSearch(t *testing.T) *recipes.SearchSettingsService { t.Helper() - search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t)) + search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "") if err != nil { t.Fatal(err) } diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx index af9ed55..903fabd 100644 --- a/web/src/settings/AISettingsPage.test.tsx +++ b/web/src/settings/AISettingsPage.test.tsx @@ -462,16 +462,52 @@ describe('Recipe search card', () => { expect(testSearch()).toBeEnabled() }) - it('starts on SearXNG with an empty URL before recipe search is set up', async () => { - stubAPI({ - 'GET /api/v1/settings/recipe-search': () => - jsonResponse(200, { provider: null, base_url: null, api_key_set: false, api_key_last4: null, updated_at: null }), + 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('') + 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 () => { @@ -587,6 +623,32 @@ describe('Recipe search card', () => { ) }) + 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() diff --git a/web/src/settings/RecipeSearchCard.tsx b/web/src/settings/RecipeSearchCard.tsx index 1aa12ad..9d54064 100644 --- a/web/src/settings/RecipeSearchCard.tsx +++ b/web/src/settings/RecipeSearchCard.tsx @@ -61,9 +61,14 @@ type Draft = { } // draftOf starts a household that has not set up recipe search on SearXNG, -// the sidecar every deployment runs. +// the sidecar every deployment runs. With no URL saved the field holds the +// deployment's SearXNG URL, which saving stores. function draftOf(saved: RecipeSearchSettings): Draft { - return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? '', api_key: null } + return { + provider: saved.provider ?? 'searxng', + base_url: saved.base_url ?? saved.default_base_url ?? '', + api_key: null, + } } // patchOf is the PATCH body for what draft changes. Only the chosen @@ -88,8 +93,11 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting }, [dirty, onDirtyChange]) useEffect(() => () => onDirtyChange(false), [onDirtyChange]) + // An edit clears the last save and test results: they describe the saved + // settings, not the draft. function edit(change: Partial) { save.reset() + test.reset() setDraft((current) => ({ ...current, ...change })) } diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index ea7b0ed..80f8c25 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -209,8 +209,12 @@ export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels export type RecipeSearchSettings = { // Null until recipe search is first set up. provider: RecipeSearchProvider | null - // The SearXNG instance's URL, null when none is set. + // The saved SearXNG instance's URL, null when none is set. base_url: string | null + // The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while + // base_url is null; null when the deployment names none. Only saving + // base_url stores a URL. + default_base_url: string | null api_key_set: boolean api_key_last4: string | null updated_at: string | null @@ -237,6 +241,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe !isRecord(body) || !isStringOrNull(body.provider) || !isStringOrNull(body.base_url) || + !isStringOrNull(body.default_base_url) || typeof body.api_key_set !== 'boolean' || !isStringOrNull(body.api_key_last4) || !isStringOrNull(body.updated_at) @@ -249,6 +254,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe return { provider: body.provider, base_url: body.base_url, + default_base_url: body.default_base_url, api_key_set: body.api_key_set, api_key_last4: body.api_key_last4, updated_at: body.updated_at, diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index bad365d..81d20c2 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -21,6 +21,7 @@ export const AI_SETTINGS = { export const RECIPE_SEARCH_SETTINGS = { provider: 'searxng', base_url: 'http://searxng:8080', + default_base_url: 'http://searxng:8080', api_key_set: false, api_key_last4: null, updated_at: '2026-10-01T15:00:00.000Z', -- 2.55.0 From 271b52a7f4ed6f12bcf986883df4a5045d30079b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 10:50:26 -0400 Subject: [PATCH 3/3] Count only real edits as unsaved recipe search changes Before recipe search is set up the card suggests SearXNG and the deployment's URL, which differ from the unset saved values. That suggestion marked the card dirty, so the leave guard warned about unsaved changes the household never made. The leave guard now fires only when the draft differs from the loaded baseline, which includes the suggestion. Save stays enabled so the suggestion can still be stored. Co-Authored-By: Claude Opus 5.5 --- web/src/settings/AISettingsPage.test.tsx | 23 +++++++++++++++++++++++ web/src/settings/RecipeSearchCard.tsx | 12 ++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx index 903fabd..1ad7dbd 100644 --- a/web/src/settings/AISettingsPage.test.tsx +++ b/web/src/settings/AISettingsPage.test.tsx @@ -657,6 +657,29 @@ describe('Recipe search card', () => { 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('keeps 44 px tap targets', async () => { stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) await renderCard() diff --git a/web/src/settings/RecipeSearchCard.tsx b/web/src/settings/RecipeSearchCard.tsx index 9d54064..7cf7daf 100644 --- a/web/src/settings/RecipeSearchCard.tsx +++ b/web/src/settings/RecipeSearchCard.tsx @@ -81,16 +81,24 @@ function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSetting return patch } +function sameDraft(a: Draft, b: Draft): boolean { + return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key +} + function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) { const [draft, setDraft] = useState(() => draftOf(saved)) const save = useSaveRecipeSearchSettings() const test = useTestRecipeSearch() const patch = patchOf(saved, draft) const dirty = Object.keys(patch).length > 0 + // Only the household's own edits are unsaved changes. Before recipe search + // is set up the untouched draft already differs from what is saved — it + // suggests SearXNG and the deployment's URL — but leaving it loses nothing. + const edited = dirty && !sameDraft(draft, draftOf(saved)) useEffect(() => { - onDirtyChange(dirty) - }, [dirty, onDirtyChange]) + onDirtyChange(edited) + }, [edited, onDirtyChange]) useEffect(() => () => onDirtyChange(false), [onDirtyChange]) // An edit clears the last save and test results: they describe the saved -- 2.55.0