Merge remote-tracking branch 'origin/dev' into HEAD
# 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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user