2026-10-08 15:16:51 -04:00
package shopping_test
import (
"context"
"database/sql"
"errors"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// zoned makes zone the household's time zone and at the instant the
// service's clock reads.
func ( e * env ) zoned ( t * testing . T , zone , at string ) {
t . Helper ()
if err := household . SeedSettings ( t . Context (), e . st , zone ); err != nil {
t . Fatal ( err )
}
e . at ( t , at )
}
// at sets the service's clock to the RFC 3339 instant at.
func ( e * env ) at ( t * testing . T , at string ) {
t . Helper ()
now , err := time . Parse ( time . RFC3339 , at )
if err != nil {
t . Fatal ( err )
}
shopping . SetClock ( e . svc , func () time . Time { return now })
}
// frozenState returns the week's list's status and frozen_at; frozen_at is
// "" while NULL.
func ( e * env ) frozenState ( t * testing . T , monday store . LocalDate ) ( string , string ) {
t . Helper ()
var status string
var frozenAt sql . NullString
if err := e . st . Reader . QueryRow ( `SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?` , monday . String ()). Scan ( & status , & frozenAt ); err != nil {
t . Fatal ( err )
}
return status , frozenAt . String
}
// weekList plans the dinners named names on the days of the week starting
// monday, in order, and generates its Supply run.
func ( e * env ) weekList ( t * testing . T , monday string , names ... string ) shopping . List {
t . Helper ()
start := mustDate ( monday )
days , err := household . WeekDays ( start )
if err != nil {
t . Fatal ( err )
}
q := db . New ( e . st . Writer )
now := store . Now (). String ()
planID := store . NewID ()
if err := q . CreateWeekPlan ( t . Context (), db . CreateWeekPlanParams { ID : planID , WeekStart : monday , 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 : planID , Date : days [ i ]. String (),
DinnerID : sql . NullString { String : e . ids [ name ], Valid : true }, CreatedAt : now , UpdatedAt : now ,
}); err != nil {
t . Fatal ( err )
}
}
g , err := e . svc . Generate ( t . Context (), start )
if err != nil {
t . Fatal ( err )
}
return g . List
}
// endedWeek is the env with the tests' week generated, in New York, on
// the Monday after it, so the week has ended.
func endedWeek ( t * testing . T ) ( * env , shopping . List ) {
t . Helper ()
e := newEnv ( t )
e . zoned ( t , "America/New_York" , "2026-10-19T12:00:00Z" )
e . dinner ( t , "Beef Chili" , ing { "ground beef" , "1" , "lb" }, ing { "onion" , "1" , "" })
e . plan ( t , "Beef Chili" )
return e , e . generate ( t ). List
}
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
// read back read-only, and freezing it again changes nothing.
func TestFreeze ( t * testing . T ) {
e , l := endedWeek ( t )
if err := e . svc . Freeze ( t . Context (), week ); err != nil {
t . Fatal ( err )
}
status , frozenAt := e . frozenState ( t , week )
if status != shopping . StatusArchived || frozenAt == "" {
t . Fatalf ( "after Freeze: status %q, frozen_at %q; want archived and set" , status , frozenAt )
}
got , err := e . svc . Get ( t . Context (), week )
if err != nil {
t . Fatal ( err )
}
if got . Status != shopping . StatusArchived {
t . Errorf ( "read back status = %q, want archived" , got . Status )
}
wantLines ( t , "read back" , lines ( got ), [] string {
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed" ,
"0 2026-10-12 Beef Chili: 1 onion [1] needed" ,
})
time . Sleep ( 2 * time . Millisecond ) // a second freeze would stamp a later frozen_at
if err := e . svc . Freeze ( t . Context (), week ); err != nil {
t . Fatalf ( "second Freeze = %v, want no error" , err )
}
if s , again := e . frozenState ( t , week ); s != shopping . StatusArchived || again != frozenAt {
t . Errorf ( "after second Freeze: status %q, frozen_at %q; want archived, %q kept" , s , again , frozenAt )
}
if _ , err := e . svc . SetNeeded ( t . Context (), l . Groups [ 0 ]. Items [ 1 ]. ID , false ); ! errors . Is ( err , shopping . ErrFrozen ) {
t . Errorf ( "SetNeeded after Freeze = %v, want ErrFrozen" , err )
}
if _ , err := e . svc . Generate ( t . Context (), week ); ! errors . Is ( err , shopping . ErrFrozen ) {
t . Errorf ( "Generate after Freeze = %v, want ErrFrozen" , err )
}
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" ,
})
}
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
// it is.
func TestFreezeWeekNotEnded ( t * testing . T ) {
e := newEnv ( t )
e . zoned ( t , "America/Los_Angeles" , "2026-10-19T06:30:00Z" ) // Sun 18 Oct 23:30 PDT
e . dinner ( t , "Beef Chili" , ing { "ground beef" , "1" , "lb" })
e . plan ( t , "Beef Chili" )
e . generate ( t )
if err := e . svc . Freeze ( t . Context (), week ); ! errors . Is ( err , shopping . ErrWeekNotEnded ) {
t . Fatalf ( "Freeze on the Sunday = %v, want ErrWeekNotEnded" , err )
}
if status , frozenAt := e . frozenState ( t , week ); status != shopping . StatusCurrent || frozenAt != "" {
t . Fatalf ( "after refused Freeze: status %q, frozen_at %q; want current, unset" , status , frozenAt )
}
if err := e . svc . Freeze ( t . Context (), mustDate ( "2026-10-19" )); ! errors . Is ( err , shopping . ErrWeekNotEnded ) {
t . Errorf ( "Freeze of a later week = %v, want ErrWeekNotEnded" , err )
}
e . at ( t , "2026-10-19T07:05:00Z" ) // Mon 19 Oct 00:05 PDT
if err := e . svc . Freeze ( t . Context (), week ); err != nil {
t . Fatalf ( "Freeze on the Monday = %v" , err )
}
if status , _ := e . frozenState ( t , week ); status != shopping . StatusArchived {
t . Errorf ( "status = %q, want archived" , status )
}
}
func TestFreezeWeekWithoutList ( t * testing . T ) {
e := newEnv ( t )
e . zoned ( t , "America/New_York" , "2026-10-19T12:00:00Z" )
if err := e . svc . Freeze ( t . Context (), week ); ! errors . Is ( err , shopping . ErrListNotFound ) {
t . Fatalf ( "Freeze = %v, want ErrListNotFound" , err )
}
if err := e . svc . Freeze ( t . Context (), mustDate ( "2026-10-13" )); err == nil {
t . Fatal ( "Freeze of a Tuesday succeeded" )
}
}
// TestFreezeTitlesUntitledList: a list still untitled when its week is
// frozen is titled by archive_title first, from the plan's dinners, and
// frozen with that title. Its title job, still queued, runs after the
// freeze, finds the title and succeeds without asking the model again.
func TestFreezeTitlesUntitledList ( t * testing . T ) {
e := newEnv ( t )
e . zoned ( t , "America/New_York" , "2026-10-19T12:00:00Z" )
e . dinner ( t , "Beef Chili" , ing { "ground beef" , "1" , "lb" })
e . dinner ( t , "Fish Tacos" )
e . plan ( t , "Fish Tacos" , "Beef Chili" )
g := e . generate ( t ) // the runner is not started: its title job waits
e . model . QueueGenerate ( titled ( "Tacos then Chili" ))
if err := e . svc . Freeze ( t . Context (), week ); err != nil {
t . Fatal ( err )
}
wantTitle ( t , "stored title" , e . storedTitle ( t ), "Tacos then Chili" )
if status , frozenAt := e . frozenState ( t , week ); status != shopping . StatusArchived || frozenAt == "" {
t . Errorf ( "status %q, frozen_at %q; want archived and set" , status , frozenAt )
}
reqs := e . model . Requests ()
if len ( reqs ) != 1 {
t . Fatalf ( "%d model calls, want 1" , len ( reqs ))
}
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili" ; reqs [ 0 ]. Messages [ 0 ]. Content != want {
t . Errorf ( "user message = %q, want %q" , reqs [ 0 ]. Messages [ 0 ]. Content , want )
}
e . start ( t )
wantJob ( t , e . waitTitle ( t , g ), jobs . StatusSucceeded , "" )
if n := len ( e . model . Requests ()); n != 1 {
t . Errorf ( "%d model calls, want 1: the job finds the list titled" , n )
}
wantTitle ( t , "stored title after the job" , e . storedTitle ( t ), "Tacos then Chili" )
}
// pauseTitle makes the env's first archive_title run pause once it has
// returned, before its title is stored, until the returned release is
// called or the test ends; paused is closed as it pauses.
func ( e * env ) pauseTitle ( t * testing . T ) ( paused <- chan struct {}, release func ()) {
t . Helper ()
p , r := make ( chan struct {}), make ( chan struct {})
var first atomic . Bool
e . afterTitle = func () {
if first . CompareAndSwap ( false , true ) {
close ( p )
select {
case <- r :
case <- t . Context (). Done ():
}
}
}
var released sync . Once
return p , func () { released . Do ( func () { close ( r ) }) }
}
// waitClosed fails t unless c is closed within 10 seconds.
func waitClosed ( t * testing . T , what string , c <- chan struct {}) {
t . Helper ()
select {
case <- c :
case <- time . After ( 10 * time . Second ):
t . Fatalf ( "timed out waiting for %s" , what )
}
}
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
// archive_title run has returned but its title is not yet stored waits for
// the job to store it, freezes the list with that title, and asks the
// model nothing itself.
func TestFreezeWaitsForTitleJob ( t * testing . T ) {
e := newEnv ( t )
e . zoned ( t , "America/New_York" , "2026-10-19T12:00:00Z" )
e . dinner ( t , "Beef Chili" , ing { "ground beef" , "1" , "lb" })
e . plan ( t , "Beef Chili" )
e . model . QueueGenerate ( titled ( "Chili Night" ))
paused , release := e . pauseTitle ( t )
e . start ( t )
g := e . generate ( t )
waitClosed ( t , "the title job's archive_title run" , paused )
froze := make ( chan error , 1 )
go func () { froze <- e . svc . Freeze ( context . Background (), week ) }()
var err error
select {
case err = <- froze :
t . Errorf ( "Freeze = %v before the title job stored its title, want it to wait" , err )
release ()
case <- time . After ( time . Second ):
release ()
err = <- froze
}
if err != nil {
t . Fatal ( err )
}
wantJob ( t , e . waitTitle ( t , g ), jobs . StatusSucceeded , "" )
wantTitle ( t , "stored title" , e . storedTitle ( t ), "Chili Night" )
if status , _ := e . frozenState ( t , week ); status != shopping . StatusArchived {
t . Errorf ( "status = %q, want archived" , status )
}
if n := len ( e . model . Requests ()); n != 1 {
t . Errorf ( "%d model calls, want 1: the freeze waits for the job's title" , n )
}
}
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
// archive_title run has returned but its title is not yet stored waits for
// the freeze, then finds the list titled and asks the model nothing.
func TestTitleJobWaitsForFreeze ( t * testing . T ) {
e := newEnv ( t )
e . zoned ( t , "America/New_York" , "2026-10-19T12:00:00Z" )
e . dinner ( t , "Beef Chili" , ing { "ground beef" , "1" , "lb" })
e . plan ( t , "Beef Chili" )
e . model . QueueGenerate ( titled ( "Chili Night" ))
g := e . generate ( t ) // the runner is not started: its title job waits
paused , release := e . pauseTitle ( t )
froze := make ( chan error , 1 )
go func () { froze <- e . svc . Freeze ( context . Background (), week ) }()
waitClosed ( t , "the freeze's archive_title run" , paused )
e . start ( t )
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 . StatusRunning {
break
}
if job . Status != jobs . StatusQueued || time . Now (). After ( deadline ) {
t . Fatalf ( "title job %s, want it running while the freeze is paused" , job . Status )
}
time . Sleep ( 5 * time . Millisecond )
}
release ()
if err := <- froze ; err != nil {
t . Fatal ( err )
}
wantJob ( t , e . waitTitle ( t , g ), jobs . StatusSucceeded , "" )
wantTitle ( t , "stored title" , e . storedTitle ( t ), "Chili Night" )
if status , _ := e . frozenState ( t , week ); status != shopping . StatusArchived {
t . Errorf ( "status = %q, want archived" , status )
}
if n := len ( e . model . Requests ()); n != 1 {
t . Errorf ( "%d model calls, want 1: the job waits for the freeze's title" , n )
}
}
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
// list, it is frozen untitled all the same, the failure is logged, and the
// Supply archive shows its week label for good.
func TestFreezeUntitledWhenTitleFails ( t * testing . T ) {
e , _ := endedWeek ( t ) // nothing queued on the model: archive_title fails
if err := e . svc . Freeze ( t . Context (), week ); err != nil {
t . Fatal ( err )
}
wantUntitled ( t , "stored title" , e . storedTitle ( t ))
if status , _ := e . frozenState ( t , week ); status != shopping . StatusArchived {
t . Errorf ( "status = %q, want archived" , status )
}
if ! strings . Contains ( e . log . String (), "Supply run frozen untitled" ) {
t . Errorf ( "log lacks the failure:\n%s" , e . log . String ())
}
periods , err := e . svc . Archive ( t . Context (), shopping . ByWeek )
if err != nil {
t . Fatal ( err )
}
if len ( periods ) != 1 || len ( periods [ 0 ]. Posts ) != 1 || periods [ 0 ]. Posts [ 0 ]. Title != nil ||
periods [ 0 ]. Posts [ 0 ]. Label != "Week 42 · Oct 12 – 18" {
t . Errorf ( "archive = %+v, want the untitled week under its label" , periods )
}
}
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
// model is not asked again.
func TestFreezeKeepsTitle ( t * testing . T ) {
e , _ := endedWeek ( t )
if _ , err := e . st . Writer . Exec ( `UPDATE shopping_lists SET title = 'Chili Night'` ); err != nil {
t . Fatal ( err )
}
if err := e . svc . Freeze ( t . Context (), week ); err != nil {
t . Fatal ( err )
}
wantTitle ( t , "stored title" , e . storedTitle ( t ), "Chili Night" )
if n := len ( e . model . Requests ()); n != 0 {
t . Errorf ( "%d model calls, want none" , n )
}
}
// postLine renders p as "weekStart label | title | dinners | items/on hand
// | current".
func postLine ( p shopping . Post ) string {
title := "-"
if p . Title != nil {
title = * p . Title
}
cur := ""
if p . Current {
cur = " | current"
}
2026-10-11 17:29:09 -04:00
if ! p . Frozen {
cur += " | open"
}
2026-10-08 15:16:51 -04:00
return p . WeekStart + " " + p . Label + " | " + title + " | " + strings . Join ( p . Dinners , ", " ) + " | " +
strconv . Itoa ( p . Items ) + " items, " + strconv . Itoa ( p . OnHand ) + " on hand" + cur
}
// periodLines renders periods one line per heading and post.
func periodLines ( periods [] shopping . Period ) [] string {
var out [] string
for _ , p := range periods {
out = append ( out , "# " + p . Key + " " + p . Heading )
for _ , post := range p . Posts {
out = append ( out , postLine ( post ))
}
}
return out
}
// TestArchiveGroups reads the Supply archive across a month and a year
// boundary in Auckland, where it is already Monday 4 January 2027 while
// UTC still reads Sunday 3 January: that week is current, the next one is
// not yet in the archive, and each week falls in the month and year of
// its Monday.
func TestArchiveGroups ( t * testing . T ) {
e := newEnv ( t )
e . zoned ( t , "Pacific/Auckland" , "2027-01-03T12:00:00Z" ) // Mon 4 Jan 2027 01:00 NZDT
e . dinner ( t , "Beef Chili" , ing { "ground beef" , "1" , "lb" }, ing { "onion" , "1" , "" }, ing { "kidney beans" , "2" , "cans" })
e . dinner ( t , "Pad Thai" , ing { "rice noodles" , "8" , "oz" }, ing { "lime" , "2" , "" })
e . dinner ( t , "Fish Tacos" ) // no recipe: no group, not counted
e . dinner ( t , "Pot Roast" , ing { "carrots" , "4" , "" })
nov := e . weekList ( t , "2026-11-30" , "Pot Roast" ) // Nov 30 – Dec 6
e . weekList ( t , "2026-12-07" , "Pad Thai" , "Fish Tacos" )
dec := e . weekList ( t , "2026-12-28" , "Beef Chili" , "Fish Tacos" , "Pad Thai" ) // ISO 2026-W53, into January
jan := e . weekList ( t , "2027-01-04" , "Pad Thai" , "Beef Chili" )
e . weekList ( t , "2027-01-11" , "Pot Roast" ) // next week: not in the archive yet
e . haveIt ( t , dec , "Beef Chili" , "onion" )
e . haveIt ( t , dec , "Pad Thai" , "lime" )
e . haveIt ( t , jan , "Pad Thai" , "lime" )
e . haveIt ( t , nov , "Pot Roast" , "carrots" )
if _ , err := e . st . Writer . Exec ( `UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'` ); err != nil {
t . Fatal ( err )
}
2026-10-11 15:57:29 -04:00
if _ , err := e . st . Writer . Exec ( `UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'; 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 )
}
posts := map [ string ] string {
2026-10-11 17:29:09 -04:00
"jan" : "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current | open" ,
2026-10-08 15:16:51 -04:00
"dec" : "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand" ,
"d07" : "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand" ,
"nov" : "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand" ,
}
for _ , tc := range [] struct {
by string
want [] string
}{
{ shopping . ByWeek , [] string {
"# 2027-W01 Week 1 · Jan 4 – 10" , posts [ "jan" ],
"# 2026-W53 Week 53 · Dec 28 – Jan 3" , posts [ "dec" ],
"# 2026-W50 Week 50 · Dec 7 – 13" , posts [ "d07" ],
"# 2026-W49 Week 49 · Nov 30 – Dec 6" , posts [ "nov" ],
}},
{ shopping . ByMonth , [] string {
"# 2027-01 January 2027" , posts [ "jan" ],
"# 2026-12 December 2026" , posts [ "dec" ], posts [ "d07" ],
"# 2026-11 November 2026" , posts [ "nov" ],
}},
{ shopping . ByYear , [] string {
"# 2027 2027" , posts [ "jan" ],
"# 2026 2026" , posts [ "dec" ], posts [ "d07" ], posts [ "nov" ],
}},
} {
got , err := e . svc . Archive ( t . Context (), tc . by )
if err != nil {
t . Fatalf ( "Archive(%s): %v" , tc . by , err )
}
if lines := periodLines ( got ); ! reflect . DeepEqual ( lines , tc . want ) {
t . Errorf ( "Archive(%s) =\n %s\nwant\n %s" , tc . by , strings . Join ( lines , "\n " ), strings . Join ( tc . want , "\n " ))
}
}
}
func TestArchiveEmptyAndInvalidPeriod ( t * testing . T ) {
e := newEnv ( t )
e . zoned ( t , "America/New_York" , "2026-10-19T12:00:00Z" )
got , err := e . svc . Archive ( t . Context (), shopping . ByMonth )
if err != nil || got == nil || len ( got ) != 0 {
t . Errorf ( "Archive of no Supply runs = %#v, %v; want empty" , got , err )
}
for _ , by := range [] string { "" , "day" , "Week" , "months" } {
if _ , err := e . svc . Archive ( t . Context (), by ); ! errors . Is ( err , shopping . ErrInvalidPeriod ) {
t . Errorf ( "Archive(%q) = %v, want ErrInvalidPeriod" , by , err )
}
}
}