351 lines
14 KiB
Go
351 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"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/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
const ideasPath = "/api/v1/ideas/from-ingredients"
|
|
|
|
// newIdeasRouter returns a router whose ideas service runs
|
|
// ideas_from_ingredients and quick_ideas through the real harness with the
|
|
// scripted model and grounds with fake search, pages and images and the
|
|
// scripted normalize, with its job runner and store, whose household
|
|
// settings are seeded. The runner is started when start is set.
|
|
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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(), search, pages, images, normalize)
|
|
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := harness.Config{
|
|
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
|
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
|
Store: st,
|
|
Guard: guard,
|
|
}
|
|
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
|
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
|
}
|
|
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
|
return skills.QuickIdeas(ctx, cfg, scope, in)
|
|
}
|
|
svc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
|
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if start {
|
|
startRunner(t, runner)
|
|
}
|
|
return h, runner, st
|
|
}
|
|
|
|
// startRunner starts runner until the test ends.
|
|
func startRunner(t *testing.T, runner *jobs.Runner) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if err := runner.Start(ctx); err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
runner.Wait()
|
|
})
|
|
}
|
|
|
|
// scriptIdeas scripts ideas_from_ingredients to propose names, in order;
|
|
// a recipe grounds those in grounds, the others ground none, and search
|
|
// fails as fail says. It returns the router's fakes.
|
|
func scriptIdeas(t *testing.T, names, grounds []string, fail map[string]error) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
|
t.Helper()
|
|
model, normalize := &llm.Fake{}, &llm.Fake{}
|
|
search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: fail}
|
|
pages := &recipestest.Pages{Bodies: map[string][]byte{}}
|
|
images := &recipestest.Images{JPEGs: map[string][]byte{}}
|
|
for i, name := range grounds {
|
|
page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i)
|
|
search.Results[name] = []recipes.Candidate{{URL: page}}
|
|
pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25)
|
|
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
|
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
}
|
|
list := make([]string, len(names))
|
|
for i, name := range names {
|
|
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name)
|
|
}
|
|
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
|
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
|
return model, normalize, search, pages, images
|
|
}
|
|
|
|
// startIdeasJob posts a request for ideas from rice and returns the job id.
|
|
func startIdeasJob(t *testing.T, h http.Handler) string {
|
|
t.Helper()
|
|
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
return body["jobId"].(string)
|
|
}
|
|
|
|
// assertFailedWithoutIdeas checks that GET /jobs/{id} reports job failed
|
|
// with code and a result showing no ideas.
|
|
func assertFailedWithoutIdeas(t *testing.T, job map[string]any, code string) {
|
|
t.Helper()
|
|
if job["status"] != "failed" || job["error"] != code {
|
|
t.Fatalf("GET job = %v, want failed with %s", job, code)
|
|
}
|
|
res, _ := job["result"].(map[string]any)
|
|
if res == nil {
|
|
t.Fatalf("failed job result = %v, want one showing no ideas", job["result"])
|
|
}
|
|
if list, ok := res["ideas"].([]any); !ok || len(list) != 0 {
|
|
t.Fatalf("failed job ideas = %v, want none", res["ideas"])
|
|
}
|
|
}
|
|
|
|
// storedIdeas is how many ideas job id's stored result holds.
|
|
func storedIdeas(t *testing.T, st *store.Store, id string) int {
|
|
t.Helper()
|
|
row, err := db.New(st.Reader).GetJob(t.Context(), id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var res ideas.Result
|
|
if err := json.Unmarshal([]byte(row.Result.String), &res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return len(res.Ideas)
|
|
}
|
|
|
|
func TestPostIdeasFromIngredientsRefusesBadRequests(t *testing.T) {
|
|
t.Parallel()
|
|
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
|
|
|
for _, tc := range []struct{ body, message string }{
|
|
{`{"quick_only": false}`, "ingredients: required"},
|
|
{`{"ingredients": ["rice"]}`, "quick_only: required"},
|
|
{`{"ingredients": [], "quick_only": false}`, "ingredients: list at least one ingredient"},
|
|
{`{"ingredients": ["rice", " "], "quick_only": false}`, "ingredients: ingredient 2 is blank"},
|
|
{`{"ingredients": ["Rice", "rice"], "quick_only": false}`, `ingredients: ingredient 2, "rice", repeats "Rice"`},
|
|
{`{"ingredients": ["rice"], "quick_only": "yes"}`, "quick_only: want a JSON bool"},
|
|
{`{"ingredients": ["rice"], "quick_only": false, "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
|
} {
|
|
rec, body := callDinners(t, h, http.MethodPost, ideasPath, tc.body)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
|
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
|
continue
|
|
}
|
|
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
|
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize := &llm.Fake{}, &llm.Fake{}
|
|
search := &recipestest.Search{Results: map[string][]recipes.Candidate{"chicken fried rice": {{URL: "https://r.example/fried-rice"}}}}
|
|
pages := &recipestest.Pages{Bodies: map[string][]byte{
|
|
"https://r.example/fried-rice": recipestest.RecipePage("Chicken Fried Rice", "R Kitchen", "https://r.example/fried-rice.jpg", 25),
|
|
}}
|
|
images := &recipestest.Images{JPEGs: map[string][]byte{"https://r.example/fried-rice.jpg": recipestest.JPEG(t, 400, 320, 1)}}
|
|
js := `{"ideas":[{"name":"Chicken Fried Rice","uses":["rice","chicken thighs","peas"],"missing":["2 eggs"],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":"chicken fried rice"}]}`
|
|
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
|
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
h, runner, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
|
|
|
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["Chicken thighs", "rice", "scallions"], "quick_only": true}`)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
id, _ := body["jobId"].(string)
|
|
if id == "" || len(body) != 1 {
|
|
t.Fatalf("POST answered %v, want only a jobId", body)
|
|
}
|
|
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
job, err := runner.Get(t.Context(), id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("job still %s", job.Status)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
rec, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
|
if rec.Code != http.StatusOK || job["status"] != "succeeded" || job["kind"] != "ideas_from_ingredients" {
|
|
t.Fatalf("GET job = %d %v", rec.Code, job)
|
|
}
|
|
res := job["result"].(map[string]any)
|
|
if res["stage"] != "done" || res["quick_only"] != true || res["dropped"] != 0.0 {
|
|
t.Fatalf("result = %v", res)
|
|
}
|
|
list := res["ideas"].([]any)
|
|
if len(list) != 1 {
|
|
t.Fatalf("ideas = %v, want 1", list)
|
|
}
|
|
got := list[0].(map[string]any)
|
|
for key, want := range map[string]any{
|
|
"name": "Chicken Fried Rice",
|
|
"summary": "Uses 2 of 3 · missing: 2 eggs",
|
|
"method": "stovetop",
|
|
"minutes_total": 25.0,
|
|
"quick": true,
|
|
"site_name": "R Kitchen",
|
|
"source_url": "https://r.example/fried-rice",
|
|
} {
|
|
if got[key] != want {
|
|
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
|
}
|
|
}
|
|
if uses, _ := json.Marshal(got["uses"]); string(uses) != `["Chicken thighs","rice"]` {
|
|
t.Errorf("idea uses = %s, want the household's two listed ingredients", uses)
|
|
}
|
|
if got["recipe_id"] == "" || got["photo_url"] == "" {
|
|
t.Errorf("idea = %v, want a recipe and photo", got)
|
|
}
|
|
}
|
|
|
|
func TestFailedIdeasJobShowsNoIdeas(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range []struct {
|
|
name string
|
|
// grounds are the ideas a recipe grounds; the others ground none.
|
|
grounds []string
|
|
ideas []string
|
|
fail map[string]error
|
|
code string
|
|
}{
|
|
{"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"},
|
|
{"a later round fails after ideas were shown",
|
|
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
|
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
|
map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize, search, pages, images := scriptIdeas(t, tc.ideas, tc.grounds, tc.fail)
|
|
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
|
assertFailedWithoutIdeas(t, waitJob(t, h, startIdeasJob(t, h)), tc.code)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A job left running with ideas shown is failed at the next start as
|
|
// interrupted, without its handler running again; it still shows no ideas.
|
|
func TestIdeasJobInterruptedByRestartShowsNoIdeas(t *testing.T) {
|
|
t.Parallel()
|
|
h, runner, st := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
|
id := startIdeasJob(t, h)
|
|
|
|
// The previous process claimed the job and showed an idea, then stopped.
|
|
q := db.New(st.Writer)
|
|
now := store.Now().String()
|
|
if _, err := q.ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
partial, err := ideas.Result{
|
|
Stage: ideas.StageGrounding,
|
|
Ingredients: []string{"rice"},
|
|
Ideas: []ideas.Idea{{Name: "Fried Rice", Uses: []string{"rice"}, Missing: []string{}, Method: "stovetop", Difficulty: "easy"}},
|
|
}.PublicJSON()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, err := q.PublishJobResult(t.Context(), db.PublishJobResultParams{
|
|
Result: sql.NullString{String: string(partial), Valid: true}, UpdatedAt: now, ID: id,
|
|
}); err != nil || n != 1 {
|
|
t.Fatalf("publish partial result = %d, %v", n, err)
|
|
}
|
|
|
|
startRunner(t, runner)
|
|
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
|
assertFailedWithoutIdeas(t, job, jobs.CodeInterrupted)
|
|
if res := job["result"].(map[string]any); res["stage"] != "grounding" {
|
|
t.Errorf("result = %v, want the rest of the partial result kept", res)
|
|
}
|
|
if n := storedIdeas(t, st, id); n != 1 {
|
|
t.Fatalf("stored ideas = %d, want the interrupted job's 1 still stored", n)
|
|
}
|
|
}
|
|
|
|
// When the write clearing a failed job's ideas itself fails, the job fails
|
|
// with the runner's internal_error and its stored result keeps the ideas;
|
|
// it still shows none.
|
|
func TestIdeasJobWhoseClearingWriteFailsShowsNoIdeas(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize, search, pages, images := scriptIdeas(t,
|
|
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
|
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
|
map[string]error{"Rice Pilaf": errors.New("boom")})
|
|
h, runner, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
|
|
// Refuse any write that empties a running job's shown ideas.
|
|
if _, err := st.Writer.ExecContext(t.Context(), `
|
|
CREATE TRIGGER refuse_clearing_ideas BEFORE UPDATE OF result ON jobs
|
|
WHEN NEW.status = 'running'
|
|
AND json_array_length(OLD.result, '$.ideas') > 0
|
|
AND json_array_length(NEW.result, '$.ideas') = 0
|
|
BEGIN SELECT RAISE(ABORT, 'clearing refused'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
startRunner(t, runner)
|
|
|
|
id := startIdeasJob(t, h)
|
|
assertFailedWithoutIdeas(t, waitJob(t, h, id), jobs.CodeInternal)
|
|
if n := storedIdeas(t, st, id); n != 4 {
|
|
t.Fatalf("stored ideas = %d, want the 4 the refused write left", n)
|
|
}
|
|
}
|
|
|
|
func TestJobsEndpointRefusesIdeasKind(t *testing.T) {
|
|
t.Parallel()
|
|
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
|
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "ideas_from_ingredients", "payload": {"ingredients": [], "quick_only": false}}`)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
|
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
|
}
|
|
}
|