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/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..1ad7dbd 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,277 @@ 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() + }) + + 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('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..7cf7daf --- /dev/null +++ b/web/src/settings/RecipeSearchCard.tsx @@ -0,0 +1,275 @@ +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. 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 ?? saved.default_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 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(edited) + }, [edited, 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 })) + } + + 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..80f8c25 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,111 @@ 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 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 +} + +// 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) || + !isStringOrNull(body.default_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, + 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, + } +} + +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 +428,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..81d20c2 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -16,6 +16,17 @@ 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', + default_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 +36,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