Files
MealQuest/internal/server/recipesearch_test.go
T
dev_agentandClaude Opus 5.5 cdbec3f3a2
CI / ci (push) Successful in 8m28s
CI / ci (pull_request) Successful in 8m20s
Add GET /api/v1/home for the Home dashboard
home.Service aggregates the Home screen from the services that own each
value, at "now" in the household's zone: the date label, tonight's dinner
from the current week's plan (null with no plan), the pending Side quest
count from the deck, the Supply run status of the upcoming week or else
the current one, and recentUpdates.unread.

unread is whether a notification was stored in notification_outbox after
household_settings.updates_seen_at (or at all while it is null), read
through the new notify.StoredSince over the HasNotificationSince query.
Nothing writes updates_seen_at yet; the updates task adds that.

Tests under America/Chicago show tonight's dinner flips at local
midnight, not UTC midnight, and cover no plan, no suggestions, the
Supply run choice and unread.

Maestro task 9ccfc3a6-cc7b-493a-aa4b-2b455d26f4df.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:19:33 -04:00

125 lines
4.7 KiB
Go

package server
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
)
const (
recipeSearchPath = "/api/v1/settings/recipe-search"
recipeSearchTestPath = "/api/v1/settings/recipe-search/test"
testBraveKey = "BSA-secret-brave-key-9f3c"
)
// newRecipeSearchRouter returns a router serving search.
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
func TestRecipeSearchSettingsAPI(t *testing.T) {
t.Parallel()
router := newRecipeSearchRouter(t, newTestSearch(t))
var bodies []string
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
rec, decoded := serve(t, router, signedIn(method, target, r))
bodies = append(bodies, rec.Body.String())
return rec, decoded
}
rec, body := call(http.MethodGet, recipeSearchPath, "")
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "not_configured" {
t.Errorf("POST test unconfigured = %d %v", rec.Code, body)
}
for _, bad := range []string{
`{"provider":"google"}`,
`{"provider":"brave"}`,
`{"provider":"searxng","base_url":"not a url"}`,
`{"provider":"brave","api_key":null}`,
`{"provider":"brave","apikey":"x"}`,
} {
if rec, _ := call(http.MethodPatch, recipeSearchPath, bad); rec.Code != http.StatusBadRequest {
t.Errorf("PATCH %s = %d, want 400", bad, rec.Code)
}
}
rec, body = call(http.MethodPatch, recipeSearchPath, `{"provider":"brave","api_key":"`+testBraveKey+`"}`)
if rec.Code != http.StatusOK || body["provider"] != "brave" || body["api_key_set"] != true || body["api_key_last4"] != "9f3c" {
t.Errorf("PATCH Brave = %d %v", rec.Code, body)
}
if _, ok := body["api_key"]; ok {
t.Errorf("PATCH response has an api_key field: %v", body)
}
rec, body = call(http.MethodGet, recipeSearchPath, "")
if rec.Code != http.StatusOK || body["provider"] != "brave" || body["api_key_last4"] != "9f3c" {
t.Errorf("GET Brave = %d %v", rec.Code, body)
}
// SearXNG keeps the stored key; the test search goes to the instance.
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"results":[{"url":"https://a.example/1"},{"url":"https://b.example/2"},{"url":"https://c.example/3"}]}`))
}))
t.Cleanup(searx.Close)
rec, body = call(http.MethodPatch, recipeSearchPath, `{"provider":"searxng","base_url":"`+searx.URL+`"}`)
if rec.Code != http.StatusOK || body["base_url"] != searx.URL || body["api_key_set"] != true {
t.Errorf("PATCH SearXNG = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
if rec.Code != http.StatusOK || body["count"] != float64(3) || body["query"] != recipes.SearchTestQuery {
t.Errorf("POST test = %d %v, want count 3", rec.Code, body)
}
searx.Close()
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
if rec.Code != http.StatusBadGateway || body["error"].(map[string]any)["code"] != "search_failed" {
t.Errorf("POST test against a stopped instance = %d %v, want 502 search_failed", rec.Code, body)
}
for _, b := range bodies {
if strings.Contains(b, testBraveKey) || strings.Contains(b, testBraveKey[:12]) {
t.Errorf("response holds the Brave key: %s", b)
}
}
}
func TestRecipeSearchSettingsNeedCSRF(t *testing.T) {
t.Parallel()
router := newRecipeSearchRouter(t, newTestSearch(t))
for _, method := range []string{http.MethodPatch, http.MethodPost} {
target := recipeSearchPath
if method == http.MethodPost {
target = recipeSearchTestPath
}
req := httptest.NewRequest(method, target, strings.NewReader(`{"provider":"brave","api_key":"`+testBraveKey+`"}`))
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("%s %s without CSRF token = %d, want 403", method, target, rec.Code)
}
}
}