2026-10-08 15:16:51 -04:00
package shopping_test
import (
"bytes"
"context"
2026-10-09 18:04:05 -04:00
"crypto/rand"
2026-10-08 15:16:51 -04:00
"database/sql"
2026-10-09 18:04:05 -04:00
"encoding/base64"
2026-10-08 15:16:51 -04:00
"errors"
"fmt"
"io"
"log/slog"
2026-10-09 18:04:05 -04:00
"net/url"
2026-10-08 15:16:51 -04:00
"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"
2026-10-09 18:04:05 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
2026-10-08 15:16:51 -04:00
"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"
2026-10-09 18:04:05 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
2026-10-08 15:16:51 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
2026-10-09 18:04:05 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
2026-10-08 15:16:51 -04:00
"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
2026-10-09 18:04:05 -04:00
// 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
2026-10-08 15:16:51 -04:00
}
// 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 )
}
2026-10-09 18:04:05 -04:00
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 )
2026-10-08 15:16:51 -04:00
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 )
}
2026-10-10 19:30:23 -04:00
dsvc , err := dinners . NewService ( e . st , grounder , runner , func () {}, logger )
2026-10-08 15:16:51 -04:00
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 )
2026-10-11 15:57:29 -04:00
if _ , err := e . st . Writer . Exec ( `UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)` ); err != nil {
2026-10-08 15:16:51 -04:00
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
2026-10-11 15:57:29 -04:00
if _ , err := e . st . Writer . Exec ( `UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)` ); err != nil {
2026-10-08 15:16:51 -04:00
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" })
}