POST /api/v1/suggestions/batches enqueues a suggestion_batch job that runs
suggest_side_quests through the harness, grounds the survivors with
recipes.Grounder.GroundMany (dropping those no recipe grounds) and stores
each as a dinner (status suggested, origin side_quest) with a pending
suggestion holding its reason and cited_regular_ids. Only one batch is
pending or running at a time; a POST while one is active returns it.
GET /api/v1/suggestions/deck lists pending suggestions oldest first with
the dinner's name, photo, difficulty and the reason. POST
/api/v1/suggestions/{id}/accept moves the dinner to the Quest log; /decline
records the Pass and archives the dinner, whose name every later batch
reads as declined. Both are idempotent and 409 on the opposite decision.
A decision leaving fewer than 5 in the deck starts a replenish batch
unless one is already pending or running. Batches left unfinished by a
restart are failed at boot so they never block the next.
Task: b4f2558c-227f-4627-852b-be5aa190242a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
4.6 KiB
Go
125 lines
4.6 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), 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)
|
|
}
|
|
}
|
|
}
|