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>
229 lines
8.7 KiB
Go
229 lines
8.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
|
)
|
|
|
|
const suggestionsPath = "/api/v1/suggestions"
|
|
|
|
// newSuggestionsRouter returns a router whose suggestions service has an
|
|
// unstarted job runner, so batches stay pending, and a deck seeded with
|
|
// pending suggestions of dinners named names, in order, whose ids it
|
|
// returns.
|
|
func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.Store, []string) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
runner, err := jobs.NewRunner(st, logger, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
|
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
|
return skills.SideQuests{}, errors.New("no model in this test")
|
|
}
|
|
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
|
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
q := db.New(st.Writer)
|
|
ctx := t.Context()
|
|
now := store.Now().String()
|
|
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var ids []string
|
|
for i, name := range names {
|
|
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
|
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "suggested", Origin: "side_quest",
|
|
Difficulty: sql.NullString{String: "easy", Valid: true}, GroundingStatus: "grounded", CreatedAt: now, UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := store.NewID()
|
|
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
|
ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
|
|
CreatedAt: store.Now().String(), UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return h, st, ids
|
|
}
|
|
|
|
func TestSuggestionDeck(t *testing.T) {
|
|
t.Parallel()
|
|
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen")
|
|
|
|
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
list, _ := body["suggestions"].([]any)
|
|
if len(list) != 2 {
|
|
t.Fatalf("deck = %v, want 2 suggestions", body)
|
|
}
|
|
for i, name := range []string{"Pho", "Ramen"} {
|
|
s := list[i].(map[string]any)
|
|
if s["id"] != ids[i] || s["name"] != name || s["difficulty"] != "easy" || s["reason"] != fmt.Sprintf("Reason %d", i) || s["status"] != "pending" {
|
|
t.Errorf("deck[%d] = %v, want %s", i, s, name)
|
|
}
|
|
if _, ok := s["photo_url"]; !ok {
|
|
t.Errorf("deck[%d] has no photo_url key", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSuggestionDecisions(t *testing.T) {
|
|
t.Parallel()
|
|
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu", "Udon")
|
|
|
|
for _, tc := range []struct {
|
|
target string
|
|
status int
|
|
code string
|
|
sugStatus string
|
|
dinnerStatus string
|
|
}{
|
|
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
|
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
|
{ids[0] + "/decline", http.StatusConflict, "suggestion_decided", "", ""},
|
|
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
|
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
|
{ids[1] + "/accept", http.StatusConflict, "suggestion_decided", "", ""},
|
|
{"nope/accept", http.StatusNotFound, "not_found", "", ""},
|
|
{"nope/decline", http.StatusNotFound, "not_found", "", ""},
|
|
} {
|
|
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+tc.target, "")
|
|
if rec.Code != tc.status {
|
|
t.Fatalf("POST %s = %d, want %d: %s", tc.target, rec.Code, tc.status, rec.Body)
|
|
}
|
|
if tc.code != "" {
|
|
if errorCode(body) != tc.code {
|
|
t.Errorf("POST %s error = %v, want %s", tc.target, body, tc.code)
|
|
}
|
|
continue
|
|
}
|
|
s, _ := body["suggestion"].(map[string]any)
|
|
if s["status"] != tc.sugStatus || s["dinner_status"] != tc.dinnerStatus {
|
|
t.Errorf("POST %s = %v, want %s with dinner %s", tc.target, body, tc.sugStatus, tc.dinnerStatus)
|
|
}
|
|
if body["replenish_job_id"] != nil {
|
|
t.Errorf("POST %s replenished with 5 left: %v", tc.target, body["replenish_job_id"])
|
|
}
|
|
}
|
|
|
|
// A third decision leaves 4: a replenish batch starts.
|
|
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[2]+"/decline", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
replenish, _ := body["replenish_job_id"].(string)
|
|
if replenish == "" {
|
|
t.Fatalf("decline leaving 4 = %v, want a replenish_job_id", body)
|
|
}
|
|
|
|
// A fourth decision joins the batch already pending and reports its job.
|
|
rec, body = callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[3]+"/accept", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("accept = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if body["replenish_job_id"] != replenish {
|
|
t.Errorf("accept leaving 3 = %v, want replenish_job_id %s", body, replenish)
|
|
}
|
|
}
|
|
|
|
func TestSuggestionDeckActiveBatch(t *testing.T) {
|
|
t.Parallel()
|
|
h, _, _ := newSuggestionsRouter(t, "Pho")
|
|
|
|
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if active, ok := body["active_batch"]; !ok || active != nil {
|
|
t.Fatalf("deck = %v, want active_batch null", body)
|
|
}
|
|
if failed, ok := body["failed_batch"]; !ok || failed != nil {
|
|
t.Fatalf("deck = %v, want failed_batch null", body)
|
|
}
|
|
|
|
rec, started := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
rec, body = callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
active, _ := body["active_batch"].(map[string]any)
|
|
if active == nil || active["batch_id"] != started["batch_id"] || active["job_id"] != started["job_id"] || active["status"] != "pending" {
|
|
t.Errorf("deck active_batch = %v, want the started batch %v", body["active_batch"], started)
|
|
}
|
|
}
|
|
|
|
func TestPostSuggestionBatch(t *testing.T) {
|
|
t.Parallel()
|
|
h, st, _ := newSuggestionsRouter(t)
|
|
|
|
rec, first := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if first["batch_id"] == nil || first["job_id"] == nil || first["trigger"] != "manual" || first["status"] != "pending" {
|
|
t.Errorf("POST batches = %v, want a pending manual batch with its job", first)
|
|
}
|
|
var kind string
|
|
if err := st.Reader.QueryRow(`SELECT kind FROM jobs WHERE id = ?`, first["job_id"]).Scan(&kind); err != nil || kind != suggest.KindSuggestionBatch {
|
|
t.Errorf("job kind = %q (%v), want %s", kind, err, suggest.KindSuggestionBatch)
|
|
}
|
|
|
|
rec, again := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
|
if rec.Code != http.StatusAccepted || again["batch_id"] != first["batch_id"] || again["job_id"] != first["job_id"] {
|
|
t.Errorf("second POST batches = %d %v, want the pending batch %v", rec.Code, again, first)
|
|
}
|
|
}
|
|
|
|
func TestSuggestionWritesNeedCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
h, _, ids := newSuggestionsRouter(t, "Pho")
|
|
for _, target := range []string{"/batches", "/" + ids[0] + "/accept", "/" + ids[0] + "/decline"} {
|
|
req := httptest.NewRequest(http.MethodPost, suggestionsPath+target, nil)
|
|
req.AddCookie(validSessionCookie)
|
|
rec, body := serve(t, h, req)
|
|
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
|
t.Errorf("POST %s without CSRF = %d %v, want 403 csrf", target, rec.Code, body)
|
|
}
|
|
}
|
|
}
|