Merge remote-tracking branch 'origin/dev' into HEAD
CI / ci (push) Successful in 7m27s
CI / ci (pull_request) Successful in 7m24s

# Conflicts:
#	cmd/server/main.go
#	internal/server/aisettings_test.go
#	internal/server/dinners_test.go
#	internal/server/google_test.go
#	internal/server/login_test.go
#	internal/server/media_test.go
#	internal/server/origin_test.go
#	internal/server/plans_test.go
#	internal/server/recipesearch_test.go
#	internal/server/server.go
#	internal/server/server_test.go
#	internal/server/settings_test.go
#	internal/server/suggestions_test.go
This commit is contained in:
2026-10-09 17:46:52 -04:00
85 changed files with 11796 additions and 171 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, 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), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string {
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
@@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) {
func TestNewRouterRequiresChat(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Chat service")
}
}
+469
View File
@@ -0,0 +1,469 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
// returns the body both raw (to tell null from absent) and decoded.
func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) {
t.Helper()
rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
var d dinners.Detail
dec := json.NewDecoder(rec.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&d); err != nil {
t.Fatalf("decode detail: %v", err)
}
return raw, d
}
// wantNull fails unless each key is present in raw and null.
func wantNull(t *testing.T, raw map[string]any, keys ...string) {
t.Helper()
for _, k := range keys {
if v, ok := raw[k]; !ok || v != nil {
t.Errorf("%s = %v (present %v), want null", k, v, ok)
}
}
}
// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page
// shows its photo at both stored sizes, its recipe's ingredients and steps
// in recipe order (not row order), its source site and the reason it was
// suggested.
func TestGetDinnerGroundedSideQuest(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" {
t.Fatalf("grounding job = %v", job)
}
id := created["id"].(string)
// Make it an accepted Side quest with the suggestion that created it,
// and reverse its recipe's positions so row order disagrees with
// recipe order.
var recipeID, photoID string
if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
} {
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
t.Fatal(err)
}
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
_, got := getDetail(t, e.h, id)
str := func(s string) *string { return &s }
minutes := int64(40)
want := dinners.Detail{
ID: id,
Name: "Chicken curry",
Status: "quest_log",
Origin: "side_quest",
Method: str("stovetop"),
MinutesTotal: &minutes,
Difficulty: str("easy"),
IsFavorite: true,
PhotoURLs: map[string]string{
"400": "/media/photos/" + photoID + "-400.jpg",
"1200": "/media/photos/" + photoID + "-1200.jpg",
},
Ingredients: []dinners.Ingredient{
{Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"},
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
},
Steps: []string{"Fry with the garlic.", "Chop the onion."},
Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)},
GroundingStatus: "grounded",
WhyPicked: str(reason),
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
// Each photo URL is served.
for _, u := range got.PhotoURLs {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil))
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" {
t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type"))
}
}
}
// TestGetDinnerManualRegular: a Regular added by hand and given a recipe
// typed in has no photo, no source and no reason, and its ingredients are
// the typed lines with nothing normalized.
func TestGetDinnerManualRegular(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
raw, got := getDetail(t, e.h, id)
wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error")
str := func(s string) *string { return &s }
minutes := int64(240)
want := dinners.Detail{
ID: id,
Name: "Family chili",
Status: "regular",
Origin: "regular",
Method: str("crockpot"),
MinutesTotal: &minutes,
Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}},
Steps: []string{"Brown.", "Simmer."},
GroundingStatus: "manual",
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
}
// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its
// real state — failed with its reason, no photo, no source and empty
// ingredients and steps — never a pretend recipe.
func TestGetDinnerFailedGrounding(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" {
t.Fatalf("grounding job = %v, want failed", job)
}
raw, got := getDetail(t, e.h, created["id"].(string))
wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty")
if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 {
t.Errorf("ingredients = %v, want []", raw["ingredients"])
}
if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 {
t.Errorf("steps = %v, want []", raw["steps"])
}
if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe ||
got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" {
t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got)
}
}
// TestGetDinnerNotFound: an unknown dinner and an archived one are both
// 404, and the detail needs a session like every /api/v1 route.
func TestGetDinnerNotFound(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Old favourite"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
for _, target := range []string{id, store.NewID()} {
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body)
}
}
live := createDinner(t, e.h, `{"name":"Taco night"}`)
req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil)
if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
}
}
// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https
// link shows it as source.url; one whose stored link carries a token in its
// query or userinfo, is http, or has a fragment shows its site name but
// source.url null and nothing of the link.
func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
clean = "https://kitchen.example/recipes/chicken-curry"
queryToken = "query-token-def456"
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
)
e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
}
var stored string
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != page {
t.Fatalf("stored source_url = %q, want %q", stored, page)
}
return id
}
source := func(what, id string) map[string]any {
t.Helper()
raw, _ := getDetail(t, e.h, id)
src, ok := raw["source"].(map[string]any)
if !ok {
t.Fatalf("source of %s = %v, want an object", what, raw["source"])
}
return src
}
cleanID := ground("Chicken curry", clean)
if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) {
t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean)
}
queryID := ground("Weeknight curry", queryPage)
withheld := func(what, id, site string, secrets ...string) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
}
for _, secret := range secrets {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body)
}
}
if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) {
t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site)
}
}
withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token")
// The other unsafe shapes, stored as a link grounded elsewhere would
// leave them.
for _, tc := range []struct{ what, link string }{
{"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"},
{"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"},
{"an http link", "http://kitchen.example/recipes/chicken-curry"},
{"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"},
} {
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil {
t.Fatal(err)
}
withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment")
}
// web/src/dinners/testdata/source_url_cases.json pins source as this
// handler sends it for each stored link; the recipe screen's tests
// render each one, so a link the server sends and the client refuses
// fails one side or the other.
fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json")
if err != nil {
t.Fatal(err)
}
var shared struct {
Cases []struct {
Stored string
Source map[string]any
}
}
if err := json.Unmarshal(fixture, &shared); err != nil {
t.Fatal(err)
}
if len(shared.Cases) == 0 {
t.Fatal("source_url_cases.json has no cases")
}
for _, c := range shared.Cases {
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil {
t.Fatal(err)
}
if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) {
got, _ := json.Marshal(src)
want, _ := json.Marshal(c.Source)
t.Errorf("source of %s = %s, want %s", c.Stored, got, want)
}
}
}
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
// under its host, so a recipe grounded in a pasted link whose host carries a
// token has site_name null and nothing from the link in its detail; a page
// that names its site keeps that name, even on a token-bearing host; and a
// stored name shaped like another host, as a redirect leaves, is withheld.
// The links carry a query, so source.url is withheld too and nothing of the
// link may appear.
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
bareToken = "private-token-abc123"
namedToken = "named-token-def456"
bare = "https://" + bareToken + ".example.com/recipe?print=1"
named = "https://www." + namedToken + ".example.com/recipe?print=1"
)
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id, recipeID string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
}
if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil {
t.Fatal(err)
}
return id, recipeID
}
storedSite := func(recipeID string) string {
t.Helper()
var site string
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
t.Fatal(err)
}
return site
}
check := func(what, id string, leaks []string, want any) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
}
for _, leak := range leaks {
if strings.Contains(strings.ToLower(rec.Body.String()), leak) {
t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
}
}
bareID, bareRecipe := ground("Chicken curry", bare)
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
t.Fatalf("stored site_name = %q, want the link's host", got)
}
check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil)
namedID, namedRecipe := ground("Weeknight curry", named)
if got := storedSite(namedRecipe); got != "Curry House" {
t.Fatalf("stored site_name = %q, want the page's", got)
}
check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House")
const redirectHost = "redirect-token-ghi789.example.net"
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
t.Fatal(err)
}
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
}
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
// the suggestion that created the dinner, never that of an earlier
// suggestion row for it that was declined.
func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
// The declined row is created first, so it is the earliest by both
// created_at and id.
for _, s := range []struct{ reason, status string }{
{"Passed on last week.", "declined"},
{reason, "accepted"},
} {
sid := store.NewID()
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{
Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid,
}); err != nil || n != 1 {
t.Fatalf("decide %s = %d, %v", s.status, n, err)
}
}
if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason {
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
}
}
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
// steps at one position has no order anyone can trust, so its detail is a
// 500 rather than the recipe in some order.
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
t.Fatal(err)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
}
}
}
+18
View File
@@ -70,6 +70,24 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
// reason it was suggested. An archived dinner is 404, like an unknown one,
// not a 200 with its status: it has no detail to show. source never has the
// recipe page's link, and source.site_name is null unless the page itself
// named its site — a name that could be a host, the link's or a redirect's,
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
// {site}" only when site_name is not null.
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDinnerError(w, r, logger, "get dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinner adds a Regular, recording the session's actor as updated_by,
// and answers 201 with it and its grounding_job_id without waiting for the
// job. When the job cannot be enqueued it still answers 201, with the dinner
+8 -2
View File
@@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.runner = runner
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
// The router serves the photos the grounder caches.
dataDir := t.TempDir()
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
}
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+417
View File
@@ -0,0 +1,417 @@
package server
import (
"context"
"database/sql"
"io"
"log/slog"
"net/http"
"net/url"
"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/dinners"
"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/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// actionsZone is the household zone of newIdeaActionsRouter.
const actionsZone = "America/New_York"
// newIdeaActionsRouter returns a started router whose ideas, dinners and
// plan services share one store, whose household settings are seeded, so
// an idea's actions read the job, make the dinner and swap the plan of the
// same household. The ideas service runs both skills through the real
// harness with the scripted model and grounds with the scripted fakes.
func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(t.Context(), st, actionsZone); 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)
}
ideasSvc, err := ideas.NewService(st, g, generate, quick, runner, logger)
if err != nil {
t.Fatal(err)
}
dinnerSvc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
t.Error("draft_week ran: an idea action must never draft a plan")
return skills.Week{}, context.Canceled
}
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
startRunner(t, runner)
return h, st
}
// finishedIdeasJob runs an ideas from ingredients job that keeps names, in
// order, and returns the router, store, job id and its ideas' recipe ids.
func finishedIdeasJob(t *testing.T, names ...string) (http.Handler, *store.Store, string, []string) {
t.Helper()
model, normalize, search, pages, images := scriptIdeas(t, names, names, nil)
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
id := startIdeasJob(t, h)
job := waitJob(t, h, id)
if job["status"] != "succeeded" {
t.Fatalf("ideas job = %v", job)
}
var recipeIDs []string
for _, i := range job["result"].(map[string]any)["ideas"].([]any) {
recipeIDs = append(recipeIDs, i.(map[string]any)["recipe_id"].(string))
}
if len(recipeIDs) != len(names) {
t.Fatalf("ideas job kept %d ideas, want %d", len(recipeIDs), len(names))
}
return h, st, id, recipeIDs
}
func ideaPath(jobID, i, action string) string {
return "/api/v1/ideas/" + jobID + "/items/" + i + "/" + action
}
// dinnerCount is how many dinners the store holds, archived or not.
func dinnerCount(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// thisWeek is today in the household's zone and the Monday of its week.
func thisWeek(t *testing.T) (week, today store.LocalDate) {
t.Helper()
zone, err := household.LoadZone(actionsZone)
if err != nil {
t.Fatal(err)
}
today = household.Today(time.Now(), zone)
return household.WeekStart(today), today
}
// planThisWeek stores a plan of this week with a Regular on every day and
// the given status, and returns the week's Monday and today.
func planThisWeek(t *testing.T, st *store.Store, status string) (week, today store.LocalDate) {
t.Helper()
ctx := t.Context()
week, today = thisWeek(t)
days, err := household.WeekDays(week)
if err != nil {
t.Fatal(err)
}
q := db.New(st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for _, date := range days {
name := "Regular " + date.String()
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date.String(),
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
if _, err := st.Writer.ExecContext(ctx, `UPDATE week_plans SET status = ? WHERE id = ?`, status, planID); err != nil {
t.Fatal(err)
}
return week, today
}
// planRevision is the revision of the plan of week.
func planRevision(t *testing.T, st *store.Store, week store.LocalDate) int64 {
t.Helper()
row, err := db.New(st.Reader).GetWeekPlan(t.Context(), week.String())
if err != nil {
t.Fatal(err)
}
return row.Revision
}
// Adding an idea to the Quest log makes a quest_log dinner grounded in the
// idea's own recipe, with origin ingredients, without grounding it again;
// asking again answers the same dinner and makes no second one.
func TestPostIdeaQuestLogAddsGroundedDinnerOnce(t *testing.T) {
t.Parallel()
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
if rec.Code != http.StatusCreated {
t.Fatalf("POST quest-log = %d: %s", rec.Code, rec.Body)
}
if d["name"] != "Rice Pudding" || d["status"] != "quest_log" || d["origin"] != "ingredients" ||
d["grounding_status"] != "grounded" || d["recipe_id"] != recipeIDs[1] || d["photo_url"] == nil ||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["updated_by"] != testSession.Subject {
t.Fatalf("dinner = %v, want Rice Pudding in the Quest log grounded in %s", d, recipeIDs[1])
}
rec, again := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
if rec.Code != http.StatusOK || again["id"] != d["id"] || again["status"] != "quest_log" {
t.Fatalf("POST quest-log again = %d %v, want 200 with dinner %v", rec.Code, again, d["id"])
}
if n := dinnerCount(t, st); n != 1 {
t.Fatalf("dinners = %d, want 1", n)
}
if names := listNames(t, h, "quest_log"); len(names) != 1 || names[0] != "Rice Pudding" {
t.Fatalf("Quest log = %q", names)
}
}
// A Quick dinners idea makes a dinner with origin quick.
func TestPostIdeaQuestLogQuickOrigin(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
quickReply(model, [3]any{"Chicken Fried Rice", "stovetop", 20})
h, _ := newIdeaActionsRouter(t, model, normalize, search, pages, images)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST quick = %d: %s", rec.Code, rec.Body)
}
id := body["jobId"].(string)
if job := waitJob(t, h, id); job["status"] != "succeeded" {
t.Fatalf("quick job = %v", job)
}
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
if rec.Code != http.StatusCreated || d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "quick" {
t.Fatalf("POST quest-log = %d %v, want a quick Quest log dinner", rec.Code, d)
}
}
// An idea repeating a dinner the household already has is that dinner, as
// it is: a Regular stays a Regular. One awaiting the household's choice
// is refused and nothing changes.
func TestPostIdeaQuestLogReusesExistingDinner(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
regular := createDinner(t, h, `{"name": "chicken fried rice"}`)
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
if rec.Code != http.StatusOK || d["id"] != regular || d["status"] != "regular" || d["origin"] != "regular" {
t.Fatalf("POST quest-log = %d %v, want 200 with Regular %s as it is", rec.Code, d, regular)
}
now := store.Now().String()
if _, err := db.New(st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(), Name: "Rice pudding", NormalizedName: harness.Normalize("Rice pudding"), Status: "suggested",
Origin: "side_quest", GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Fatalf("POST quest-log of a suggested Side quest = %d %v, want 409 duplicate_dinner", rec.Code, body)
}
if n := dinnerCount(t, st); n != 2 {
t.Fatalf("dinners = %d, want the Regular and the Side quest only", n)
}
}
// Cook tonight puts the idea's dinner on today of this week's plan through
// the plan's swap: the day is new, the plan's revision rises and a synced
// plan becomes changed. Asking again plans the same dinner.
func TestPostIdeaCookTonightSwapsToday(t *testing.T) {
t.Parallel()
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice")
week, today := planThisWeek(t, st, plan.StatusSynced)
before := planRevision(t, st, week)
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusOK {
t.Fatalf("POST cook-tonight = %d: %s", rec.Code, rec.Body)
}
d := body["dinner"].(map[string]any)
if d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "ingredients" || d["recipe_id"] != recipeIDs[0] {
t.Fatalf("dinner = %v", d)
}
assertTonight := func(body map[string]any) {
t.Helper()
p := body["plan"].(map[string]any)
if p["week_start"] != week.String() || p["status"] != plan.StatusChanged {
t.Fatalf("plan = %v, want week %s changed", p, week)
}
found := false
for _, day := range p["days"].([]any) {
day := day.(map[string]any)
planned := day["dinner"].(map[string]any)["id"] == d["id"]
if day["date"] == today.String() {
found = true
if !planned || day["is_new"] != true {
t.Fatalf("today %s = %v, want %v new", today, day, d["id"])
}
} else if planned {
t.Fatalf("dinner also on %v", day["date"])
}
}
if !found {
t.Fatalf("plan has no day %s", today)
}
}
assertTonight(body)
if after := planRevision(t, st, week); after != before+1 {
t.Fatalf("plan revision = %d, want %d", after, before+1)
}
rec, body = callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != d["id"] {
t.Fatalf("POST cook-tonight again = %d %v", rec.Code, body)
}
assertTonight(body)
if n := dinnerCount(t, st); n != 8 {
t.Fatalf("dinners = %d, want the seven Regulars and one idea", n)
}
}
// Cook tonight reuses a dinner the household already has.
func TestPostIdeaCookTonightReusesExistingDinner(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
_, today := planThisWeek(t, st, plan.StatusDraft)
regular := createDinner(t, h, `{"name": "Chicken fried rice"}`)
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != regular {
t.Fatalf("POST cook-tonight = %d %v, want Regular %s", rec.Code, body, regular)
}
for _, day := range body["plan"].(map[string]any)["days"].([]any) {
day := day.(map[string]any)
if day["date"] == today.String() && (day["dinner"].(map[string]any)["id"] != regular || day["is_new"] != false) {
t.Fatalf("today = %v, want Regular %s", day, regular)
}
}
}
// With no plan for this week, cook tonight answers 409 no_plan and changes
// nothing: no dinner is made and no plan is drafted or stored.
func TestPostIdeaCookTonightWithoutPlan(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusConflict || errorCode(body) != "no_plan" {
t.Fatalf("POST cook-tonight = %d %v, want 409 no_plan", rec.Code, body)
}
if n := dinnerCount(t, st); n != 0 {
t.Fatalf("dinners = %d, want none", n)
}
var plans, drafts int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM week_plans`).Scan(&plans); err != nil {
t.Fatal(err)
}
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&drafts); err != nil {
t.Fatal(err)
}
if plans != 0 || drafts != 0 {
t.Fatalf("plans = %d, drafts = %d, want none", plans, drafts)
}
}
// An idea that is not an index of a succeeded ideas job is refused.
func TestPostIdeaActionsRefuse(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
planThisWeek(t, st, plan.StatusDraft)
// A Regular's grounding job is a job, but finds no ideas.
rec, body := callDinners(t, h, http.MethodPost, dinnersPath, `{"name": "Beef Stew"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST /dinners = %d: %s", rec.Code, rec.Body)
}
other := body["grounding_job_id"].(string)
for _, action := range []string{"quest-log", "cook-tonight"} {
for _, tc := range []struct {
job, i string
status int
code string
}{
{id, "x", http.StatusBadRequest, "invalid_request"},
{id, "-1", http.StatusBadRequest, "invalid_request"},
{id, "01", http.StatusBadRequest, "invalid_request"},
{id, "1", http.StatusNotFound, "not_found"},
{"unknown", "0", http.StatusNotFound, "not_found"},
{other, "0", http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, h, http.MethodPost, ideaPath(tc.job, tc.i, action), "")
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("POST %s %s/%s = %d %v, want %d %s", action, tc.job, tc.i, rec.Code, body, tc.status, tc.code)
}
}
}
if n := dinnerCount(t, st); n != 8 {
t.Fatalf("dinners = %d, want the plan's seven Regulars and Beef Stew only", n)
}
}
// While the job still finds ideas, an idea is not acted on: its index may
// still move.
func TestPostIdeaActionsWhileJobRuns(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := scriptIdeas(t, []string{"Chicken Fried Rice"}, []string{"Chicken Fried Rice"}, nil)
gate := make(chan struct{})
search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
id := startIdeasJob(t, h)
for _, action := range []string{"quest-log", "cook-tonight"} {
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", action), "")
if rec.Code != http.StatusConflict || errorCode(body) != "ideas_not_ready" {
t.Errorf("POST %s = %d %v, want 409 ideas_not_ready", action, rec.Code, body)
}
}
close(gate)
waitJob(t, h, id)
if n := dinnerCount(t, st); n != 0 {
t.Fatalf("dinners = %d, want none", n)
}
}
+224
View File
@@ -0,0 +1,224 @@
package server
import (
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
)
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
const maxIdeasBody = 16 << 10
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
// fields are required.
type ideasFromIngredients struct {
Ingredients []string `json:"ingredients"`
QuickOnly *bool `json:"quick_only"`
}
// postIdeasFromIngredients starts finding ideas from the ingredients on
// hand and answers 202 with the jobId that GET /jobs/{id} reports on,
// without waiting for the job. An ingredient list the household must
// correct is 400.
func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
switch {
case body.Ingredients == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required")
return
case body.QuickOnly == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required")
return
}
started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly)
var invalid *ideas.InvalidError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
return
case err != nil:
logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
// ideasQuick is the POST /ideas/quick body. Both fields are required:
// exclude is [] on a first page.
type ideasQuick struct {
Kind *string `json:"kind"`
Exclude []string `json:"exclude"`
}
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
// crockpot or under30, none named in exclude, and answers 202 with the
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
// missing or unknown kind, or an exclude list the household must correct,
// is 400.
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
switch {
case body.Kind == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
return
case body.Exclude == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
return
}
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
var invalid *ideas.InvalidError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
return
case err != nil:
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers
// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found
// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while
// the job is still finding ideas.
func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) {
raw := chi.URLParam(r, "i")
i, err := strconv.Atoi(raw)
if err != nil || i < 0 || strconv.Itoa(i) != raw {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more")
return ideas.Chosen{}, false
}
idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i)
switch {
case errors.Is(err, ideas.ErrIdeaNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found")
return ideas.Chosen{}, false
case errors.Is(err, ideas.ErrIdeasNotReady):
writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error())
return ideas.Chosen{}, false
case err != nil:
logger.ErrorContext(r.Context(), "read idea", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return ideas.Chosen{}, false
}
return idea, true
}
// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log,
// recording the session's actor as updated_by, as a dinner grounded in
// the recipe the idea was grounded in, with origin ingredients or quick.
// It answers 201 with the dinner made, or 200 with the dinner the idea
// repeats (see dinners.Service.AddToQuestLog), so asking again answers the
// same dinner. A repeated dinner awaiting the household's choice is 409
// duplicate_dinner.
func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
if !ok {
return
}
d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "add idea to Quest log", err)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, r, logger, status, d)
}
}
// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer:
// the idea's dinner and the week's plan with it tonight.
type cookedTonight struct {
Dinner dinners.Dinner `json:"dinner"`
Plan plan.Plan `json:"plan"`
}
// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today,
// in the household's zone, of the plan of today's week. The idea's dinner
// is added or found as postIdeaQuestLog does, then swapped onto today
// through plan.Service.Swap, exactly as a swap on the Draft board, so the
// plan is marked edited and a synced plan becomes changed. It answers 200
// with the dinner and the plan as swapped; asking again plans the same
// dinner again.
//
// When today's week has no plan it answers 409 no_plan and changes
// nothing: no dinner is added and no plan is made, since a plan holds all
// seven days; the household plans the week first. The week is always
// today's own, so no other day is ever written. A dinner already on
// another day of the week is 409 dinner_already_planned, after the dinner
// is added or found.
func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
if !ok {
return
}
week, today, err := planSvc.Tonight(r.Context())
if errors.Is(err, plan.ErrNotFound) {
writeNoPlan(w, r, logger)
return
}
if err != nil {
writePlanError(w, r, logger, "find tonight's plan", err)
return
}
d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "add idea to cook tonight", err)
return
}
p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID)
if errors.Is(err, plan.ErrNotFound) {
// Plans are never deleted, so the plan Tonight found is still
// there; this is said loudly rather than answered as success.
logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String())
writeNoPlan(w, r, logger)
return
}
if err != nil {
writePlanError(w, r, logger, "cook idea tonight", err)
return
}
writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p})
}
}
// writeNoPlan answers 409 no_plan: today's week has no plan to put
// tonight's dinner on, so the household plans the week first.
func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first")
}
+350
View File
@@ -0,0 +1,350 @@
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, 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)
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, 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), newTestRunner(t),
r, 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), newTestIdeas(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair)
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, 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), svc, newTestRunner(t),
r, 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), svc, newTestIdeas(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
@@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, 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), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, 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), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil push service")
}
}
+181
View File
@@ -0,0 +1,181 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
const quickPath = "/api/v1/ideas/quick"
// quickRecipe makes the search for name find a page titled title with a
// total time of minutes, and scripts the normalize_recipe reply grounding
// it takes. "Slow Cooker" in title grounds the method as crockpot.
func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) {
t.Helper()
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
search.Results[name] = []recipes.Candidate{{URL: page}}
pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1))
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
// quickReply scripts one quick_ideas reply holding the ideas named, each
// searched by its name.
func quickReply(model *llm.Fake, ideas ...[3]any) {
list := make([]string, len(ideas))
for i, idea := range ideas {
list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0])
}
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}},
&recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}}
}
func TestPostIdeasQuickRefusesBadRequests(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 }{
{`{"exclude": []}`, "kind: required"},
{`{"kind": "crockpot"}`, "exclude: required"},
{`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`},
{`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`},
{`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`},
{`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"},
{`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`},
{`{"kind": 30, "exclude": []}`, "kind: want a JSON string"},
{`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`},
} {
rec, body := callDinners(t, h, http.MethodPost, quickPath, 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)
}
}
}
// The job answers 202 at once and its result fills idea by idea: one
// idea is shown while another still grounds. The kind is checked against
// the grounded recipe and excluded names never come back.
func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45)
quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30)
gate := make(chan struct{})
search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate}
quickReply(model,
[3]any{"Easy Shrimp Tacos", "stovetop", 15},
[3]any{"Chicken Fried Rice", "stovetop", 20},
[3]any{"Beef Stroganoff", "stovetop", 25},
[3]any{"Garlic Shrimp Pasta", "stovetop", 20},
)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`)
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)
}
// While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows.
deadline := time.Now().Add(10 * time.Second)
for {
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
if res, _ := job["result"].(map[string]any); res != nil {
if list, _ := res["ideas"].([]any); len(list) == 1 {
if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" {
t.Fatalf("partial job = %v", job)
}
break
}
}
if time.Now().After(deadline) {
t.Fatal("no idea was shown while another still grounded")
}
time.Sleep(5 * time.Millisecond)
}
close(gate)
job := waitJob(t, h, id)
if job["status"] != "succeeded" || job["kind"] != "quick_ideas" {
t.Fatalf("GET job = %v", job)
}
res := job["result"].(map[string]any)
if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 {
t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res)
}
if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` {
t.Errorf("result exclude = %s", ex)
}
var names []string
for _, i := range res["ideas"].([]any) {
names = append(names, i.(map[string]any)["name"].(string))
}
if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) {
t.Fatalf("ideas = %q, want %q", names, want)
}
got := res["ideas"].([]any)[1].(map[string]any)
for key, want := range map[string]any{
"method": "stovetop",
"minutes_total": 30.0,
"site_name": "R Kitchen",
"source_url": "https://r.example/garlic-shrimp-pasta",
} {
if got[key] != want {
t.Errorf("idea %s = %v, want %v", key, got[key], want)
}
}
if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil {
t.Errorf("idea = %v, want a recipe, photo and difficulty", got)
}
}
func TestFailedQuickJobShowsNoIdeas(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
ideas := [][3]any{{"Chicken Chili", "crockpot", 240}}
for i := range 5 {
ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300})
}
quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed")
}
func TestJobsEndpointRefusesQuickKind(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": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+15 -3
View File
@@ -17,6 +17,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
@@ -43,7 +44,7 @@ type RouteMounter interface {
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test;
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
@@ -62,12 +63,15 @@ type RouteMounter interface {
// /api/v1/integrations/google; pushSvc serves GET
// /api/v1/push/vapid-public-key, POST and DELETE
// /api/v1/push/subscriptions and POST
// /api/v1/settings/notifications/test-push; runner serves
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
// planSvc too, /cook-tonight; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -98,6 +102,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if pushSvc == nil {
return nil, errors.New("server: nil push service")
}
if ideasSvc == nil {
return nil, errors.New("server: nil ideas service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -149,6 +156,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
@@ -181,6 +189,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+45 -11
View File
@@ -25,6 +25,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"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/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -55,7 +56,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -194,6 +195,30 @@ func newTestSuggest(t *testing.T) *suggest.Service {
return svc
}
// newTestIdeas returns an ideas service with its own unstarted job runner
// and generators that always fail.
func newTestIdeas(t *testing.T) *ideas.Service {
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.IdeasRequest) (skills.Ideas, error) {
return skills.Ideas{}, errors.New("no model in this test")
}
failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
return skills.QuickIdeaList{}, errors.New("no model in this test")
}
svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestPlan returns a plan service over a migrated temp database, with
// its own unstarted job runner and a drafter that always fails.
func newTestPlan(t *testing.T) *plan.Service {
@@ -292,7 +317,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -301,7 +326,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -310,7 +335,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, 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), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, 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), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -319,7 +344,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -328,7 +353,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -337,7 +362,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
@@ -346,7 +371,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
@@ -355,16 +380,25 @@ func TestNewRouterRequiresShopping(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil shopping service")
}
}
func TestNewRouterRequiresIdeas(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, 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), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil ideas service")
}
}
func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
@@ -373,7 +407,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, 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), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, 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), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}