package shopping_test import ( "bytes" "context" "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "io" "log/slog" "net/url" "reflect" "strings" "sync" "testing" "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/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest" "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/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) // week is the Monday the tests plan, and weekDates its days. var ( week = mustDate("2026-10-12") weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} ) func mustDate(s string) store.LocalDate { d, err := store.ParseLocalDate(s) if err != nil { panic(err) } return d } type env struct { svc *shopping.Service st *store.Store dataDir string planID string // ids are the dinners' ids by name. ids map[string]string // runner runs the title jobs, two at a time, once start starts it. runner *jobs.Runner // model answers archive_title, which titles the Supply runs; with // nothing queued it fails, and the lists stay untitled. model *llm.Fake // beforeTitle, when set, runs as archive_title is asked for a title. beforeTitle func() // afterTitle, when set, runs once archive_title has returned, before // its title is stored. afterTitle func() // beforeModel, when set, runs as the model is sent a request. beforeModel func(llm.Request) // log holds what the service logged. log *syncBuffer // chat is the fake Google Chat endpoint chatSvc posts to; no webhook // is stored until a test stores one. chat *chattest.Chat chatSvc *chat.Service } // syncBuffer is a bytes.Buffer safe for concurrent use. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } func newEnv(t *testing.T) *env { t.Helper() dataDir := t.TempDir() st, err := store.Open(t.Context(), dataDir) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := st.Close(); err != nil { t.Error(err) } }) if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil { t.Fatal(err) } e := &env{st: st, dataDir: dataDir, ids: map[string]string{}, model: &llm.Fake{}, log: &syncBuffer{}} 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 hookedModel{e}, nil }, Store: st, Guard: guard, } title := func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { if e.beforeTitle != nil { e.beforeTitle() } out, err := skills.TitleArchive(ctx, cfg, scope, in) if e.afterTitle != nil { e.afterTitle() } return out, err } logger := slog.New(slog.NewTextHandler(e.log, nil)) e.runner, err = jobs.NewRunner(st, logger, 2) if err != nil { t.Fatal(err) } key := make([]byte, secretbox.KeySize) rand.Read(key) box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) if err != nil { t.Fatal(err) } e.chat = chattest.New(t) e.chatSvc, err = chat.NewService(st, box, &url.URL{Scheme: "https", Host: "meals.example.com"}, e.chat.Transport()) if err != nil { t.Fatal(err) } e.svc, err = shopping.NewService(st, title, e.runner, notifytest.New(t, st), e.chatSvc, logger) if err != nil { t.Fatal(err) } return e } // hookedModel is the env's model, running beforeModel first. type hookedModel struct{ e *env } func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) { if m.e.beforeModel != nil { m.e.beforeModel(req) } return m.e.model.Generate(ctx, req) } func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) { return m.e.model.ListModels(ctx) } func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) } // start starts the env's job runner, stopping it when the test ends. func (e *env) start(t *testing.T) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) if err := e.runner.Start(ctx); err != nil { cancel() t.Fatal(err) } t.Cleanup(func() { cancel() e.runner.Wait() }) } // waitTitle waits for g's title job to finish and returns it. func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job { t.Helper() if g.TitleJobID == nil { t.Fatal("generation enqueued no title job") } deadline := time.Now().Add(10 * time.Second) for { job, err := e.runner.Get(t.Context(), *g.TitleJobID) if err != nil { t.Fatal(err) } if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed { return job } if time.Now().After(deadline) { t.Fatalf("title job %s still %s", job.ID, job.Status) } time.Sleep(5 * time.Millisecond) } } // ing is a recipe ingredient as recipe grounding stores it: an empty // qty or unit is NULL. type ing struct { name, qty, unit string } // dinner stores a Regular named name and returns its id. With ings it is // grounded in a fetched recipe listing them; with none it has no recipe. func (e *env) dinner(t *testing.T, name string, ings ...ing) string { t.Helper() q := db.New(e.st.Writer) now := store.Now().String() var recipeID sql.NullString status := "pending" if ings != nil { recipeID = e.recipe(t, name, ings) status = "grounded" } d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{ ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular", RecipeID: recipeID, GroundingStatus: status, CreatedAt: now, UpdatedAt: now, }) if err != nil { t.Fatal(err) } e.ids[name] = d.ID return d.ID } // recipe stores a fetched recipe titled title with ings, as // recipes.Grounder stores a normalized page, and returns its id. func (e *env) recipe(t *testing.T, title string, ings []ing) sql.NullString { t.Helper() q := db.New(e.st.Writer) now := store.Now().String() r, err := q.UpsertRecipe(t.Context(), db.UpsertRecipeParams{ ID: store.NewID(), SourceUrl: "https://recipes.example/" + strings.ReplaceAll(strings.ToLower(title), " ", "-"), Title: title, RawJsonld: "{}", FetchedAt: now, CreatedAt: now, UpdatedAt: now, }) if err != nil { t.Fatal(err) } e.setIngredients(t, r.ID, ings) return sql.NullString{String: r.ID, Valid: true} } // setIngredients replaces recipe id's ingredients with ings, as a // regrounding does. func (e *env) setIngredients(t *testing.T, id string, ings []ing) { t.Helper() q := db.New(e.st.Writer) if err := q.DeleteRecipeIngredients(t.Context(), id); err != nil { t.Fatal(err) } now := store.Now().String() for i, in := range ings { qtyUnit := strings.TrimSpace(in.qty + " " + in.unit) if err := q.CreateRecipeIngredient(t.Context(), db.CreateRecipeIngredientParams{ ID: store.NewID(), RecipeID: id, Position: int64(i), RawText: strings.TrimSpace(qtyUnit + " " + in.name), Name: sql.NullString{String: in.name, Valid: true}, Qty: sql.NullString{String: in.qty, Valid: in.qty != ""}, Unit: sql.NullString{String: in.unit, Valid: in.unit != ""}, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatal(err) } } } // recipeOf returns the id of dinner name's recipe. func (e *env) recipeOf(t *testing.T, name string) string { t.Helper() d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name]) if err != nil { t.Fatal(err) } return d.RecipeID.String } // plan stores the week's plan with the dinners named names on weekDates, // in order. func (e *env) plan(t *testing.T, names ...string) { t.Helper() q := db.New(e.st.Writer) now := store.Now().String() e.planID = store.NewID() if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: e.planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil { t.Fatal(err) } for i, name := range names { if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ ID: store.NewID(), PlanID: e.planID, Date: weekDates[i], DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatal(err) } } } // swap plans the dinner named name on date, as a swap on the Draft board // does. func (e *env) swap(t *testing.T, date, name string) { t.Helper() n, err := db.New(e.st.Writer).SwapPlanDay(t.Context(), db.SwapPlanDayParams{ DinnerID: sql.NullString{String: e.ids[name], Valid: true}, UpdatedAt: store.Now().String(), PlanID: e.planID, Date: date, }) if err != nil || n != 1 { t.Fatalf("swap %s to %s: %d rows, %v", date, name, n, err) } } // haveIt marks the item named name of dinner's group "have it". func (e *env) haveIt(t *testing.T, l shopping.List, dinner, name string) string { t.Helper() for _, g := range l.Groups { for _, it := range g.Items { if g.DinnerName == dinner && it.Name == name { if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, it.ID); err != nil { t.Fatal(err) } return it.ID } } } t.Fatalf("no item %q of %s", name, dinner) return "" } func (e *env) generate(t *testing.T) shopping.Generated { t.Helper() g, err := e.svc.Generate(t.Context(), week) if err != nil { t.Fatal(err) } return g } // lines renders l one item a line as "position date dinner: position // name [qty] needed|have it". func lines(l shopping.List) []string { var out []string for _, g := range l.Groups { for _, it := range g.Items { qty := "-" if it.Qty != nil { qty = *it.Qty } mark := "needed" if !it.Needed { mark = "have it" } out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", g.Position, g.Date, g.DinnerName, it.Position, it.Name, qty, mark)) } } return out } // stored renders the stored list of the week as lines does, so a test // checks the database holds what Generate returned. func (e *env) stored(t *testing.T) []string { t.Helper() rows, err := e.st.Reader.Query(`SELECT g.position, g.date, d.name, i.position, i.name, COALESCE(i.qty, '-'), i.needed FROM shopping_lists l JOIN shopping_groups g ON g.list_id = l.id JOIN dinners d ON d.id = g.dinner_id JOIN shopping_items i ON i.group_id = g.id WHERE l.week_start = ? ORDER BY g.position, i.position`, week.String()) if err != nil { t.Fatal(err) } defer rows.Close() var out []string for rows.Next() { var ( gpos, ipos, needed int date, dinner, name string qty string ) if err := rows.Scan(&gpos, &date, &dinner, &ipos, &name, &qty, &needed); err != nil { t.Fatal(err) } mark := "needed" if needed == 0 { mark = "have it" } out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", gpos, date, dinner, ipos, name, qty, mark)) } if err := rows.Err(); err != nil { t.Fatal(err) } return out } // count returns the number of rows in table. func (e *env) count(t *testing.T, table string) int { t.Helper() var n int if err := e.st.Reader.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { t.Fatal(err) } return n } func wantLines(t *testing.T, what string, got, want []string) { t.Helper() if !reflect.DeepEqual(got, want) { t.Errorf("%s =\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(want, "\n ")) } } func TestGenerateSevenDayPlan(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"kidney beans", "2", "cans"}, ing{"onion", "1", ""}) e.dinner(t, "Pot Roast", ing{"chuck roast", "3", "lb"}, ing{"carrots", "4", ""}) e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"bell peppers", "3", ""}, ing{"tortillas", "8", ""}) e.dinner(t, "Spaghetti Bolognese", ing{"spaghetti", "1", "lb"}, ing{"crushed tomatoes", "28", "oz"}) e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}, ing{"dill", "", "sprig"}) e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"bean sprouts", "2", "cups"}, ing{"salt", "", ""}) e.dinner(t, "Shawarma Bowls", ing{"chicken thighs", "2", "lb"}, ing{"pita", "4", ""}) e.plan(t, "Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Shawarma Bowls") g := e.generate(t) want := []string{ "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", "0 2026-10-12 Beef Chili: 1 kidney beans [2 cans] needed", "0 2026-10-12 Beef Chili: 2 onion [1] needed", "1 2026-10-13 Pot Roast: 0 chuck roast [3 lb] needed", "1 2026-10-13 Pot Roast: 1 carrots [4] needed", "2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed", "2 2026-10-14 Chicken Fajitas: 1 bell peppers [3] needed", "2 2026-10-14 Chicken Fajitas: 2 tortillas [8] needed", "3 2026-10-15 Spaghetti Bolognese: 0 spaghetti [1 lb] needed", "3 2026-10-15 Spaghetti Bolognese: 1 crushed tomatoes [28 oz] needed", "4 2026-10-16 Sheet Pan Salmon: 0 salmon fillets [4] needed", "4 2026-10-16 Sheet Pan Salmon: 1 lemon [1] needed", "4 2026-10-16 Sheet Pan Salmon: 2 dill [sprig] needed", "5 2026-10-17 Pad Thai: 0 rice noodles [8 oz] needed", "5 2026-10-17 Pad Thai: 1 bean sprouts [2 cups] needed", "5 2026-10-17 Pad Thai: 2 salt [-] needed", "6 2026-10-18 Shawarma Bowls: 0 chicken thighs [2 lb] needed", "6 2026-10-18 Shawarma Bowls: 1 pita [4] needed", } wantLines(t, "list", lines(g.List), want) wantLines(t, "stored list", e.stored(t), want) if len(g.Skipped) != 0 { t.Errorf("skipped = %+v, want none", g.Skipped) } for _, grp := range g.List.Groups { if grp.DinnerID != e.ids[grp.DinnerName] { t.Errorf("group %s dinner id = %s, want %s", grp.DinnerName, grp.DinnerID, e.ids[grp.DinnerName]) } } if g.List.WeekStart != "2026-10-12" || g.List.PlanID != e.planID || g.List.Status != shopping.StatusCurrent || g.List.Title != nil { t.Errorf("list = %+v, want the week's current, untitled list of plan %s", g.List, e.planID) } var title sql.NullString if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE id = ?`, g.List.ID).Scan(&title); err != nil { t.Fatal(err) } if title.Valid { t.Errorf("stored title = %q, want NULL until the AI titles the list", title.String) } // Generating again from the same plan changes nothing: same rows, same // ids, no extra rows. The list is still untitled, so it gets another // title job. again := e.generate(t) if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID { t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID)) } again.TitleJobID = g.TitleJobID if !reflect.DeepEqual(again, g) { t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g) } if n := e.count(t, "shopping_lists"); n != 1 { t.Errorf("%d lists, want 1", n) } if n := e.count(t, "shopping_items"); n != len(want) { t.Errorf("%d items stored, want %d", n, len(want)) } } // TestGenerateManualRecipe grounds a dinner with a recipe typed in, through // dinners.Service as the household does: its lines are stored unnormalized, // so each is an item as typed, with no quantity. func TestGenerateManualRecipe(t *testing.T) { e := newEnv(t) model := &llm.Fake{} grounder := recipestest.NewGrounder(t, e.st, e.dataDir, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, model) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) runner, err := jobs.NewRunner(e.st, logger, 1) if err != nil { t.Fatal(err) } dsvc, err := dinners.NewService(e.st, grounder, runner, logger) if err != nil { t.Fatal(err) } e.dinner(t, "Grandma's Goulash") minutes, method := 45, "stovetop" if _, err := dsvc.Ground(t.Context(), e.ids["Grandma's Goulash"], dinners.Fix{Manual: &dinners.ManualRecipe{ Ingredients: []string{"2 lb beef chuck", "1 onion, diced", "paprika to taste"}, Steps: []string{"Brown the beef."}, Minutes: &minutes, Method: &method, }}, "member-1"); err != nil { t.Fatal(err) } e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) e.plan(t, "Grandma's Goulash", "Beef Chili") g := e.generate(t) want := []string{ "0 2026-10-12 Grandma's Goulash: 0 2 lb beef chuck [-] needed", "0 2026-10-12 Grandma's Goulash: 1 1 onion, diced [-] needed", "0 2026-10-12 Grandma's Goulash: 2 paprika to taste [-] needed", "1 2026-10-13 Beef Chili: 0 ground beef [1 lb] needed", } wantLines(t, "list", lines(g.List), want) wantLines(t, "stored list", e.stored(t), want) if len(g.Skipped) != 0 { t.Errorf("skipped = %+v, want none", g.Skipped) } if reqs := model.Requests(); len(reqs) != 0 { t.Errorf("%d model requests, want none", len(reqs)) } } // TestGenerateSkipsDinnersWithoutIngredients plans a dinner with no recipe // (its grounding pending) and one whose recipe lists no ingredients: each // day gets no group and is reported with why. func TestGenerateSkipsDinnersWithoutIngredients(t *testing.T) { e := newEnv(t) e.dinner(t, "Mystery Stew") e.dinner(t, "Toast", []ing{}...) // a recipe with no ingredients e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) e.plan(t, "Mystery Stew", "Toast", "Beef Chili") g := e.generate(t) want := []string{"0 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed"} wantLines(t, "list", lines(g.List), want) wantLines(t, "stored list", e.stored(t), want) wantSkipped := []shopping.SkippedDay{ {Date: "2026-10-12", DinnerID: e.ids["Mystery Stew"], DinnerName: "Mystery Stew", Reason: shopping.SkipNoRecipe}, {Date: "2026-10-13", DinnerID: e.ids["Toast"], DinnerName: "Toast", Reason: shopping.SkipNoIngredients}, } if !reflect.DeepEqual(g.Skipped, wantSkipped) { t.Errorf("skipped = %+v, want %+v", g.Skipped, wantSkipped) } if n := e.count(t, "shopping_groups"); n != 1 { t.Errorf("%d groups stored, want 1: a skipped day gets no empty group", n) } } // TestGenerateAfterSwapKeepsHaveIt swaps a day to a new dinner and moves a // dinner to another day, and regrounds a dinner so its ingredients change // order and quantity: an item whose dinner and name are unchanged keeps // its id and "have it" wherever it now is; the rest is added as needed or // removed. func TestGenerateAfterSwapKeepsHaveIt(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"}) e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"tortillas", "8", ""}) e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}) e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""}) e.plan(t, "Beef Chili", "Chicken Fajitas", "Sheet Pan Salmon") first := e.generate(t).List onion := e.haveIt(t, first, "Beef Chili", "onion") tortillas := e.haveIt(t, first, "Chicken Fajitas", "tortillas") lemon := e.haveIt(t, first, "Sheet Pan Salmon", "lemon") // Tuesday becomes Pad Thai and Fajitas moves to Wednesday, dropping // the Salmon; the Chili is regrounded: onion first, no beans, a // pepper added and more beef. e.swap(t, "2026-10-13", "Pad Thai") e.swap(t, "2026-10-14", "Chicken Fajitas") e.setIngredients(t, e.recipeOf(t, "Beef Chili"), []ing{{"onion", "2", ""}, {"ground beef", "2", "lb"}, {"jalapeño", "1", ""}}) g := e.generate(t) want := []string{ "0 2026-10-12 Beef Chili: 0 onion [2] have it", "0 2026-10-12 Beef Chili: 1 ground beef [2 lb] needed", "0 2026-10-12 Beef Chili: 2 jalapeño [1] needed", "1 2026-10-13 Pad Thai: 0 rice noodles [8 oz] needed", "1 2026-10-13 Pad Thai: 1 lime [2] needed", "2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed", "2 2026-10-14 Chicken Fajitas: 1 tortillas [8] have it", } wantLines(t, "list", lines(g.List), want) wantLines(t, "stored list", e.stored(t), want) ids := map[string]string{} for _, grp := range g.List.Groups { for _, it := range grp.Items { ids[grp.DinnerName+"/"+it.Name] = it.ID } } if ids["Beef Chili/onion"] != onion || ids["Chicken Fajitas/tortillas"] != tortillas { t.Errorf("kept items have ids %s and %s, want %s and %s", ids["Beef Chili/onion"], ids["Chicken Fajitas/tortillas"], onion, tortillas) } var n int if err := e.st.Reader.QueryRow(`SELECT count(*) FROM shopping_items WHERE id = ?`, lemon).Scan(&n); err != nil { t.Fatal(err) } if n != 0 { t.Error("the Salmon's lemon is still stored after the Salmon left the plan") } if got := e.count(t, "shopping_items"); got != len(want) { t.Errorf("%d items stored, want %d", got, len(want)) } if got := e.count(t, "shopping_groups"); got != 3 { t.Errorf("%d groups stored, want 3", got) } if g.List.ID != first.ID { t.Errorf("list id = %s, want the first generation's %s", g.List.ID, first.ID) } } // TestGenerateMatchesRepeatedNamesInOrder lists a name twice in one // dinner: the second "salt" marked "have it" stays the second. func TestGenerateMatchesRepeatedNamesInOrder(t *testing.T) { e := newEnv(t) e.dinner(t, "Pasta", ing{"salt", "1", "tbsp"}, ing{"spaghetti", "1", "lb"}, ing{"salt", "", "pinch"}) e.plan(t, "Pasta") first := e.generate(t).List second := first.Groups[0].Items[2].ID if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, second); err != nil { t.Fatal(err) } e.setIngredients(t, e.recipeOf(t, "Pasta"), []ing{{"spaghetti", "1", "lb"}, {"salt", "1", "tbsp"}, {"salt", "", "pinch"}}) g := e.generate(t) wantLines(t, "list", lines(g.List), []string{ "0 2026-10-12 Pasta: 0 spaghetti [1 lb] needed", "0 2026-10-12 Pasta: 1 salt [1 tbsp] needed", "0 2026-10-12 Pasta: 2 salt [pinch] have it", }) if id := g.List.Groups[0].Items[2].ID; id != second { t.Errorf("second salt id = %s, want %s", id, second) } } func TestGenerateWeekWithoutPlan(t *testing.T) { e := newEnv(t) if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrPlanNotFound) { t.Fatalf("Generate = %v, want ErrPlanNotFound", err) } if n := e.count(t, "shopping_lists"); n != 0 { t.Errorf("%d lists stored, want none", n) } } func TestGenerateRefusesNonMonday(t *testing.T) { e := newEnv(t) if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-13")); err == nil { t.Fatal("Generate of a Tuesday succeeded") } } // TestGenerateRefusesFrozenList leaves a list frozen into the Supply // archive as it is. func TestGenerateRefusesFrozenList(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) e.plan(t, "Beef Chili") e.generate(t) if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { t.Fatal(err) } e.swap(t, "2026-10-12", "Pad Thai") if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) { t.Fatalf("Generate = %v, want ErrFrozen", err) } wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"}) } // TestGeneratePushedRefusesEditedPlan makes the Supply run of a pushed // plan at the revision pushed, then edits the plan: the pushed revision no // longer makes one, and the Supply run is left as it was. func TestGeneratePushedRefusesEditedPlan(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) e.plan(t, "Beef Chili") q := db.New(e.st.Writer) pushed, err := q.GetWeekPlan(t.Context(), week.String()) if err != nil { t.Fatal(err) } if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); err != nil { t.Fatalf("GeneratePushed at the pushed revision: %v", err) } e.swap(t, "2026-10-12", "Pad Thai") if err := q.MarkWeekPlanEdited(t.Context(), db.MarkWeekPlanEditedParams{UpdatedAt: store.Now().String(), ID: e.planID}); err != nil { t.Fatal(err) } if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); !errors.Is(err, shopping.ErrPlanChanged) { t.Fatalf("GeneratePushed after an edit = %v, want ErrPlanChanged", err) } wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"}) } // TestGenerateRefusesUnnamedIngredient: grounding always names a fetched // recipe's ingredient, so one without a name is an error, and nothing is // stored. func TestGenerateRefusesUnnamedIngredient(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) e.plan(t, "Beef Chili") if _, err := e.st.Writer.Exec(`UPDATE recipe_ingredients SET name = NULL WHERE position = 1`); err != nil { t.Fatal(err) } _, err := e.svc.Generate(t.Context(), week) if err == nil || !strings.Contains(err.Error(), `ingredient 2 ("1 onion") has no normalized name`) { t.Fatalf("Generate = %v, want the unnamed ingredient refused", err) } if n := e.count(t, "shopping_lists"); n != 0 { t.Errorf("%d lists stored, want none", n) } } // TestGetReadsStoredList reads back what Generate stored, with what the // household marked, and counts it. func TestGetReadsStoredList(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) e.dinner(t, "Pot Roast", ing{"carrots", "4", ""}) e.plan(t, "Beef Chili", "Pot Roast") made := e.generate(t).List e.haveIt(t, made, "Beef Chili", "onion") got, err := e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } wantLines(t, "read list", lines(got), []string{ "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", "0 2026-10-12 Beef Chili: 1 onion [1] have it", "1 2026-10-13 Pot Roast: 0 carrots [4] needed", }) if got.ID != made.ID || got.WeekStart != week.String() || got.Status != shopping.StatusCurrent || got.Title != nil || got.SentToChatAt != nil { t.Errorf("read list = %+v", got) } if c := got.Counts(); c != (shopping.Counts{Needed: 2, Total: 3}) { t.Errorf("Counts = %+v, want 2 of 3", c) } } func TestGetWeekWithoutList(t *testing.T) { e := newEnv(t) if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) { t.Fatalf("Get = %v, want ErrListNotFound", err) } if _, err := e.svc.Get(t.Context(), mustDate("2026-10-13")); err == nil { t.Fatal("Get of a Tuesday succeeded") } } // TestSetNeeded marks items and returns the counts after each change. func TestSetNeeded(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"garlic", "2", "cloves"}) e.plan(t, "Beef Chili") l := e.generate(t).List onion, garlic := l.Groups[0].Items[1].ID, l.Groups[0].Items[2].ID for _, step := range []struct { id string needed bool want shopping.Counts }{ {onion, false, shopping.Counts{Needed: 2, Total: 3}}, {onion, false, shopping.Counts{Needed: 2, Total: 3}}, {garlic, false, shopping.Counts{Needed: 1, Total: 3}}, {onion, true, shopping.Counts{Needed: 2, Total: 3}}, } { c, err := e.svc.SetNeeded(t.Context(), step.id, step.needed) if err != nil { t.Fatal(err) } if c != step.want { t.Errorf("SetNeeded(%s, %v) = %+v, want %+v", step.id, step.needed, c, step.want) } } wantLines(t, "stored list", e.stored(t), []string{ "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", "0 2026-10-12 Beef Chili: 1 onion [1] needed", "0 2026-10-12 Beef Chili: 2 garlic [2 cloves] have it", }) } func TestSetNeededUnknownItem(t *testing.T) { e := newEnv(t) if _, err := e.svc.SetNeeded(t.Context(), store.NewID(), false); !errors.Is(err, shopping.ErrItemNotFound) { t.Fatalf("SetNeeded = %v, want ErrItemNotFound", err) } } // TestSetNeededRefusesFrozenList leaves an item of a list in the Supply // archive as it was. func TestSetNeededRefusesFrozenList(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) e.plan(t, "Beef Chili") l := e.generate(t).List if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { t.Fatal(err) } if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[0].ID, false); !errors.Is(err, shopping.ErrFrozen) { t.Fatalf("SetNeeded = %v, want ErrFrozen", err) } wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"}) }