Recipe search settings section (#83)
CI / ci (push) Successful in 1m56s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #83.
This commit is contained in:
2026-10-06 10:59:31 -04:00
committed by approver_agent
parent e9a35a7f57
commit d3839fd4a1
19 changed files with 919 additions and 69 deletions
+6 -2
View File
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset # X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development. # trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33 # 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 export MEALQUEST_DEFAULT_TZ=America/New_York
make dev make dev
``` ```
@@ -53,8 +56,9 @@ make dev
http://localhost:5173. Configuration is environment-only and fails fast: http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE), lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR) and trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
`MEALQUEST_DEFAULT_TZ`, and (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 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 endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`. accepted only for `localhost`, `127.0.0.1` and `[::1]`.
+2 -2
View File
@@ -79,9 +79,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil { if err != nil {
return err return err
} }
search, err := recipes.NewSearchSettingsService(db, box) search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL)
if err != nil { if err != nil {
return err return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err)
} }
ai, err := aisettings.New(db, box, cfg.PublicURL.String()) ai, err := aisettings.New(db, box, cfg.PublicURL.String())
+4 -4
View File
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret # MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret. # The Authentik provider's client secret.
# MEALQUEST_SEARXNG_URL=http://searxng:8080 # MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. Planned; not # The SearXNG recipe-search sidecar on the compose network. The Settings
# read by the service yet. The app takes its recipe-search provider and # screen pre-fills it as the SearXNG URL until one is saved; the saved
# base URL from PATCH /api/v1/settings/recipe-search; see # provider and base URL come from PATCH /api/v1/settings/recipe-search.
# deploy/RUNBOOK.md "Recipe search (SearXNG)". # See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# --- Planned; not read by the service yet ------------------------------------- # --- Planned; not read by the service yet -------------------------------------
# #
+16 -8
View File
@@ -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 instance for recipe search. On the compose network it is reachable at
`http://searxng:8080`; that is the address to configure in the app. `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 Starting the sidecar does not connect the app to it. The recipe-search
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future provider and base URL live in the `recipe_search_settings` row and are set
wiring, and today it does nothing. The recipe-search provider and base URL through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
live in the `recipe_search_settings` row and are set through recipe search card on Settings → AI assistant). Until that row exists,
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings recipe search answers 409 `not_configured`.
screen's recipe search; the screen itself is not built yet). 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 needs the `X-MealQuest-CSRF` header set to `csrfToken` from
`GET /api/v1/me`): `GET /api/v1/me`):
+3
View File
@@ -149,6 +149,9 @@ automatic** (D-021).
the Brave key (`api_key`, write-only: responses carry only the Brave key (`api_key`, write-only: responses carry only
`api_key_set` and `api_key_last4`; an empty string removes it). The `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. 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 `POST /api/v1/settings/recipe-search/test` searches "chicken tikka
masala" and returns `{query, count}`, 409 `not_configured`, or 502 masala" and returns `{query, count}`, 409 `not_configured`, or 502
`search_failed` with the provider's error. `search_failed` with the provider's error.
+11
View File
@@ -35,6 +35,8 @@ const (
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ" EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
) )
// Session lifetime defaults (D-004): long enough that the kitchen tablet // 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 // validates it only while the settings do not exist yet, and ignores it
// once they do. Empty when unset. // once they do. Empty when unset.
DefaultTZ string 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 // 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), TrustedProxy: r.trustedProxy(EnvTrustedProxy),
DefaultTZ: r.optional(EnvDefaultTZ), DefaultTZ: r.optional(EnvDefaultTZ),
SearXNGURL: r.optional(EnvSearXNGURL),
} }
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
+5
View File
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
EnvOIDCAllowedGroups: " mealquest-admin ,family", EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvDefaultTZ: "Europe/London", EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
} }
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil { if err != nil {
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
if cfg.DefaultTZ != "Europe/London" { if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ) 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) { func TestSecretRedacted(t *testing.T) {
+40 -17
View File
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
type SearchSettings struct { type SearchSettings struct {
// Provider is "searxng" or "brave", nil until first chosen. // Provider is "searxng" or "brave", nil until first chosen.
Provider *string `json:"provider"` Provider *string `json:"provider"`
// BaseURL is the SearXNG instance's URL, nil when not set. // BaseURL is the saved SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"` BaseURL *string `json:"base_url"`
APIKeySet bool `json:"api_key_set"` // DefaultBaseURL is the deployment's SearXNG URL
APIKeyLast4 *string `json:"api_key_last4"` // (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 is nil until the settings are first saved.
UpdatedAt *string `json:"updated_at"` UpdatedAt *string `json:"updated_at"`
} }
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
type SearchSettingsService struct { type SearchSettingsService struct {
st *store.Store st *store.Store
box *secretbox.Box box *secretbox.Box
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
defaultBaseURL string
// newProvider is NewSearchProvider; tests point Brave elsewhere. // newProvider is NewSearchProvider; tests point Brave elsewhere.
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error) newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
} }
// NewSearchSettingsService returns the service over st, sealing keys with // NewSearchSettingsService returns the service over st, sealing keys with
// box. // box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) { // one must be a valid SearXNG URL.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
if st == nil { if st == nil {
return nil, errors.New("recipes: nil store") return nil, errors.New("recipes: nil store")
} }
if box == nil { if box == nil {
return nil, errors.New("recipes: nil secret box") 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) { func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx) row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return SearchSettings{}, nil return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
} }
if err != nil { if err != nil {
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err) 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 // 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 { if err != nil {
return SearchSettings{}, err return SearchSettings{}, err
} }
return searchSettingsFrom(saved), nil return s.settingsFrom(saved), nil
} }
// apply returns row with patch applied, validated. // apply returns row with patch applied, validated.
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
return len(cands), nil return len(cands), nil
} }
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings { func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
provider, updated := row.Provider, row.UpdatedAt provider, updated := row.Provider, row.UpdatedAt
return SearchSettings{ return SearchSettings{
Provider: &provider, Provider: &provider,
BaseURL: nullString(row.BaseUrl), BaseURL: nullString(row.BaseUrl),
APIKeySet: len(row.ApiKeyCt) > 0, DefaultBaseURL: s.defaultBase(),
APIKeyLast4: nullString(row.ApiKeyLast4), APIKeySet: len(row.ApiKeyCt) > 0,
UpdatedAt: &updated, APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
} }
} }
+76 -1
View File
@@ -19,6 +19,13 @@ import (
const testBraveKey = "BSA-secret-brave-key-9f3c" const testBraveKey = "BSA-secret-brave-key-9f3c"
func newTestSearchSettings(t *testing.T) *SearchSettingsService { 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() t.Helper()
st, err := store.Open(t.Context(), t.TempDir()) st, err := store.Open(t.Context(), t.TempDir())
if err != nil { if err != nil {
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
svc, err := NewSearchSettingsService(st, box) svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestSearchSettingsTestSearch(t *testing.T) {
t.Parallel() t.Parallel()
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
} }
rec, body := call(http.MethodGet, recipeSearchPath, "") 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) t.Errorf("GET unconfigured = %d %v", rec.Code, body)
} }
rec, body = call(http.MethodPost, recipeSearchTestPath, "") rec, body = call(http.MethodPost, recipeSearchTestPath, "")
+1 -1
View File
@@ -132,7 +132,7 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
// configured, over a migrated temp database and a random secret box. // configured, over a migrated temp database and a random secret box.
func newTestSearch(t *testing.T) *recipes.SearchSettingsService { func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
t.Helper() t.Helper()
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t)) search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+2 -1
View File
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
import { mockViewport } from './test/matchMedia.ts' import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
import App, { routes } from './App.tsx' 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' import { InDataRouter } from './test/dataRouter.tsx'
afterEach(() => { afterEach(() => {
@@ -80,6 +80,7 @@ describe('App routes', () => {
'/api/v1/me': () => jsonResponse(200, ME), '/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }), '/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
}) })
renderAt('/settings/ai') renderAt('/settings/ai')
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' }) const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
@@ -251,3 +251,33 @@
justify-content: flex-end; justify-content: flex-end;
gap: 10px; 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;
}
+275 -2
View File
@@ -4,11 +4,12 @@ import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.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 { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.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 = { const MODELS = {
models: [ models: [
@@ -30,6 +31,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
'GET /api/v1/me': () => jsonResponse(200, ME), 'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS), 'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders, ...responders,
} }
const fetchMock = vi.fn<typeof fetch>((input, init) => { const fetchMock = vi.fn<typeof fetch>((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', () => { describe('maskedKey', () => {
it('shows only the last four characters', () => { it('shows only the last four characters', () => {
expect(maskedKey('3f9a')).toBe('••••••••3f9a') expect(maskedKey('3f9a')).toBe('••••••••3f9a')
+44 -27
View File
@@ -14,6 +14,7 @@ import { TextInput } from '../ui/TextInput.tsx'
import { import {
aiKeyPolicy, aiKeyPolicy,
aiProviderLabels, aiProviderLabels,
maskedKey,
providersWithBaseURL, providersWithBaseURL,
useAIModels, useAIModels,
useAISettings, useAISettings,
@@ -26,6 +27,7 @@ import {
type AISettingsPatch, type AISettingsPatch,
} from './api.ts' } from './api.ts'
import styles from './AISettingsPage.module.css' import styles from './AISettingsPage.module.css'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
// TYPE_MODEL is the Model dropdown's "Type a model id" option. // TYPE_MODEL is the Model dropdown's "Type a model id" option.
const TYPE_MODEL = '\u0000type' const TYPE_MODEL = '\u0000type'
@@ -33,9 +35,12 @@ const TYPE_MODEL = '\u0000type'
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and // Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
// system prompt are edited as a draft and saved together; the API key is // 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 // 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() { export function AISettingsPage() {
const settings = useAISettings() const settings = useAISettings()
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
return ( return (
<div className={styles.page}> <div className={styles.page}>
<Header /> <Header />
@@ -45,11 +50,38 @@ export function AISettingsPage() {
AI settings could not load: {settings.error.message} AI settings could not load: {settings.error.message}
</ErrorBanner> </ErrorBanner>
)} )}
{settings.isSuccess && <AISettingsForm saved={settings.data} />} {settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={setAIDirty} />}
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
</div> </div>
) )
} }
// 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 (
<Confirm
open={blocker.state === 'blocked'}
title="Leave without saving?"
confirmLabel="Leave"
cancelLabel="Keep editing"
onCancel={() => blocker.reset?.()}
onConfirm={() => blocker.proceed?.()}
>
Your changes on this screen have not been saved.
</Confirm>
)
}
function Header() { function Header() {
const navigate = useNavigate() const navigate = useNavigate()
return ( return (
@@ -95,7 +127,13 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
return patch 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 [draft, setDraft] = useState(() => draftOf(saved))
const [typingModel, setTypingModel] = useState(false) const [typingModel, setTypingModel] = useState(false)
const [confirmingReset, setConfirmingReset] = useState(false) const [confirmingReset, setConfirmingReset] = useState(false)
@@ -116,15 +154,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
const listed = canList && models.isSuccess ? models.data : [] const listed = canList && models.isSuccess ? models.data : []
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model)) const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
const blocker = useBlocker(
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
)
useEffect(() => { useEffect(() => {
if (!dirty) return onDirtyChange(dirty)
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault() }, [dirty, onDirtyChange])
window.addEventListener('beforeunload', onBeforeUnload) useEffect(() => () => onDirtyChange(false), [onDirtyChange])
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [dirty])
function edit(change: Partial<Draft>) { function edit(change: Partial<Draft>) {
save.reset() save.reset()
@@ -285,26 +318,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
> >
This restores MealQuest's default system prompt for everyone in the household. This restores MealQuest's default system prompt for everyone in the household.
</Confirm> </Confirm>
<Confirm
open={blocker.state === 'blocked'}
title="Leave without saving?"
confirmLabel="Leave"
cancelLabel="Keep editing"
onCancel={() => blocker.reset?.()}
onConfirm={() => blocker.proceed?.()}
>
Your changes to the AI assistant have not been saved.
</Confirm>
</> </>
) )
} }
// 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". // formatVerifiedDate turns key_verified_at into the handoff's "Oct 1".
export function formatVerifiedDate(timestamp: string): string { export function formatVerifiedDate(timestamp: string): string {
const date = new Date(timestamp) const date = new Date(timestamp)
+275
View File
@@ -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 (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Recipe search
</h2>
<p className={styles.hint}>Where MealQuest looks for real recipes behind each Side quest.</p>
{settings.isPending && <Skeleton className={styles.cardLoading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Recipe search settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <RecipeSearchForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</Card>
)
}
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<Draft>) {
save.reset()
test.reset()
setDraft((current) => ({ ...current, ...change }))
}
function onSave() {
save.mutate(patch, {
onSuccess: (settings) => {
setDraft(draftOf(settings))
test.reset()
},
})
}
return (
<>
<Segmented
label="Recipe search provider"
options={providerOptions}
value={draft.provider}
onValueChange={(provider) => edit({ provider })}
/>
{draft.provider === 'searxng' ? (
<TextInput
label="SearXNG URL"
type="url"
className={styles.mono}
value={draft.base_url}
placeholder="https://"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ base_url: event.target.value })}
/>
) : (
<BraveKey
last4={saved.api_key_last4}
newKey={draft.api_key}
onKey={(api_key) => edit({ api_key })}
/>
)}
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Recipe search saved.'}
{test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
</div>
{save.isError && (
<p className={styles.error} role="alert">
{saveErrorMessage(save.error)}
</p>
)}
{test.isError && (
<p className={styles.error} role="alert">
{testErrorMessage(test.error)}
</p>
)}
{dirty && saved.provider !== null && <p className={styles.note}>Save your changes to test them.</p>}
<div className={styles.cardActions}>
<Button
variant="outline"
disabled={dirty || saved.provider === null || test.isPending}
onClick={() => test.mutate()}
>
{test.isPending ? 'Testing…' : 'Test'}
</Button>
<Button disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save recipe search'}
</Button>
</div>
</>
)
}
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 (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
Brave API key
</span>
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
<span className={styles.keyValue}>{shown}</span>
<button type="button" className={styles.replace} onClick={() => setReplacing(true)}>
{last4 === null && newKey === null ? 'Add key' : 'Replace'}
</button>
</div>
{newKey !== null && (
<div className={styles.keyStatus}>
<p className={styles.note}>New key not saved yet</p>
<button type="button" className={styles.link} onClick={() => onKey(null)}>
Discard
</button>
</div>
)}
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
<form
className={styles.sheet}
onSubmit={(event) => {
event.preventDefault()
onKey(key)
close()
}}
>
<h2 id={sheetTitleId} className={styles.sheetTitle}>
{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}
</h2>
<p className={styles.sheetText}>
The key is stored on the server when you save recipe search. This device only ever sees its last four
characters.
</p>
<TextInput
label="Brave API key"
type="password"
className={styles.mono}
value={key}
autoComplete="off"
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
<div className={styles.sheetActions}>
<Button variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={key === ''}>
Use this key
</Button>
</div>
</form>
</Sheet>
</div>
)
}
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}`
}
+113
View File
@@ -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_KEY_PATH = '/api/v1/settings/ai/api-key'
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify' export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt' 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 // 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 read API. Until it lands the server answers 404 and the Regular
// dinners row says the count is unavailable. // dinners row says the count is unavailable.
@@ -192,6 +194,111 @@ export function useResetAIPrompt() {
return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined) 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<unknown>(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<unknown>(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<unknown>(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])$/ const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// useHouseholdSettings refuses a dinner time that is not HH:MM, so // 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'}` 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". // formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
export function formatClockTime(time: string): string { export function formatClockTime(time: string): string {
const match = clockTime.exec(time) const match = clockTime.exec(time)
+3 -3
View File
@@ -1,9 +1,9 @@
import { render, screen, within } from '@testing-library/react' import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppRoutes } from '../App.tsx' import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx' import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle, outerSize } from '../test/cssRules.ts' import { declaredStyle, outerSize } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { expectMinHitArea } from '../test/hitArea.ts' import { expectMinHitArea } from '../test/hitArea.ts'
import { stubFetchByPath } from '../test/fetchStub.ts' import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts' import { mockViewport } from '../test/matchMedia.ts'
@@ -25,11 +25,11 @@ function renderAt(path: string, width: number) {
const resize = mockViewport(width) const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API) stubFetchByPath(SETTINGS_API)
render( render(
<MemoryRouter initialEntries={[path]}> <InDataRouter path={path}>
<ApiProviders> <ApiProviders>
<AppRoutes /> <AppRoutes />
</ApiProviders> </ApiProviders>
</MemoryRouter>, </InDataRouter>,
) )
return resize return resize
} }
+12
View File
@@ -16,6 +16,17 @@ export const AI_SETTINGS = {
updated_at: '2026-10-01T15:00:00.000Z', 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 // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status // today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet. // endpoints do not exist yet.
@@ -25,6 +36,7 @@ export const SETTINGS_API = {
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), '/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/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), '/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 // ApiProviders gives the pages under test what App gives them: an API client