Pre-fill the SearXNG URL from the deployment default
CI / ci (push) Successful in 1m56s
CI / ci (pull_request) Successful in 2m3s

The service now reads the optional MEALQUEST_SEARXNG_URL, validates it at
startup like any SearXNG URL, and returns it in the recipe search settings
as the read-only default_base_url. base_url stays the saved URL only, and
PATCH is unchanged: nothing is stored until the household saves.

The recipe search card pre-fills the SearXNG URL from default_base_url
while no URL is saved, and an edit now clears the last Test result so a
stale count or error never sits beside "Save your changes to test them."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:41:31 -04:00
co-authored by Claude Opus 5.5
parent cb42ffa280
commit 938b408deb
15 changed files with 250 additions and 44 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)
} }
+67 -5
View File
@@ -462,16 +462,52 @@ describe('Recipe search card', () => {
expect(testSearch()).toBeEnabled() expect(testSearch()).toBeEnabled()
}) })
it('starts on SearXNG with an empty URL before recipe search is set up', async () => { const UNCONFIGURED = {
stubAPI({ provider: null,
'GET /api/v1/settings/recipe-search': () => base_url: null,
jsonResponse(200, { provider: null, base_url: null, api_key_set: false, api_key_last4: null, updated_at: 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() await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true') expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('') expect(url()).toHaveValue('http://searxng:8080')
expect(testSearch()).toBeDisabled() expect(testSearch()).toBeDisabled()
expect(saveSearch()).toBeEnabled() 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 () => { it('switches providers, keeping each provider\'s field', async () => {
@@ -587,6 +623,32 @@ describe('Recipe search card', () => {
) )
}) })
it('clears a test result when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument()
})
it('clears a test error when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed')
fireEvent.click(brave())
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByRole('alert')).not.toBeInTheDocument()
})
it('asks before leaving with an unsaved recipe search change', async () => { it('asks before leaving with an unsaved recipe search change', async () => {
stubAPI() stubAPI()
await renderCard() await renderCard()
+10 -2
View File
@@ -61,9 +61,14 @@ type Draft = {
} }
// draftOf starts a household that has not set up recipe search on SearXNG, // draftOf starts a household that has not set up recipe search on SearXNG,
// the sidecar every deployment runs. // the sidecar every deployment runs. With no URL saved the field holds the
// deployment's SearXNG URL, which saving stores.
function draftOf(saved: RecipeSearchSettings): Draft { function draftOf(saved: RecipeSearchSettings): Draft {
return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? '', api_key: null } return {
provider: saved.provider ?? 'searxng',
base_url: saved.base_url ?? saved.default_base_url ?? '',
api_key: null,
}
} }
// patchOf is the PATCH body for what draft changes. Only the chosen // patchOf is the PATCH body for what draft changes. Only the chosen
@@ -88,8 +93,11 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
}, [dirty, onDirtyChange]) }, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [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>) { function edit(change: Partial<Draft>) {
save.reset() save.reset()
test.reset()
setDraft((current) => ({ ...current, ...change })) setDraft((current) => ({ ...current, ...change }))
} }
+7 -1
View File
@@ -209,8 +209,12 @@ export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels
export type RecipeSearchSettings = { export type RecipeSearchSettings = {
// Null until recipe search is first set up. // Null until recipe search is first set up.
provider: RecipeSearchProvider | null provider: RecipeSearchProvider | null
// The SearXNG instance's URL, null when none is set. // The saved SearXNG instance's URL, null when none is set.
base_url: string | null 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_set: boolean
api_key_last4: string | null api_key_last4: string | null
updated_at: string | null updated_at: string | null
@@ -237,6 +241,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe
!isRecord(body) || !isRecord(body) ||
!isStringOrNull(body.provider) || !isStringOrNull(body.provider) ||
!isStringOrNull(body.base_url) || !isStringOrNull(body.base_url) ||
!isStringOrNull(body.default_base_url) ||
typeof body.api_key_set !== 'boolean' || typeof body.api_key_set !== 'boolean' ||
!isStringOrNull(body.api_key_last4) || !isStringOrNull(body.api_key_last4) ||
!isStringOrNull(body.updated_at) !isStringOrNull(body.updated_at)
@@ -249,6 +254,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe
return { return {
provider: body.provider, provider: body.provider,
base_url: body.base_url, base_url: body.base_url,
default_base_url: body.default_base_url,
api_key_set: body.api_key_set, api_key_set: body.api_key_set,
api_key_last4: body.api_key_last4, api_key_last4: body.api_key_last4,
updated_at: body.updated_at, updated_at: body.updated_at,
+1
View File
@@ -21,6 +21,7 @@ export const AI_SETTINGS = {
export const RECIPE_SEARCH_SETTINGS = { export const RECIPE_SEARCH_SETTINGS = {
provider: 'searxng', provider: 'searxng',
base_url: 'http://searxng:8080', base_url: 'http://searxng:8080',
default_base_url: 'http://searxng:8080',
api_key_set: false, api_key_set: false,
api_key_last4: null, api_key_last4: null,
updated_at: '2026-10-01T15:00:00.000Z', updated_at: '2026-10-01T15:00:00.000Z',