Implement the ideas_from_ingredients v1 skill on the AI harness: it asks
for 8 ideas {name, uses[], missing[], est_minutes, method, difficulty,
search_query}, keeps only "uses" entries whose harness.Normalize form is
one of the household's listed ingredients (written as the household wrote
them, in its order), drops ideas using none or repeating another, and
computes the summary line from the corrected lists.
Add internal/ideas: an ideas_from_ingredients job that grounds the ideas
with Grounder.GroundMany in the model's order until 5 are kept, drops the
ungroundable ones and, for quick_only, those whose grounded recipe is not
a Quick dinner (crockpot or minutes_total <= 30), publishing the result
as each idea is kept. POST /api/v1/ideas/from-ingredients answers 202
{jobId}.
Maestro task c3c5f98e-7f7d-4c6b-a1ab-d45bb8e497f9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
4.7 KiB
Go
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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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)
|
|
}
|
|
}
|
|
}
|