2026-10-07 05:53:22 -04:00
package suggest_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
2026-10-08 03:13:21 -04:00
"net/url"
2026-10-07 05:53:22 -04:00
"slices"
"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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
2026-10-08 03:13:21 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
2026-10-07 05:53:22 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
2026-10-07 16:53:23 -04:00
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
2026-10-07 05:53:22 -04:00
"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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const member = "member-1"
type env struct {
svc * suggest . Service
st * store . Store
runner * jobs . Runner
search * recipestest . Search
pages * recipestest . Pages
images * recipestest . Images
// model answers normalize_recipe while grounding; side answers
// suggest_side_quests.
model * llm . Fake
side * llm . Fake
// regulars are the Regulars' ids by name.
regulars map [ string ] string
grounder * recipes . Grounder
// logs collects the service's and job runner's log output.
logs * logBuffer
mu sync . Mutex
inputs [] skills . SideQuestsRequest
// stub, when set, answers in place of suggest_side_quests.
stub func ( in skills . SideQuestsRequest ) skills . SideQuests
}
// newEnv returns a suggestions service over a migrated store with the
// Regulars Beef Chili and Chicken Fajitas and an unstarted job runner. It
// suggests through the real harness and suggest_side_quests with the
// scripted e.side, and grounds with fake search, pages and images and the
// scripted e.model.
func newEnv ( t * testing . T ) * env {
t . Helper ()
ctx := t . Context ()
dataDir := t . TempDir ()
st , err := store . Open ( ctx , dataDir )
if err != nil {
t . Fatal ( err )
}
t . Cleanup ( func () {
if err := st . Close (); err != nil {
t . Error ( err )
}
})
if err := store . Migrate ( ctx , st . Writer , migrations . FS ); err != nil {
t . Fatal ( err )
}
if err := household . SeedSettings ( ctx , st , "America/New_York" ); err != nil {
t . Fatal ( err )
}
e := & env {
st : st ,
search : & recipestest . Search { Results : map [ string ][] recipes . Candidate {}},
pages : & recipestest . Pages { Bodies : map [ string ][] byte {}},
images : & recipestest . Images { JPEGs : map [ string ][] byte {}},
model : & llm . Fake {},
side : & llm . Fake {},
regulars : map [ string ] string {},
logs : & logBuffer {},
}
for _ , name := range [] string { "Beef Chili" , "Chicken Fajitas" } {
e . regulars [ name ] = e . dinner ( t , name , "regular" )
}
g := recipestest . NewGrounder ( t , st , dataDir , e . search , e . pages , e . images , e . model )
e . grounder = g
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 e . side , nil },
Store : st ,
Guard : guard ,
}
suggester := func ( ctx context . Context , scope string , in skills . SideQuestsRequest ) ( skills . SideQuests , error ) {
e . mu . Lock ()
e . inputs = append ( e . inputs , in )
stub := e . stub
e . mu . Unlock ()
if stub != nil {
return stub ( in ), nil
}
return skills . SuggestSideQuests ( ctx , cfg , scope , in )
}
logger := slog . New ( slog . NewTextHandler ( e . logs , nil ))
e . runner , err = jobs . NewRunner ( st , logger , 1 )
if err != nil {
t . Fatal ( err )
}
2026-10-09 18:04:05 -04:00
e . svc , err = suggest . NewService ( st , g , suggester , e . runner , notifytest . New ( t , st ), logger )
2026-10-07 05:53:22 -04:00
if err != nil {
t . Fatal ( err )
}
return e
}
// logBuffer collects log output written by concurrent goroutines.
type logBuffer struct {
mu sync . Mutex
buf strings . Builder
}
func ( b * logBuffer ) Write ( p [] byte ) ( int , error ) {
b . mu . Lock ()
defer b . mu . Unlock ()
return b . buf . Write ( p )
}
func ( b * logBuffer ) String () string {
b . mu . Lock ()
defer b . mu . Unlock ()
return b . buf . String ()
}
// 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 ()
})
}
// dinner stores a dinner named name with status and returns its id.
func ( e * env ) dinner ( t * testing . T , name , status string ) string {
t . Helper ()
now := store . Now (). String ()
origin := "regular"
if status == "suggested" {
origin = "side_quest"
}
d , err := db . New ( e . st . Writer ). CreateDinner ( t . Context (), db . CreateDinnerParams {
ID : store . NewID (),
Name : name ,
NormalizedName : harness . Normalize ( name ),
Status : status ,
Origin : origin ,
GroundingStatus : "manual" ,
CreatedAt : now ,
UpdatedAt : now ,
})
if err != nil {
t . Fatal ( err )
}
return d . ID
}
// seedDeck stores a finished batch holding pending suggestions of
// dinners named names, in order, and returns their ids.
func ( e * env ) seedDeck ( t * testing . T , names ... string ) [] string {
t . Helper ()
q := db . New ( e . st . Writer )
now := store . Now (). String ()
b , err := q . CreateSuggestionBatch ( t . Context (), db . CreateSuggestionBatchParams { ID : store . NewID (), Trigger : "manual" , CreatedAt : now , UpdatedAt : now })
if err != nil {
t . Fatal ( err )
}
if _ , err := q . FinishSuggestionBatch ( t . Context (), db . FinishSuggestionBatchParams { Status : "done" , UpdatedAt : now , ID : b . ID }); err != nil {
t . Fatal ( err )
}
var ids [] string
for _ , name := range names {
id := store . NewID ()
if err := q . CreateSuggestion ( t . Context (), db . CreateSuggestionParams {
ID : id ,
DinnerID : e . dinner ( t , name , "suggested" ),
BatchID : b . ID ,
Name : name ,
Reason : "Like your Beef Chili." ,
CitedRegularIds : fmt . Sprintf ( "[%q]" , e . regulars [ "Beef Chili" ]),
CreatedAt : store . Now (). String (),
UpdatedAt : now ,
}); err != nil {
t . Fatal ( err )
}
ids = append ( ids , id )
}
return ids
}
// recipe makes the search for name find one page that grounds it, and
// scripts the normalize_recipe reply grounding it takes.
func ( e * env ) recipe ( t * testing . T , name string ) {
t . Helper ()
slug := strings . ReplaceAll ( strings . ToLower ( name ), " " , "-" )
page , photo := "https://r.example/" + slug , "https://r.example/" + slug + ".jpg"
e . search . Results [ name ] = [] recipes . Candidate {{ URL : page }}
e . pages . Bodies [ page ] = recipestest . RecipePage ( name , "R Kitchen" , photo , 35 )
e . images . JPEGs [ photo ] = recipestest . JPEG ( t , 400 , 320 , uint8 ( len ( e . images . JPEGs ) + 1 ))
e . model . QueueGenerate ( recipestest . NormalizedReply ( "stovetop" , 35 ))
}
// sideQuest renders one Side quest of a suggest_side_quests reply whose
// search query is its name.
func sideQuest ( name , reason string , cited ... string ) string {
c , _ := json . Marshal ( cited )
r , _ := json . Marshal ( reason )
return fmt . Sprintf ( `{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}` ,
name , r , c , name )
}
// reply scripts one suggest_side_quests reply holding quests.
func ( e * env ) reply ( quests ... string ) {
js := `{"suggestions":[` + strings . Join ( quests , "," ) + `]}`
e . side . QueueGenerate ( llm . GenerateResult { Response : llm . Response { Text : js , JSON : json . RawMessage ( js )}})
}
// waitJob waits for job id to finish and returns it.
func ( e * env ) waitJob ( t * testing . T , id string ) jobs . Job {
t . Helper ()
deadline := time . Now (). Add ( 10 * time . Second )
for {
job , err := e . runner . Get ( t . Context (), id )
if err != nil {
t . Fatal ( err )
}
if job . Status == jobs . StatusSucceeded || job . Status == jobs . StatusFailed {
return job
}
if time . Now (). After ( deadline ) {
t . Fatalf ( "job %s still %s" , id , job . Status )
}
time . Sleep ( 5 * time . Millisecond )
}
}
type batchResult struct {
BatchID string `json:"batch_id"`
Stage string `json:"stage"`
SuggestionIDs [] string `json:"suggestion_ids"`
Dropped int `json:"dropped"`
}
// runBatch starts a manual batch, waits for it to succeed and returns its
// result.
func ( e * env ) runBatch ( t * testing . T ) batchResult {
t . Helper ()
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if ! b . Created {
t . Fatalf ( "batch %s was already active" , b . ID )
}
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusSucceeded {
t . Fatalf ( "batch job %s, error %v" , job . Status , * job . Error )
}
var res batchResult
if err := json . Unmarshal ( job . Result , & res ); err != nil {
t . Fatal ( err )
}
if res . BatchID != b . ID || res . Stage != suggest . StageDone {
t . Fatalf ( "batch result = %+v, want batch %s done" , res , b . ID )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchDone {
t . Fatalf ( "batch status = %s, want done" , got )
}
return res
}
2026-10-10 08:53:14 -04:00
// scheduledWeek is the week the tests' scheduled batches are for: the
// Monday of 2026-W41.
var scheduledWeek = mustDate ( "2026-10-05" )
func mustDate ( s string ) store . LocalDate {
d , err := store . ParseLocalDate ( s )
if err != nil {
panic ( err )
}
return d
}
func mustZone ( t * testing . T , name string ) * time . Location {
t . Helper ()
loc , err := household . LoadZone ( name )
if err != nil {
t . Fatal ( err )
}
return loc
}
// startBatch starts a batch with trigger, a scheduled one for
// scheduledWeek, and returns it with the key its new_suggestions has.
func ( e * env ) startBatch ( t * testing . T , trigger string ) ( suggest . Batch , string ) {
t . Helper ()
if trigger == suggest . TriggerScheduled {
b , err := e . svc . StartScheduledBatch ( t . Context (), scheduledWeek )
if err != nil {
t . Fatal ( err )
}
return b , "new_suggestions:2026-W41"
}
b , err := e . svc . StartBatch ( t . Context (), trigger )
if err != nil {
t . Fatal ( err )
}
return b , "new_suggestions:" + b . ID
}
2026-10-07 05:53:22 -04:00
func ( e * env ) batchStatus ( t * testing . T , id string ) string {
t . Helper ()
var status string
if err := e . st . Reader . QueryRowContext ( t . Context (), `SELECT status FROM suggestion_batches WHERE id = ?` , id ). Scan ( & status ); err != nil {
t . Fatal ( err )
}
return status
}
func ( e * env ) activeBatches ( t * testing . T ) int {
t . Helper ()
var n int
if err := e . st . Reader . QueryRowContext ( t . Context (), `SELECT COUNT(*) FROM suggestion_batches WHERE status IN ('pending', 'running')` ). Scan ( & n ); err != nil {
t . Fatal ( err )
}
return n
}
func ( e * env ) dinnerStatus ( t * testing . T , id string ) string {
t . Helper ()
row , err := db . New ( e . st . Reader ). GetDinner ( t . Context (), id )
if err != nil {
t . Fatal ( err )
}
return row . Status
}
// deckNames returns the names Deck lists, in order.
func ( e * env ) deckNames ( t * testing . T ) [] string {
t . Helper ()
deck , err := e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
names := [] string {}
for _ , s := range deck . Suggestions {
names = append ( names , s . Name )
}
return names
}
func TestBatchStoresGroundedSideQuests ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . recipe ( t , "Chicken Shawarma Bowls" )
e . recipe ( t , "Chili Verde" )
e . reply (
sideQuest ( "Chicken Shawarma Bowls" , "Spiced chicken, like your Chicken Fajitas." , "Chicken Fajitas" ),
sideQuest ( "Mystery Stew" , "Hearty like your Beef Chili." , "Beef Chili" ), // no recipe grounds it
sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ),
sideQuest ( "Lamb Tagine" , "Slow-cooked like your Pot Roast." , "Pot Roast" ), // cites no Regular
)
res := e . runBatch ( t )
if len ( res . SuggestionIDs ) != 2 || res . Dropped != 2 {
t . Fatalf ( "result = %+v, want 2 suggestions and 2 dropped" , res )
}
if g , _ , _ := e . model . Remaining (); g != 0 {
t . Errorf ( "%d normalize_recipe replies unused" , g )
}
if q := e . search . Queries (); slices . Contains ( q , "Lamb Tagine" ) {
t . Errorf ( "searched %q for a Side quest the skill dropped" , q )
}
deck , err := e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
if len ( deck . Suggestions ) != 2 {
t . Fatalf ( "deck has %d suggestions, want 2" , len ( deck . Suggestions ))
}
want := [] struct { name , reason , regular string }{
{ "Chicken Shawarma Bowls" , "Spiced chicken, like your Chicken Fajitas." , "Chicken Fajitas" },
{ "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" },
}
for i , w := range want {
s := deck . Suggestions [ i ]
if s . ID != res . SuggestionIDs [ i ] || s . Name != w . name || s . Reason != w . reason || s . Status != suggest . StatusPending {
t . Errorf ( "deck[%d] = %+v, want %s pending with reason %q" , i , s , w . name , w . reason )
}
if ! slices . Equal ( s . CitedRegularIDs , [] string { e . regulars [ w . regular ]}) {
t . Errorf ( "deck[%d] cites %v, want %s's id" , i , s . CitedRegularIDs , w . regular )
}
if s . PhotoURL == nil || ! strings . HasPrefix ( * s . PhotoURL , "/media/photos/" ) || s . Difficulty == nil {
t . Errorf ( "deck[%d] photo %v, difficulty %v; want both" , i , s . PhotoURL , s . Difficulty )
}
row , err := db . New ( e . st . Reader ). GetDinner ( t . Context (), s . DinnerID )
if err != nil {
t . Fatal ( err )
}
if row . Status != "suggested" || row . Origin != "side_quest" || row . GroundingStatus != "grounded" || ! row . RecipeID . Valid || row . Cuisine . String != "Fusion" {
t . Errorf ( "dinner %s = %+v, want suggested side_quest grounded in a recipe" , w . name , row )
}
}
var dinners int
if err := e . st . Reader . QueryRowContext ( t . Context (), `SELECT COUNT(*) FROM dinners WHERE name IN ('Mystery Stew', 'Lamb Tagine')` ). Scan ( & dinners ); err != nil {
t . Fatal ( err )
}
if dinners != 0 {
t . Errorf ( "%d dropped Side quests stored as dinners, want none" , dinners )
}
}
2026-10-09 18:04:05 -04:00
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
2026-10-10 08:53:14 -04:00
// quests stores new_suggestions counting and naming them, keyed by the
// scheduled ISO week or the manual batch, for that week or the current
// one; a replenish batch, or one that adds none, stores nothing.
2026-10-09 18:04:05 -04:00
func TestBatchEmitsNewSuggestions ( t * testing . T ) {
for _ , tc := range [] struct {
trigger string
grounds bool
notify bool
}{
{ suggest . TriggerScheduled , true , true },
{ suggest . TriggerManual , true , true },
{ suggest . TriggerReplenish , true , false },
{ suggest . TriggerManual , false , false },
} {
t . Run ( fmt . Sprintf ( "%s grounds %v" , tc . trigger , tc . grounds ), func ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
if tc . grounds {
e . recipe ( t , "Chicken Shawarma Bowls" )
e . recipe ( t , "Chili Verde" )
}
e . reply (
sideQuest ( "Chicken Shawarma Bowls" , "Spiced chicken, like your Chicken Fajitas." , "Chicken Fajitas" ),
sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ),
)
2026-10-10 08:53:14 -04:00
b , key := e . startBatch ( t , tc . trigger )
2026-10-09 18:04:05 -04:00
if job := e . waitJob ( t , b . JobID ); job . Status != jobs . StatusSucceeded {
t . Fatalf ( "batch job %s" , job . Status )
}
rows := notifytest . Rows ( t , e . st , notify . NewSuggestions )
if ! tc . notify {
if len ( rows ) != 0 {
t . Errorf ( "new_suggestions rows = %+v, want none" , rows )
}
return
}
if len ( rows ) != 1 {
t . Fatalf ( "new_suggestions rows = %+v, want one on push" , rows )
}
r := rows [ 0 ]
2026-10-10 08:53:14 -04:00
week := household . WeekStart ( store . LocalDateOf ( time . Now (). In ( mustZone ( t , "America/New_York" )))). String ()
if tc . trigger == suggest . TriggerScheduled {
week = scheduledWeek . String ()
}
if r . Key != key || r . Channel != notify . ChannelPush || r . Payload . URL != "/discover" ||
r . Payload . Title != "2 new Side quests are in" || r . Payload . Body != "Chicken Shawarma Bowls, Chili Verde" || r . Payload . Week != week {
t . Errorf ( "new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover" , r , key , week )
2026-10-09 18:04:05 -04:00
}
})
}
}
2026-10-10 08:53:14 -04:00
// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
// quests, Fajita Dish A to F, whose first round grounds all but B, so a
// second round grounds F. Unless lastGrounds, normalize_recipe fails for
// F.
func ( e * env ) twoRoundBatch ( t * testing . T , lastGrounds bool ) {
t . Helper ()
var quests [] string
for i := range suggest . BatchSize + 1 {
name := fmt . Sprintf ( "Fajita Dish %c" , 'A' + i )
e . search . Results [ name ] = nil
switch {
case i == suggest . BatchSize && ! lastGrounds :
// A page is found, but no normalize_recipe reply is scripted
// for it, so the AI call grounding it fails.
slug := strings . ReplaceAll ( strings . ToLower ( name ), " " , "-" )
page , photo := "https://r.example/" + slug , "https://r.example/" + slug + ".jpg"
e . search . Results [ name ] = [] recipes . Candidate {{ URL : page }}
e . pages . Bodies [ page ] = recipestest . RecipePage ( name , "R Kitchen" , photo , 35 )
e . images . JPEGs [ photo ] = recipestest . JPEG ( t , 400 , 320 , uint8 ( len ( e . images . JPEGs ) + 1 ))
case i != 1 :
e . recipe ( t , name )
}
quests = append ( quests , sideQuest ( name , "Like your Chicken Fajitas." , "Chicken Fajitas" ))
}
e . reply ( quests ... )
}
// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across
// two grounding rounds stores new_suggestions once, when it completes,
// counting and naming every Side quest it stored, not just the first
// round's; a replenish batch notifies nothing.
func TestBatchNotifiesOnceWithEverySideQuestStored ( t * testing . T ) {
2026-10-09 18:04:05 -04:00
for _ , tc := range [] struct {
2026-10-10 08:53:14 -04:00
trigger string
notify bool
2026-10-09 18:04:05 -04:00
}{
2026-10-10 08:53:14 -04:00
{ suggest . TriggerScheduled , true },
{ suggest . TriggerManual , true },
{ suggest . TriggerReplenish , false },
2026-10-09 18:04:05 -04:00
} {
2026-10-10 08:53:14 -04:00
t . Run ( tc . trigger , func ( t * testing . T ) {
2026-10-09 18:04:05 -04:00
e := newEnv ( t )
e . start ( t )
2026-10-10 08:53:14 -04:00
e . twoRoundBatch ( t , true )
2026-10-09 18:04:05 -04:00
2026-10-10 08:53:14 -04:00
b , key := e . startBatch ( t , tc . trigger )
if job := e . waitJob ( t , b . JobID ); job . Status != jobs . StatusSucceeded {
t . Fatalf ( "batch job %s, want succeeded" , job . Status )
2026-10-09 18:04:05 -04:00
}
2026-10-10 08:53:14 -04:00
deck := e . deckNames ( t )
if want := [] string { "Fajita Dish A" , "Fajita Dish C" , "Fajita Dish D" , "Fajita Dish E" , "Fajita Dish F" }; ! slices . Equal ( deck , want ) {
t . Fatalf ( "deck = %v, want %v" , deck , want )
2026-10-09 18:04:05 -04:00
}
rows := notifytest . Rows ( t , e . st , notify . NewSuggestions )
if ! tc . notify {
if len ( rows ) != 0 {
t . Errorf ( "new_suggestions rows = %+v, want none" , rows )
}
return
}
2026-10-10 08:53:14 -04:00
want := fmt . Sprintf ( "%d new Side quests are in" , len ( deck ))
if len ( rows ) != 1 || rows [ 0 ]. Key != key || rows [ 0 ]. Payload . Title != want ||
rows [ 0 ]. Payload . Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" {
t . Errorf ( "new_suggestions rows = %+v, want one keyed %s titled %q" , rows , key , want )
}
})
}
}
// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails
// in a batch's second grounding round, a scheduled or manual batch stores
// none of the first round's Side quests and sends no new_suggestions, so
// no Side quest is left in the deck unannounced; a replenish batch, which
// never notifies, keeps the first round's in the deck.
func TestBatchFailingInALaterRoundStoresNothing ( t * testing . T ) {
for _ , tc := range [] struct {
trigger string
deck int
}{
{ suggest . TriggerScheduled , 0 },
{ suggest . TriggerManual , 0 },
{ suggest . TriggerReplenish , suggest . BatchSize - 1 },
} {
t . Run ( tc . trigger , func ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . twoRoundBatch ( t , false )
b , _ := e . startBatch ( t , tc . trigger )
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusFailed || job . Error == nil || * job . Error != suggest . ReasonAIFailed {
t . Fatalf ( "batch job %s, error %+v, want failed %s" , job . Status , job . Error , suggest . ReasonAIFailed )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchFailed {
t . Errorf ( "batch status = %s, want failed" , got )
}
if got := e . deckNames ( t ); len ( got ) != tc . deck {
t . Errorf ( "deck = %v, want %d Side quests" , got , tc . deck )
}
if rows := notifytest . Rows ( t , e . st , notify . NewSuggestions ); len ( rows ) != 0 {
t . Errorf ( "new_suggestions rows = %+v, want none" , rows )
2026-10-09 18:04:05 -04:00
}
})
}
}
// TestBatchFailsWhenItsNotificationCannotBeStored: Side quests are stored
// only with their notification, so a notification that cannot be stored
// fails the batch with an empty deck rather than leaving Side quests the
// household is never told of.
func TestBatchFailsWhenItsNotificationCannotBeStored ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . recipe ( t , "Chili Verde" )
e . reply ( sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ))
if _ , err := e . st . Writer . ExecContext ( t . Context (), `CREATE TRIGGER refuse_outbox BEFORE INSERT ON notification_outbox BEGIN SELECT RAISE(ABORT, 'outbox refused'); END` ); err != nil {
t . Fatal ( err )
}
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if job := e . waitJob ( t , b . JobID ); job . Status != jobs . StatusFailed {
t . Fatalf ( "batch job %s, want failed" , job . Status )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchFailed {
t . Errorf ( "batch status = %s, want failed" , got )
}
if got := e . deckNames ( t ); len ( got ) != 0 {
t . Errorf ( "deck = %v with no notification stored, want empty" , got )
}
}
2026-10-07 05:53:22 -04:00
func TestBatchKeepsAtMostBatchSize ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
var quests [] string
for i := range suggest . BatchSize + 2 {
name := fmt . Sprintf ( "Fajita Dish %c" , 'A' + i )
e . search . Results [ name ] = nil // ungroundable unless recipe is called
quests = append ( quests , sideQuest ( name , "Like your Chicken Fajitas." , "Chicken Fajitas" ))
}
// The first round grounds BatchSize Side quests; one fails, so the
// second round grounds one more, and the last is never grounded.
for i := range suggest . BatchSize + 1 {
if i != 1 {
e . recipe ( t , fmt . Sprintf ( "Fajita Dish %c" , 'A' + i ))
}
}
e . reply ( quests ... )
res := e . runBatch ( t )
if len ( res . SuggestionIDs ) != suggest . BatchSize {
t . Fatalf ( "kept %d, want %d" , len ( res . SuggestionIDs ), suggest . BatchSize )
}
if slices . Contains ( e . search . Queries (), fmt . Sprintf ( "Fajita Dish %c" , 'A' + suggest . BatchSize + 1 )) {
t . Error ( "grounded a Side quest after the batch was full" )
}
names := e . deckNames ( t )
if names [ 0 ] != "Fajita Dish A" || names [ 1 ] != "Fajita Dish C" || names [ len ( names ) - 1 ] != fmt . Sprintf ( "Fajita Dish %c" , 'A' + suggest . BatchSize ) {
t . Errorf ( "deck = %v, want the model's order without B" , names )
}
}
func TestDeckIsOldestFirst ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . recipe ( t , "Chicken Shawarma Bowls" )
e . recipe ( t , "Chili Verde" )
e . reply (
sideQuest ( "Chicken Shawarma Bowls" , "Spiced chicken, like your Chicken Fajitas." , "Chicken Fajitas" ),
sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ),
)
e . runBatch ( t )
e . recipe ( t , "Beef Bulgogi" )
e . reply ( sideQuest ( "Beef Bulgogi" , "Sweet beef for Beef Chili fans." , "Beef Chili" ))
e . runBatch ( t )
want := [] string { "Chicken Shawarma Bowls" , "Chili Verde" , "Beef Bulgogi" }
if got := e . deckNames ( t ); ! slices . Equal ( got , want ) {
t . Errorf ( "deck = %v, want %v" , got , want )
}
// The second batch was told the first one's Side quests are pending.
if got := e . inputs [ 1 ]. Pending ; ! slices . Equal ( got , want [: 2 ]) {
t . Errorf ( "second batch pending = %v, want %v" , got , want [: 2 ])
}
}
func TestDeckReportsTheActiveBatch ( t * testing . T ) {
e := newEnv ( t ) // runner not started: the batch stays pending
e . seedDeck ( t , "Pho" )
deck , err := e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
if deck . ActiveBatch != nil {
t . Fatalf ( "active batch = %+v with none pending or running, want nil" , deck . ActiveBatch )
}
if deck . FailedBatch != nil {
t . Fatalf ( "failed batch = %+v with none failed, want nil" , deck . FailedBatch )
}
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
deck , err = e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
if len ( deck . Suggestions ) != 1 {
t . Errorf ( "deck has %d suggestions, want 1" , len ( deck . Suggestions ))
}
want := suggest . Batch { ID : b . ID , JobID : b . JobID , Trigger : suggest . TriggerManual , Status : suggest . BatchPending }
if deck . ActiveBatch == nil || * deck . ActiveBatch != want {
t . Errorf ( "active batch = %+v, want %+v" , deck . ActiveBatch , want )
}
if deck . FailedBatch != nil {
t . Errorf ( "failed batch = %+v with a batch pending, want nil" , deck . FailedBatch )
}
}
func TestDeckReportsTheLatestFailedBatch ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . side . QueueGenerate ( llm . GenerateResult { Err : errors . New ( "provider down" )})
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if job := e . waitJob ( t , b . JobID ); job . Status != jobs . StatusFailed {
t . Fatalf ( "job status = %s, want failed" , job . Status )
}
deck , err := e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
want := suggest . Batch { ID : b . ID , JobID : b . JobID , Trigger : suggest . TriggerManual , Status : suggest . BatchFailed }
if deck . ActiveBatch != nil || deck . FailedBatch == nil || * deck . FailedBatch != want {
t . Fatalf ( "active batch = %+v, failed batch = %+v; want no active batch and failed %+v" , deck . ActiveBatch , deck . FailedBatch , want )
}
// A later batch that succeeds supersedes the failure.
e . recipe ( t , "Chili Verde" )
e . reply ( sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ))
e . runBatch ( t )
deck , err = e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
if deck . ActiveBatch != nil || deck . FailedBatch != nil {
t . Errorf ( "active batch = %+v, failed batch = %+v after a batch succeeded, want neither" , deck . ActiveBatch , deck . FailedBatch )
}
}
func TestDeckFailsBatchInterruptedByRestart ( t * testing . T ) {
e := newEnv ( t )
e . seedDeck ( t , "Pho" )
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
// A previous process claimed the job and started the batch, then
// stopped before recording its end.
now := store . Now (). String ()
if _ , err := db . New ( e . st . Writer ). ClaimNextJob ( t . Context (), sql . NullString { String : now , Valid : true }); err != nil {
t . Fatal ( err )
}
if n , err := db . New ( e . st . Writer ). StartSuggestionBatch ( t . Context (), db . StartSuggestionBatchParams { UpdatedAt : now , ID : b . ID }); err != nil || n != 1 {
t . Fatalf ( "start batch: %d rows, %v" , n , err )
}
e . start ( t ) // fails the running job as interrupted
if job := e . waitJob ( t , b . JobID ); job . Status != jobs . StatusFailed {
t . Fatalf ( "job status = %s, want failed" , job . Status )
}
deck , err := e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
if deck . ActiveBatch != nil {
t . Errorf ( "active batch = %+v after its job failed, want nil" , deck . ActiveBatch )
}
want := suggest . Batch { ID : b . ID , JobID : b . JobID , Trigger : suggest . TriggerManual , Status : suggest . BatchFailed }
if deck . FailedBatch == nil || * deck . FailedBatch != want {
t . Errorf ( "failed batch = %+v after its job was interrupted, want %+v" , deck . FailedBatch , want )
}
if len ( deck . Suggestions ) != 1 {
t . Errorf ( "deck has %d suggestions, want 1" , len ( deck . Suggestions ))
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchFailed {
t . Errorf ( "interrupted batch status = %s, want failed" , got )
}
}
func TestDeckLeavesOutDinnersNoLongerSuggested ( t * testing . T ) {
e := newEnv ( t )
ids := e . seedDeck ( t , "Pho" , "Ramen" )
sug , err := e . svc . Deck ( t . Context ())
if err != nil {
t . Fatal ( err )
}
if _ , err := db . New ( e . st . Writer ). ArchiveDinner ( t . Context (), db . ArchiveDinnerParams { ID : sug . Suggestions [ 0 ]. DinnerID , UpdatedAt : store . Now (). String ()}); err != nil {
t . Fatal ( err )
}
if got := e . deckNames ( t ); ! slices . Equal ( got , [] string { "Ramen" }) {
t . Errorf ( "deck = %v, want [Ramen]" , got )
}
if _ , err := e . svc . Accept ( t . Context (), ids [ 0 ], member ); ! errors . Is ( err , suggest . ErrDinnerNotSuggested ) {
t . Errorf ( "accept a suggestion whose dinner was archived: %v, want ErrDinnerNotSuggested" , err )
}
}
func TestAcceptIsIdempotent ( t * testing . T ) {
e := newEnv ( t )
ids := e . seedDeck ( t , "Pho" , "Ramen" , "Laksa" , "Bibimbap" , "Katsu" , "Tonkatsu" )
first , err := e . svc . Accept ( t . Context (), ids [ 0 ], member )
if err != nil {
t . Fatal ( err )
}
s := first . Suggestion
if s . Status != suggest . StatusAccepted || s . DinnerStatus != "quest_log" || s . DecidedAt == nil {
t . Fatalf ( "accepted = %+v, want accepted with its dinner in the Quest log" , s )
}
row , err := db . New ( e . st . Reader ). GetDinner ( t . Context (), s . DinnerID )
if err != nil {
t . Fatal ( err )
}
if row . Status != "quest_log" || row . UpdatedBy . String != member {
t . Errorf ( "dinner status %s, updated_by %q; want quest_log by %s" , row . Status , row . UpdatedBy . String , member )
}
again , err := e . svc . Accept ( t . Context (), ids [ 0 ], "member-2" )
if err != nil {
t . Fatal ( err )
}
if * again . Suggestion . DecidedAt != * s . DecidedAt || again . ReplenishJobID != nil {
t . Errorf ( "repeat accept = %+v, want the first decision unchanged" , again )
}
if row , _ := db . New ( e . st . Reader ). GetDinner ( t . Context (), s . DinnerID ); row . UpdatedBy . String != member {
t . Errorf ( "repeat accept recorded updated_by %q" , row . UpdatedBy . String )
}
var decided * suggest . DecidedError
if _ , err := e . svc . Decline ( t . Context (), ids [ 0 ], member ); ! errors . As ( err , & decided ) || decided . Status != suggest . StatusAccepted {
t . Fatalf ( "decline an accepted suggestion: %v, want *DecidedError accepted" , err )
}
if got := e . dinnerStatus ( t , s . DinnerID ); got != "quest_log" {
t . Errorf ( "dinner status after refused decline = %s, want quest_log" , got )
}
if got := e . deckNames ( t ); slices . Contains ( got , "Pho" ) {
t . Errorf ( "deck %v still holds the accepted suggestion" , got )
}
}
func TestDeclineIsIdempotent ( t * testing . T ) {
e := newEnv ( t )
ids := e . seedDeck ( t , "Pho" , "Ramen" , "Laksa" , "Bibimbap" , "Katsu" , "Tonkatsu" )
first , err := e . svc . Decline ( t . Context (), ids [ 1 ], member )
if err != nil {
t . Fatal ( err )
}
s := first . Suggestion
if s . Status != suggest . StatusDeclined || s . DinnerStatus != "archived" || s . DecidedAt == nil {
t . Fatalf ( "declined = %+v, want declined with its dinner archived" , s )
}
again , err := e . svc . Decline ( t . Context (), ids [ 1 ], member )
if err != nil {
t . Fatal ( err )
}
if * again . Suggestion . DecidedAt != * s . DecidedAt || again . ReplenishJobID != nil {
t . Errorf ( "repeat decline = %+v, want the first decision unchanged" , again )
}
var decided * suggest . DecidedError
if _ , err := e . svc . Accept ( t . Context (), ids [ 1 ], member ); ! errors . As ( err , & decided ) || decided . Status != suggest . StatusDeclined {
t . Fatalf ( "accept a declined suggestion: %v, want *DecidedError declined" , err )
}
if got := e . dinnerStatus ( t , s . DinnerID ); got != "archived" {
t . Errorf ( "dinner status after refused accept = %s, want archived" , got )
}
2026-10-07 16:53:23 -04:00
names , err := db . New ( e . st . Reader ). ListPassedNames ( t . Context ())
2026-10-07 05:53:22 -04:00
if err != nil {
t . Fatal ( err )
}
if ! slices . Equal ( names , [] string { "Ramen" }) {
t . Errorf ( "declined names = %v, want [Ramen]" , names )
}
}
func TestDecideUnknownSuggestion ( t * testing . T ) {
e := newEnv ( t )
if _ , err := e . svc . Accept ( t . Context (), "nope" , member ); ! errors . Is ( err , suggest . ErrNotFound ) {
t . Errorf ( "accept unknown: %v, want ErrNotFound" , err )
}
if _ , err := e . svc . Decline ( t . Context (), "nope" , member ); ! errors . Is ( err , suggest . ErrNotFound ) {
t . Errorf ( "decline unknown: %v, want ErrNotFound" , err )
}
}
func TestReplenishWhenDeckFallsBelowFive ( t * testing . T ) {
e := newEnv ( t ) // runner not started: a replenish batch stays pending
ids := e . seedDeck ( t , "Pho" , "Ramen" , "Laksa" , "Bibimbap" , "Katsu" , "Tonkatsu" )
d , err := e . svc . Accept ( t . Context (), ids [ 0 ], member ) // 5 left
if err != nil {
t . Fatal ( err )
}
if d . ReplenishJobID != nil || e . activeBatches ( t ) != 0 {
t . Fatalf ( "replenished with 5 left: job %v, %d active batches" , d . ReplenishJobID , e . activeBatches ( t ))
}
d , err = e . svc . Decline ( t . Context (), ids [ 1 ], member ) // 4 left
if err != nil {
t . Fatal ( err )
}
if d . ReplenishJobID == nil {
t . Fatal ( "no replenish batch with 4 left" )
}
job , err := e . runner . Get ( t . Context (), * d . ReplenishJobID )
if err != nil {
t . Fatal ( err )
}
if job . Kind != suggest . KindSuggestionBatch || job . Status != jobs . StatusQueued {
t . Errorf ( "replenish job = %+v, want a queued %s" , job , suggest . KindSuggestionBatch )
}
var trigger string
if err := e . st . Reader . QueryRowContext ( t . Context (), `SELECT trigger FROM suggestion_batches WHERE status = 'pending'` ). Scan ( & trigger ); err != nil {
t . Fatal ( err )
}
if trigger != suggest . TriggerReplenish {
t . Errorf ( "batch trigger = %s, want replenish" , trigger )
}
}
func TestReplenishIsSingleFlight ( t * testing . T ) {
e := newEnv ( t ) // runner not started: the replenish batch stays pending
ids := e . seedDeck ( t , "Pho" , "Ramen" , "Laksa" )
first , err := e . svc . Decline ( t . Context (), ids [ 0 ], member )
if err != nil {
t . Fatal ( err )
}
if first . ReplenishJobID == nil {
t . Fatal ( "no replenish batch with 2 left" )
}
second , err := e . svc . Accept ( t . Context (), ids [ 1 ], member )
if err != nil {
t . Fatal ( err )
}
// The second decision joins the pending batch and reports its job, so a
// client that did not start it can follow it.
if second . ReplenishJobID == nil || * second . ReplenishJobID != * first . ReplenishJobID {
t . Errorf ( "second decision replenish job = %v, want the pending batch's job %s" , second . ReplenishJobID , * first . ReplenishJobID )
}
manual , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if manual . Created || manual . JobID != * first . ReplenishJobID || manual . Trigger != suggest . TriggerReplenish {
t . Errorf ( "manual batch = %+v, want the pending replenish batch with job %s" , manual , * first . ReplenishJobID )
}
if n := e . activeBatches ( t ); n != 1 {
t . Errorf ( "%d active batches, want 1" , n )
}
var jobsQueued int
if err := e . st . Reader . QueryRowContext ( t . Context (), `SELECT COUNT(*) FROM jobs WHERE kind = ?` , suggest . KindSuggestionBatch ). Scan ( & jobsQueued ); err != nil {
t . Fatal ( err )
}
if jobsQueued != 1 {
t . Errorf ( "%d batch jobs, want 1" , jobsQueued )
}
}
func TestStartBatchIsSingleFlightUnderConcurrency ( t * testing . T ) {
e := newEnv ( t )
var wg sync . WaitGroup
batches := make ([] suggest . Batch , 8 )
for i := range batches {
wg . Add ( 1 )
go func () {
defer wg . Done ()
b , err := e . svc . StartBatch ( context . Background (), suggest . TriggerManual )
if err != nil {
t . Error ( err )
}
batches [ i ] = b
}()
}
wg . Wait ()
created := 0
for _ , b := range batches {
if b . Created {
created ++
}
if b . ID != batches [ 0 ]. ID || b . JobID != batches [ 0 ]. JobID || b . JobID == "" {
t . Errorf ( "batch %+v, want %s with job %s" , b , batches [ 0 ]. ID , batches [ 0 ]. JobID )
}
}
if created != 1 {
t . Errorf ( "%d calls created a batch, want 1" , created )
}
}
func TestLaterBatchNeverResuggestsDeclined ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
shawarma := sideQuest ( "Chicken Shawarma Bowls" , "Spiced chicken, like your Chicken Fajitas." , "Chicken Fajitas" )
e . recipe ( t , "Chicken Shawarma Bowls" )
e . reply ( shawarma )
res := e . runBatch ( t )
// Passing it leaves the deck empty, so a replenish batch runs; the
// model suggests the passed dish again, respelled, with a new one.
e . recipe ( t , "Beef Ragu" )
e . reply (
sideQuest ( "chicken shawarma bowls!" , "Spiced chicken, like your Chicken Fajitas." , "Chicken Fajitas" ),
sideQuest ( "Beef Ragu" , "Slow beef like your Beef Chili." , "Beef Chili" ),
)
d , err := e . svc . Decline ( t . Context (), res . SuggestionIDs [ 0 ], member )
if err != nil {
t . Fatal ( err )
}
if d . ReplenishJobID == nil {
t . Fatal ( "passing the last Side quest started no replenish batch" )
}
job := e . waitJob ( t , * d . ReplenishJobID )
if job . Status != jobs . StatusSucceeded {
t . Fatalf ( "replenish job %s: %v" , job . Status , * job . Error )
}
var replenished batchResult
if err := json . Unmarshal ( job . Result , & replenished ); err != nil {
t . Fatal ( err )
}
if len ( replenished . SuggestionIDs ) != 1 || replenished . Dropped != 1 {
t . Errorf ( "replenish result = %+v, want 1 kept and the passed dish dropped" , replenished )
}
if got := e . deckNames ( t ); ! slices . Equal ( got , [] string { "Beef Ragu" }) {
t . Errorf ( "deck = %v, want [Beef Ragu]" , got )
}
e . mu . Lock ()
in := e . inputs [ 1 ]
e . mu . Unlock ()
if ! slices . Equal ( in . Declined , [] string { "Chicken Shawarma Bowls" }) || ! slices . Equal ( in . Context . Passed , [] string { "Chicken Shawarma Bowls" }) {
t . Errorf ( "replenish batch declined %v, passed %v; want the passed dish in both" , in . Declined , in . Context . Passed )
}
}
// TestBatchNeverStoresDishPassedWhileRunning passes a Side quest after a
// batch has read its input but before it stores, with suggest_side_quests
// answering from that input: the batch must not store the passed dish.
func TestBatchNeverStoresDishPassedWhileRunning ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
ids := e . seedDeck ( t , "Chicken Shawarma Bowls" )
e . recipe ( t , "Chicken Shawarma Bowls" )
e . recipe ( t , "Beef Ragu" )
held , release := make ( chan struct {}), make ( chan struct {})
stub := func ( skills . SideQuestsRequest ) skills . SideQuests {
close ( held )
<- release
quest := func ( name , cited string ) skills . SideQuest {
return skills . SideQuest { Name : name , Cuisine : "Fusion" , Method : "stovetop" , EstMinutes : 35 , Difficulty : "easy" ,
Reason : "Like your " + cited + "." , CitedRegulars : [] string { cited }, SearchQuery : name }
}
return skills . SideQuests { Suggestions : [] skills . SideQuest {
quest ( "Chicken Shawarma Bowls" , "Chicken Fajitas" ),
quest ( "Beef Ragu" , "Beef Chili" ),
}}
}
e . mu . Lock ()
e . stub = stub
e . mu . Unlock ()
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
<- held
if _ , err := e . svc . Decline ( t . Context (), ids [ 0 ], member ); err != nil {
close ( release )
t . Fatal ( err )
}
close ( release )
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusSucceeded {
t . Fatalf ( "batch job %s: %v" , job . Status , * job . Error )
}
var res batchResult
if err := json . Unmarshal ( job . Result , & res ); err != nil {
t . Fatal ( err )
}
if len ( res . SuggestionIDs ) != 1 || res . Dropped != 1 {
t . Errorf ( "batch result = %+v, want 1 kept and the passed dish dropped" , res )
}
if got := e . deckNames ( t ); ! slices . Equal ( got , [] string { "Beef Ragu" }) {
t . Errorf ( "deck = %v, want [Beef Ragu]" , got )
}
}
// TestLaterBatchNeverResuggestsRenamedPass passes a Side quest, renames
// its archived dinner, and checks the next batch still refuses the name it
// was suggested under.
func TestLaterBatchNeverResuggestsRenamedPass ( t * testing . T ) {
e := newEnv ( t ) // runner not started: the replenish batch waits for the rename
logger := slog . New ( slog . NewTextHandler ( io . Discard , nil ))
2026-10-10 15:26:20 -04:00
dinnerSvc , err := dinners . NewService ( e . st , e . grounder , e . runner , func () {}, logger )
2026-10-07 05:53:22 -04:00
if err != nil {
t . Fatal ( err )
}
ids := e . seedDeck ( t , "Chicken Shawarma Bowls" )
d , err := e . svc . Decline ( t . Context (), ids [ 0 ], member )
if err != nil {
t . Fatal ( err )
}
if d . ReplenishJobID == nil {
t . Fatal ( "passing the last Side quest started no replenish batch" )
}
renamed := "Leftover Wrap"
if _ , err := dinnerSvc . Update ( t . Context (), d . Suggestion . DinnerID , dinners . Patch { Name : & renamed }, member ); err != nil {
t . Fatal ( err )
}
// The respelled passed dish would ground, so only the declined names
// keep it out of the deck.
e . recipe ( t , "chicken shawarma bowls!" )
e . recipe ( t , "Beef Ragu" )
e . reply (
sideQuest ( "chicken shawarma bowls!" , "Spiced chicken, like your Chicken Fajitas." , "Chicken Fajitas" ),
sideQuest ( "Beef Ragu" , "Slow beef like your Beef Chili." , "Beef Chili" ),
)
e . start ( t )
job := e . waitJob ( t , * d . ReplenishJobID )
if job . Status != jobs . StatusSucceeded {
t . Fatalf ( "replenish job %s: %v" , job . Status , * job . Error )
}
if got := e . deckNames ( t ); ! slices . Equal ( got , [] string { "Beef Ragu" }) {
t . Errorf ( "deck = %v, want [Beef Ragu]" , got )
}
e . mu . Lock ()
in := e . inputs [ 0 ]
e . mu . Unlock ()
if ! slices . Equal ( in . Declined , [] string { "Chicken Shawarma Bowls" }) || ! slices . Equal ( in . Context . Passed , [] string { "Chicken Shawarma Bowls" }) {
t . Errorf ( "replenish batch declined %v, passed %v; want the name it was suggested under in both" , in . Declined , in . Context . Passed )
2026-10-07 16:53:23 -04:00
}
}
// TestLaterBatchNeverResuggestsRenamedDrop drops a tried dinner at its
// Rotation check ("Not for us"), renames its archived dinner, and checks
// the next batch still refuses the name it was dropped under.
func TestLaterBatchNeverResuggestsRenamedDrop ( t * testing . T ) {
e := newEnv ( t )
ctx := t . Context ()
logger := slog . New ( slog . NewTextHandler ( io . Discard , nil ))
2026-10-10 15:26:20 -04:00
dinnerSvc , err := dinners . NewService ( e . st , e . grounder , e . runner , func () {}, logger )
2026-10-07 16:53:23 -04:00
if err != nil {
t . Fatal ( err )
}
fail := func ( context . Context , string , skills . WeekRequest ) ( skills . Week , error ) {
return skills . Week {}, errors . New ( "no draft in this test" )
}
2026-10-10 15:26:20 -04:00
planSvc , err := plan . NewService ( e . st , fail , e . runner , noCalendar {}, notifytest . New ( t , e . st ), func () {}, & url . URL { Scheme : "https" , Host : "meals.example.com" }, logger )
2026-10-07 16:53:23 -04:00
if err != nil {
t . Fatal ( err )
}
id := e . dinner ( t , "Pad Thai" , "quest_log" )
q := db . New ( e . st . Writer )
now := store . Now (). String ()
planID := store . NewID ()
if err := q . CreateWeekPlan ( ctx , db . CreateWeekPlanParams { ID : planID , WeekStart : "2026-10-12" , CreatedAt : now , UpdatedAt : now }); err != nil {
t . Fatal ( err )
}
if err := q . PutPlanDay ( ctx , db . PutPlanDayParams { ID : store . NewID (), PlanID : planID , Date : "2026-10-14" ,
DinnerID : sql . NullString { String : id , Valid : true }, CreatedAt : now , UpdatedAt : now }); err != nil {
t . Fatal ( err )
}
day , err := store . ParseLocalDate ( "2026-10-14" )
if err != nil {
t . Fatal ( err )
}
if n , err := planSvc . MarkTried ( ctx , day ); err != nil || n != 1 {
t . Fatalf ( "MarkTried = %d, %v, want 1" , n , err )
}
if _ , err := planSvc . DecideReview ( ctx , id , plan . DecisionDrop , member ); err != nil {
t . Fatal ( err )
}
renamed := "Peanut Noodles"
if _ , err := dinnerSvc . Update ( ctx , id , dinners . Patch { Name : & renamed }, member ); err != nil {
t . Fatal ( err )
}
// The respelled dropped dish would ground, so only the passed names
// keep it out of the deck.
e . recipe ( t , "pad thai!" )
e . recipe ( t , "Beef Ragu" )
e . reply (
sideQuest ( "pad thai!" , "Noodles, like your Chicken Fajitas." , "Chicken Fajitas" ),
sideQuest ( "Beef Ragu" , "Slow beef like your Beef Chili." , "Beef Chili" ),
)
e . start ( t )
res := e . runBatch ( t )
if len ( res . SuggestionIDs ) != 1 || res . Dropped != 1 {
t . Errorf ( "batch result = %+v, want 1 kept and the dropped dish dropped" , res )
}
if got := e . deckNames ( t ); ! slices . Equal ( got , [] string { "Beef Ragu" }) {
t . Errorf ( "deck = %v, want [Beef Ragu]" , got )
}
e . mu . Lock ()
in := e . inputs [ 0 ]
e . mu . Unlock ()
if ! slices . Equal ( in . Declined , [] string { "Pad Thai" }) || ! slices . Equal ( in . Context . Passed , [] string { "Pad Thai" }) {
t . Errorf ( "batch declined %v, passed %v; want the name it was dropped under in both" , in . Declined , in . Context . Passed )
2026-10-07 05:53:22 -04:00
}
}
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
// job fail to store: the decision must not be recorded either, so the
// retry decides again and starts the replenish.
func TestDecisionNeverCommitsWithoutItsReplenish ( t * testing . T ) {
e := newEnv ( t ) // runner not started: the replenish batch stays pending
ids := e . seedDeck ( t , "Pho" , "Ramen" , "Laksa" , "Bibimbap" , "Katsu" )
if _ , err := e . st . Writer . ExecContext ( t . Context (), `CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'jobs refused'); END` ); err != nil {
t . Fatal ( err )
}
if _ , err := e . svc . Decline ( t . Context (), ids [ 0 ], member ); err == nil {
t . Fatal ( "decline succeeded with no replenish job stored" )
}
if got := e . deckNames ( t ); len ( got ) != 5 {
t . Errorf ( "deck after the failed decline = %v, want all 5 still pending" , got )
}
if n := e . activeBatches ( t ); n != 0 {
t . Errorf ( "%d active batches after the failed decline, want 0" , n )
}
if _ , err := e . st . Writer . ExecContext ( t . Context (), `DROP TRIGGER refuse_jobs` ); err != nil {
t . Fatal ( err )
}
d , err := e . svc . Decline ( t . Context (), ids [ 0 ], member )
if err != nil {
t . Fatal ( err )
}
if d . Suggestion . Status != suggest . StatusDeclined || d . ReplenishJobID == nil {
t . Fatalf ( "retried decline = %+v, want declined with a replenish job" , d )
}
job , err := e . runner . Get ( t . Context (), * d . ReplenishJobID )
if err != nil {
t . Fatal ( err )
}
if job . Kind != suggest . KindSuggestionBatch || job . Status != jobs . StatusQueued {
t . Errorf ( "replenish job = %+v, want a queued %s" , job , suggest . KindSuggestionBatch )
}
if n := e . activeBatches ( t ); n != 1 {
t . Errorf ( "%d active batches, want 1" , n )
}
}
func TestBatchFailsWhenAIFails ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . side . QueueGenerate ( llm . GenerateResult { Err : errors . New ( "provider down" )})
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusFailed || * job . Error != suggest . ReasonAIFailed {
t . Fatalf ( "job = %s %v, want failed %s" , job . Status , job . Error , suggest . ReasonAIFailed )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchFailed {
t . Errorf ( "batch status = %s, want failed" , got )
}
if got := e . deckNames ( t ); len ( got ) != 0 {
t . Errorf ( "deck = %v after a failed batch, want empty" , got )
}
// A failed batch does not block the next one.
next , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if ! next . Created || next . ID == b . ID {
t . Errorf ( "next batch = %+v, want a new one" , next )
}
}
func TestBatchFailsWhenSearchFails ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . search . Err = errors . New ( "search down" )
e . reply ( sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ))
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusFailed || * job . Error != suggest . ReasonSearchFailed {
t . Fatalf ( "job = %s %v, want failed %s" , job . Status , job . Error , suggest . ReasonSearchFailed )
}
if got := e . deckNames ( t ); len ( got ) != 0 {
t . Errorf ( "deck = %v, want empty" , got )
}
}
func TestBatchFailsWithNoRegulars ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
for _ , id := range e . regulars {
if _ , err := db . New ( e . st . Writer ). ArchiveDinner ( t . Context (), db . ArchiveDinnerParams { ID : id , UpdatedAt : store . Now (). String ()}); err != nil {
t . Fatal ( err )
}
}
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusFailed || * job . Error != suggest . ReasonNoRegulars {
t . Fatalf ( "job = %s %v, want failed %s" , job . Status , job . Error , suggest . ReasonNoRegulars )
}
if reqs := e . side . Requests (); len ( reqs ) != 0 {
t . Errorf ( "model called %d times with no Regulars" , len ( reqs ))
}
}
func TestStartBatchFailsOrphanedBatch ( t * testing . T ) {
e := newEnv ( t )
now := store . Now (). String ()
orphan , err := db . New ( e . st . Writer ). CreateSuggestionBatch ( t . Context (), db . CreateSuggestionBatchParams { ID : store . NewID (), Trigger : "manual" , CreatedAt : now , UpdatedAt : now })
if err != nil {
t . Fatal ( err )
}
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if ! b . Created || b . ID == orphan . ID {
t . Fatalf ( "batch = %+v, want a new one past the orphan" , b )
}
if got := e . batchStatus ( t , orphan . ID ); got != suggest . BatchFailed {
t . Errorf ( "orphan status = %s, want failed" , got )
}
// A batch whose job is still queued is not an orphan.
again , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if again . Created || again . ID != b . ID || e . batchStatus ( t , b . ID ) != suggest . BatchPending {
t . Errorf ( "second batch = %+v, status %s; want batch %s still pending" , again , e . batchStatus ( t , b . ID ), b . ID )
}
}
func TestBatchJobFailsWhenRecordingItsEndFails ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . recipe ( t , "Chili Verde" )
e . reply ( sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ))
if _ , err := e . st . Writer . ExecContext ( t . Context (), `CREATE TRIGGER refuse_done BEFORE UPDATE OF status ON suggestion_batches WHEN NEW.status = 'done' BEGIN SELECT RAISE(ABORT, 'done refused'); END` ); err != nil {
t . Fatal ( err )
}
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusFailed {
t . Fatalf ( "job %s with its batch %s, want failed" , job . Status , e . batchStatus ( t , b . ID ))
}
if _ , err := e . st . Writer . ExecContext ( t . Context (), `DROP TRIGGER refuse_done` ); err != nil {
t . Fatal ( err )
}
next , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if ! next . Created || next . ID == b . ID {
t . Errorf ( "next batch = %+v, want a new one" , next )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchFailed {
t . Errorf ( "batch status = %s with its job failed, want failed" , got )
}
2026-10-09 18:04:05 -04:00
// Its Side quests stay in the deck, and the household was told of them.
2026-10-10 08:53:14 -04:00
if rows := notifytest . Rows ( t , e . st , notify . NewSuggestions ); len ( rows ) != 1 || rows [ 0 ]. Key != "new_suggestions:" + b . ID || rows [ 0 ]. Payload . Title != "1 new Side quest is in" || rows [ 0 ]. Payload . Body != "Chili Verde" {
2026-10-09 18:04:05 -04:00
t . Errorf ( "new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde" , rows )
}
2026-10-07 05:53:22 -04:00
}
func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . recipe ( t , "Chili Verde" )
e . reply ( sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ))
for _ , trigger := range [] string {
`CREATE TRIGGER refuse_done BEFORE UPDATE OF status ON suggestion_batches WHEN NEW.status = 'done' BEGIN SELECT RAISE(ABORT, 'done refused'); END` ,
`CREATE TRIGGER refuse_job_end BEFORE UPDATE OF status ON jobs WHEN NEW.status IN ('succeeded', 'failed') BEGIN SELECT RAISE(ABORT, 'job end refused'); END` ,
} {
if _ , err := e . st . Writer . ExecContext ( t . Context (), trigger ); err != nil {
t . Fatal ( err )
}
}
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
deadline := time . Now (). Add ( 10 * time . Second )
for ! strings . Contains ( e . logs . String (), "record job failure" ) {
if time . Now (). After ( deadline ) {
t . Fatalf ( "job end never recorded; batch %s" , e . batchStatus ( t , b . ID ))
}
time . Sleep ( 5 * time . Millisecond )
}
// While neither end is recorded the batch is still the active one.
again , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if again . Created || again . ID != b . ID {
t . Fatalf ( "batch = %+v while batch %s is running, want it" , again , b . ID )
}
// The database recovers; the service is not restarted.
for _ , trigger := range [] string { "refuse_done" , "refuse_job_end" } {
if _ , err := e . st . Writer . ExecContext ( t . Context (), "DROP TRIGGER " + trigger ); err != nil {
t . Fatal ( err )
}
}
job := e . waitJob ( t , b . JobID )
if job . Status != jobs . StatusFailed {
t . Fatalf ( "job %s, want failed" , job . Status )
}
next , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if ! next . Created || next . ID == b . ID {
t . Errorf ( "next batch = %+v, want a new one" , next )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchFailed {
t . Errorf ( "batch status = %s with its job failed, want failed" , got )
}
}
func TestBlockedBatchEndDoesNotHoldShutdown ( t * testing . T ) {
e := newEnv ( t )
ctx , cancel := context . WithCancel ( context . Background ())
if err := e . runner . Start ( ctx ); err != nil {
cancel ()
t . Fatal ( err )
}
stopped := make ( chan struct {})
t . Cleanup ( func () {
cancel ()
<- stopped
})
e . search . Gate = make ( chan struct {})
e . search . Err = errors . New ( "search down" )
e . reply ( sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ))
b , err := e . svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
deadline := time . Now (). Add ( 10 * time . Second )
for len ( e . search . Queries ()) == 0 {
if time . Now (). After ( deadline ) {
t . Fatal ( "batch never searched" )
}
time . Sleep ( 5 * time . Millisecond )
}
// Hold the only writer connection, so recording the batch's end
// waits for it once the search fails.
conn , err := e . st . Writer . Conn ( t . Context ())
if err != nil {
t . Fatal ( err )
}
waits := e . st . Writer . Stats (). WaitCount
close ( e . search . Gate )
for e . st . Writer . Stats (). WaitCount == waits {
if time . Now (). After ( deadline ) {
conn . Close ()
t . Fatal ( "batch end never waited for the writer" )
}
time . Sleep ( 5 * time . Millisecond )
}
go func () {
e . runner . Wait ()
close ( stopped )
}()
cancel ()
// Each final write's attempt ends after jobs.FinalWriteTimeout; well
// past that, the worker is waiting for the writer with no bound.
select {
case <- stopped :
case <- time . After ( jobs . FinalWriteTimeout + 10 * time . Second ):
conn . Close ()
<- stopped
t . Fatal ( "worker waited for a held writer through shutdown" )
}
if err := conn . Close (); err != nil {
t . Fatal ( err )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchRunning {
t . Fatalf ( "after shutdown batch status = %s, want left running" , got )
}
// The next process fails the job as interrupted, and the batch no
// longer blocks the next one.
logger := slog . New ( slog . NewTextHandler ( io . Discard , nil ))
next , err := jobs . NewRunner ( e . st , logger , 1 )
if err != nil {
t . Fatal ( err )
}
refuse := func ( context . Context , string , skills . SideQuestsRequest ) ( skills . SideQuests , error ) {
return skills . SideQuests {}, errors . New ( "not wanted" )
}
2026-10-09 18:04:05 -04:00
svc , err := suggest . NewService ( e . st , e . grounder , refuse , next , notifytest . New ( t , e . st ), logger )
2026-10-07 05:53:22 -04:00
if err != nil {
t . Fatal ( err )
}
nextCtx , nextCancel := context . WithCancel ( context . Background ())
if err := next . Start ( nextCtx ); err != nil {
nextCancel ()
t . Fatal ( err )
}
t . Cleanup ( func () {
nextCancel ()
next . Wait ()
})
job , err := next . Get ( t . Context (), b . JobID )
if err != nil {
t . Fatal ( err )
}
if job . Status != jobs . StatusFailed || job . Error == nil || * job . Error != jobs . CodeInterrupted {
t . Fatalf ( "interrupted job = %+v, want failed with %q" , job , jobs . CodeInterrupted )
}
again , err := svc . StartBatch ( t . Context (), suggest . TriggerManual )
if err != nil {
t . Fatal ( err )
}
if ! again . Created || again . ID == b . ID {
t . Errorf ( "next batch = %+v, want a new one" , again )
}
if got := e . batchStatus ( t , b . ID ); got != suggest . BatchFailed {
t . Errorf ( "interrupted batch status = %s, want failed" , got )
}
}
func TestStartBatchRefusesUnknownTrigger ( t * testing . T ) {
e := newEnv ( t )
if _ , err := e . svc . StartBatch ( t . Context (), "whenever" ); err == nil {
t . Fatal ( "unknown trigger accepted" )
}
}
2026-10-08 03:13:21 -04:00
2026-10-10 08:53:14 -04:00
// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by
// StartScheduledBatch, for a Monday.
func TestScheduledBatchNeedsItsWeek ( t * testing . T ) {
e := newEnv ( t )
if _ , err := e . svc . StartBatch ( t . Context (), suggest . TriggerScheduled ); err == nil {
t . Error ( "StartBatch started a scheduled batch with no week" )
}
for _ , week := range [] store . LocalDate {{}, mustDate ( "2026-10-06" )} {
if _ , err := e . svc . StartScheduledBatch ( t . Context (), week ); err == nil {
t . Errorf ( "StartScheduledBatch(%q) accepted a week that is not a Monday" , week )
}
}
if n := e . activeBatches ( t ); n != 0 {
t . Errorf ( "%d active batches, want none" , n )
}
}
// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's
// new_suggestions is keyed by the week it was scheduled for, not the week
// it runs in, so a batch caught up after its week ended still tells the
// household of that week's Side quests and leaves the next week's key free.
func TestScheduledBatchKeyedByItsWeekWhenRunLate ( t * testing . T ) {
e := newEnv ( t )
e . start ( t )
e . recipe ( t , "Chili Verde" )
e . reply ( sideQuest ( "Chili Verde" , "Your Beef Chili gone green." , "Beef Chili" ))
// A week long past: the batch runs today, weeks later.
b , err := e . svc . StartScheduledBatch ( t . Context (), mustDate ( "2026-01-05" ))
if err != nil {
t . Fatal ( err )
}
if job := e . waitJob ( t , b . JobID ); job . Status != jobs . StatusSucceeded {
t . Fatalf ( "batch job %s" , job . Status )
}
rows := notifytest . Rows ( t , e . st , notify . NewSuggestions )
if len ( rows ) != 1 || rows [ 0 ]. Key != "new_suggestions:2026-W02" || rows [ 0 ]. Payload . Week != "2026-01-05" ||
rows [ 0 ]. Payload . Title != "1 new Side quest is in" {
t . Errorf ( "new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05" , rows )
}
}
2026-10-08 03:13:21 -04:00
// noCalendar is a plan.Calendar for tests that never push.
type noCalendar struct {}
func ( noCalendar ) PushEvents ( context . Context , [] gcal . Event , func () error ) error {
return errors . New ( "no calendar in this test" )
}