Add internal/plan: a draft_week job that drafts the week through the
harness from every Regular and the Quest log dinners no other current or
later plan holds, given the household context and last week's dinners,
and stores it as the week's plan; plan reads with the week label, is_new
and each crock-pot dinner's start_by (dinner time minus minutes_total,
computed on read); and day swaps that recompute is_new and move a synced
plan to changed. Drafting over a synced or changed plan needs confirm, at
enqueue and again when the job stores the week; a redraft keeps each
day's calendar event id.
Serve GET /api/v1/plans/{weekStart}, POST /api/v1/plans/{weekStart}/draft
(202 {job_id}, single-flight per week) and PUT
/api/v1/plans/{weekStart}/days/{date}; a weekStart that is not a Monday
is 400 invalid_week.
Maestro task ea48e650-d510-49cc-8485-0f07174ee731.
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), newTestPlan(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)
|
|
}
|
|
}
|
|
}
|