Files
MealQuest/internal/recipes/searchsettings_test.go
T
dev_agentandClaude Opus 5.5 ceedc45309
CI / ci (push) Successful in 1m49s
CI / ci (pull_request) Successful in 1m48s
Grounding pipeline and recipe-search settings API
recipes.Grounder ties search, fetch/extract, Rank, the photo cache and
normalize_recipe together. Ground(ctx, dishName, query) reuses a stored
source_url among the top candidates without fetching, otherwise tries the
ranked recipes best first and stores the photo, recipe, ingredients and
steps in one transaction, returning Grounded{recipe_id, photo_id, method,
minutes_total, difficulty, source_url, site_name} or a typed
*ErrUngroundable listing every reason. GroundURL grounds a pasted URL the
same way; GroundMany grounds items 3 at a time with per-item results.

PhotoCache is split into DownloadPhoto (fetch, decode, size check, no
writes) and CachePhoto(ctx, tx, photo), which records the row in the
caller's transaction; RemovePhotoFiles cleans up after a rollback.

Migration 0007 adds photo_id, method, difficulty and minutes_total to
recipes, and api_key_last4 to recipe_search_settings. SearXNG is now
called at recipe_search_settings.base_url.

GET/PATCH /api/v1/settings/recipe-search (Brave key sealed with
secretbox, only api_key_set and api_key_last4 returned) and POST
/api/v1/settings/recipe-search/test (searches "chicken tikka masala",
returns the count, 409 not_configured or 502 search_failed).

Maestro task 53afd59c-6c35-4af7-904c-6c167d1ad69d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:06:39 -04:00

173 lines
5.7 KiB
Go

package recipes
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const testBraveKey = "BSA-secret-brave-key-9f3c"
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
t.Helper()
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)
}
})
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
svc, err := NewSearchSettingsService(st, box)
if err != nil {
t.Fatal(err)
}
return svc
}
func ptr(s string) *string { return &s }
// noKey fails the test when v, marshalled as the API would, holds the
// Brave key.
func noKey(t *testing.T, what string, v any) {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), testBraveKey) || strings.Contains(string(b), testBraveKey[:12]) {
t.Errorf("%s holds the Brave key: %s", what, b)
}
}
func TestSearchSettingsUpdate(t *testing.T) {
t.Parallel()
svc := newTestSearchSettings(t)
got, err := svc.Get(t.Context())
if err != nil || got != (SearchSettings{}) {
t.Fatalf("Get before any update = %+v, %v; want every field unset", got, err)
}
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
t.Errorf("Provider before any update error = %v, want ErrSearchNotConfigured", err)
}
for name, patch := range map[string]SearchSettingsPatch{
"no provider": {BaseURL: ptr("http://searxng:8080")},
"unknown provider": {Provider: ptr("google")},
"searxng without URL": {Provider: ptr("searxng")},
"bad URL": {Provider: ptr("searxng"), BaseURL: ptr("ftp://searxng")},
"brave without key": {Provider: ptr("brave")},
"short key": {Provider: ptr("brave"), APIKey: ptr("abc12")},
"key with space": {Provider: ptr("brave"), APIKey: ptr("abc 12345678")},
} {
var invalid *SearchSettingsError
if _, err := svc.Update(t.Context(), patch); !errors.As(err, &invalid) {
t.Errorf("%s: error = %v, want *SearchSettingsError", name, err)
}
}
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("http://searxng:8080")})
if err != nil {
t.Fatal(err)
}
if *got.Provider != "searxng" || *got.BaseURL != "http://searxng:8080" || got.APIKeySet || got.APIKeyLast4 != nil || got.UpdatedAt == nil {
t.Errorf("SearXNG settings = %+v", got)
}
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("brave"), APIKey: ptr(testBraveKey)})
if err != nil {
t.Fatal(err)
}
if *got.Provider != "brave" || !got.APIKeySet || *got.APIKeyLast4 != "9f3c" || *got.BaseURL != "http://searxng:8080" {
t.Errorf("Brave settings = %+v", got)
}
noKey(t, "Update result", got)
got, err = svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
noKey(t, "Get result", got)
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(row.ApiKeyCt), testBraveKey) {
t.Error("Brave key stored in plain text")
}
if _, err := svc.Update(t.Context(), SearchSettingsPatch{APIKey: ptr("")}); err == nil {
t.Error("removing the key while Brave is chosen succeeded")
}
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), APIKey: ptr("")})
if err != nil || got.APIKeySet || got.APIKeyLast4 != nil {
t.Errorf("switch to SearXNG removing the key = %+v, %v", got, err)
}
}
func TestSearchSettingsTestSearch(t *testing.T) {
t.Parallel()
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != SearchTestQuery+" recipe" {
t.Errorf("SearXNG query = %q", r.URL.Query().Get("q"))
}
w.Write([]byte(`{"results":[{"url":"https://a.example/1"},{"url":"https://b.example/2"}]}`))
}))
t.Cleanup(searx.Close)
svc := newTestSearchSettings(t)
if _, err := svc.TestSearch(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
t.Errorf("TestSearch unconfigured error = %v, want ErrSearchNotConfigured", err)
}
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr(searx.URL)}); err != nil {
t.Fatal(err)
}
if n, err := svc.TestSearch(t.Context()); err != nil || n != 2 {
t.Errorf("TestSearch = %d, %v; want 2", n, err)
}
// Brave refuses the key; the error must not carry it.
brave := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Subscription-Token") != testBraveKey {
t.Errorf("Brave token = %q", r.Header.Get("X-Subscription-Token"))
}
http.Error(w, "bad key "+testBraveKey, http.StatusUnauthorized)
}))
t.Cleanup(brave.Close)
svc.newProvider = func(s db.RecipeSearchSetting, box *secretbox.Box) (SearchProvider, error) {
p, err := NewSearchProvider(s, box)
if b, ok := p.(*Brave); ok {
b.endpoint = brave.URL
}
return p, err
}
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("brave"), APIKey: ptr(testBraveKey)}); err != nil {
t.Fatal(err)
}
_, err := svc.TestSearch(t.Context())
if !errors.Is(err, ErrSearchRefused) {
t.Fatalf("TestSearch error = %v, want ErrSearchRefused", err)
}
if strings.Contains(err.Error(), testBraveKey) {
t.Errorf("TestSearch error holds the Brave key: %v", err)
}
}