package plan_test import ( "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" "net/url" "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/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" "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/notify" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) // week is the Monday the tests draft, 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"} ) // publicURL is MEALQUEST_PUBLIC_URL in these tests. var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"} func mustDate(s string) store.LocalDate { d, err := store.ParseLocalDate(s) if err != nil { panic(err) } return d } type env struct { svc *plan.Service st *store.Store runner *jobs.Runner model *llm.Fake // google is the fake Google the service pushes to through gcal. google *gcaltest.Google gcal *gcal.Service // ids are the seeded dinners' ids by name. ids map[string]string mu sync.Mutex inputs []skills.WeekRequest } // newEnv returns a plan service over a migrated store in zone // America/New_York with dinner time 17:30, the Regulars below and Quest // log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner // with two workers. It drafts through the real harness and draft_week with // the scripted e.model, and pushes through gcal to e.google, which starts // unconnected. func newEnv(t *testing.T) *env { t.Helper() ctx := t.Context() st, err := store.Open(ctx, t.TempDir()) 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, model: &llm.Fake{}, ids: map[string]string{}} for _, d := range []struct { name, status, method string minutes int64 }{ {"Beef Chili", "regular", "crockpot", 240}, {"Pot Roast", "regular", "crockpot", 0}, {"Chicken Fajitas", "regular", "stovetop", 30}, {"Spaghetti Bolognese", "regular", "stovetop", 45}, {"Sheet Pan Salmon", "regular", "oven", 35}, {"Pad Thai", "quest_log", "stovetop", 35}, {"Shawarma Bowls", "quest_log", "oven", 40}, } { e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes) } 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.model, nil }, Store: st, Guard: guard, } drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) { e.mu.Lock() e.inputs = append(e.inputs, in) e.mu.Unlock() return skills.DraftWeek(ctx, cfg, scope, in) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) // Two workers, as cmd/server runs, so drafts of a week could overlap. e.runner, err = jobs.NewRunner(st, logger, 2) if err != nil { t.Fatal(err) } e.google = gcaltest.New(t, "https://meals.example.com"+gcal.CallbackPath) key := make([]byte, secretbox.KeySize) rand.Read(key) box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) if err != nil { t.Fatal(err) } e.gcal, err = gcal.NewService(st, box, gcal.Config{ ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: publicURL, SecretKey: []byte("server-secret-key"), Endpoint: e.google.Endpoint(), RevokeURL: e.google.RevokeURL(), CalendarAPI: e.google.CalendarAPI(), }) if err != nil { t.Fatal(err) } e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger) if err != nil { t.Fatal(err) } return e } // input returns the request of the ith draft_week run. func (e *env) input(i int) skills.WeekRequest { e.mu.Lock() defer e.mu.Unlock() return e.inputs[i] } // 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 and returns its id; minutes 0 is unknown. func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string { t.Helper() now := store.Now().String() origin := "regular" if status != "regular" { 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, Method: sql.NullString{String: method, Valid: method != ""}, MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0}, GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, }) if err != nil { t.Fatal(err) } return d.ID } // reply scripts one draft_week reply planning the dinners named names on // weekDates, in order. func (e *env) reply(note string, names ...string) { e.model.QueueGenerate(e.weekReply(note, names...)) } // weekReply is the draft_week reply planning the dinners named names on // weekDates, in order. func (e *env) weekReply(note string, names ...string) llm.GenerateResult { return e.weekReplyOn(weekDates, note, names...) } // weekReplyOn is the draft_week reply planning the dinners named names on // dates, in order. func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult { var days []string for i, name := range names { days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name])) } js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note) return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}} } // fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast // on Tuesday. var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"} // 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) } } // draft asks for a draft of week with confirm and waits for its job. func (e *env) draft(t *testing.T, confirm bool) jobs.Job { t.Helper() d, err := e.svc.StartDraft(t.Context(), week, confirm) if err != nil { t.Fatal(err) } if !d.Created { t.Fatalf("draft job %s was already queued", d.JobID) } return e.waitJob(t, d.JobID) } // mustDraft drafts week with confirm, requiring the job to succeed, and // returns the plan. func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan { t.Helper() job := e.draft(t, confirm) if job.Status != jobs.StatusSucceeded { t.Fatalf("draft job %s, error %v", job.Status, *job.Error) } p, err := e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } var res struct { WeekStart string `json:"week_start"` PlanID string `json:"plan_id"` } if err := json.Unmarshal(job.Result, &res); err != nil { t.Fatal(err) } if res.WeekStart != week.String() || res.PlanID != p.ID { t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID) } return p } // setStatus sets the plan of week to status, as Push to Calendar would. func (e *env) setStatus(t *testing.T, status string) { t.Helper() if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`, status, store.Now().String(), week.String()); err != nil { t.Fatal(err) } } func dayNames(p plan.Plan) []string { var names []string for _, d := range p.Days { names = append(names, d.Dinner.Name) } return names } func TestDraftCreatesPlan(t *testing.T) { e := newEnv(t) e.start(t) e.reply("Five Regulars and two from your Quest log.", fullWeek...) p := e.mustDraft(t, false) if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" { t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel) } if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." { t.Errorf("ai_note = %v", p.AINote) } if p.CalendarSyncedAt != nil { t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt) } if got := dayNames(p); !slices.Equal(got, fullWeek) { t.Errorf("days = %v, want %v", got, fullWeek) } for i, d := range p.Days { if d.Date != weekDates[i] { t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i]) } wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls" if d.IsNew != wantNew { t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew) } if d.Dinner.ID != e.ids[d.Dinner.Name] { t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID) } } chili := p.Days[0] if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" { t.Errorf("Beef Chili dinner = %+v", chili.Dinner) } // The pool is every Regular and Quest log dinner; last week has no // plan. in := e.input(0) var pool []string for _, c := range in.Pool { pool = append(pool, c.Name+"/"+c.Kind) } slices.Sort(pool) want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular", "Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"} if !slices.Equal(pool, want) { t.Errorf("pool = %v, want %v", pool, want) } if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 { t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek) } } // TestDraftEmitsPlanReady: the AI's first draft of a week stores plan_ready // keyed by the week, on push and Google Chat, naming the dinners Monday to // Sunday; a redraft (Rebuild week) and a manual swap store nothing more. func TestDraftEmitsPlanReady(t *testing.T) { e := newEnv(t) e.start(t) e.reply("", fullWeek...) e.mustDraft(t, false) zone, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } want := chat.PlanReady(week, household.WeekStart(household.Today(time.Now(), zone)), fullWeek) rows := notifytest.Rows(t, e.st, notify.PlanReady) if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush { t.Fatalf("plan_ready rows = %+v, want one on chat and one on push", rows) } for _, r := range rows { p := r.Payload if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" || p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" { t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title) } } reversed := slices.Clone(fullWeek) slices.Reverse(reversed) e.reply("", reversed...) e.mustDraft(t, false) e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil { t.Fatal(err) } if again := notifytest.Rows(t, e.st, notify.PlanReady); !slices.Equal(again, rows) { t.Errorf("plan_ready rows after a redraft and a swap = %+v, want the first draft's only", again) } } // TestFailedDraftEmitsNothing: a draft that stores no plan notifies no one. func TestFailedDraftEmitsNothing(t *testing.T) { e := newEnv(t) e.start(t) e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) if job := e.draft(t, false); job.Status != jobs.StatusFailed { t.Fatalf("job %s, want failed", job.Status) } if rows := notifytest.Rows(t, e.st, notify.PlanReady); len(rows) != 0 { t.Errorf("plan_ready rows = %+v, want none", rows) } } // TestEmitDayOfReminder: the reminder of a planned date names its dinner, // keyed by the date; a date with no plan emits nothing, and a date emitted // again stores nothing more. func TestEmitDayOfReminder(t *testing.T) { e := newEnv(t) e.start(t) if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); ok || err != nil { t.Fatalf("EmitDayOfReminder with no plan = %v, %v, want false", ok, err) } e.reply("", fullWeek...) e.mustDraft(t, false) for range 2 { if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); !ok || err != nil { t.Fatalf("EmitDayOfReminder = %v, %v, want true", ok, err) } } rows := notifytest.Rows(t, e.st, notify.DayOfReminder) if len(rows) != 1 { t.Fatalf("day_of_reminder rows = %+v, want one on push", rows) } r := rows[0] if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" || r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" { t.Errorf("day_of_reminder row = %+v", r) } } func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) { e := newEnv(t) e.start(t) e.reply("", fullWeek...) p := e.mustDraft(t, false) newYork, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } // Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT. if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) { t.Errorf("Beef Chili start_by = %v, want %v", got, want) } // Pot Roast is a crock-pot dinner with no minutes; the rest are not // crock-pot dinners. for _, d := range p.Days[1:] { if d.StartBy != nil { t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy) } } if p.AINote != nil { t.Errorf("ai_note = %q, want null for an empty note", *p.AINote) } // start_by follows the household's dinner time. dinnerTime := "18:45" if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil { t.Fatal(err) } p, err = e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) { t.Errorf("Beef Chili start_by = %v, want %v", got, want) } if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) { t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err) } } func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) { e := newEnv(t) // New York leaves daylight saving time at 02:00 on Sunday 2026-11-01. // Beef Chili (240 minutes) is planned Monday to Saturday and a 20-hour // crock-pot dinner on Sunday, so its start crosses the change. stew := e.dinner(t, "Overnight Stew", "regular", "crockpot", 1200) q := db.New(e.st.Writer) now := store.Now().String() id := store.NewID() dst := mustDate("2026-10-26") if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: dst.String(), CreatedAt: now, UpdatedAt: now}); err != nil { t.Fatal(err) } dates := []string{"2026-10-26", "2026-10-27", "2026-10-28", "2026-10-29", "2026-10-30", "2026-10-31", "2026-11-01"} for i, date := range dates { dinner := e.ids["Beef Chili"] if i == 6 { dinner = stew } if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date, DinnerID: sql.NullString{String: dinner, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { t.Fatal(err) } } p, err := e.svc.Get(t.Context(), dst) if err != nil { t.Fatal(err) } // Saturday's dinner is 17:30 EDT; Sunday's is 17:30 EST, 22:30 UTC, and // twenty hours before it is 22:30 EDT on Saturday. for _, tc := range []struct { day int want string }{ {5, `"2026-10-31T13:30:00-04:00"`}, {6, `"2026-10-31T22:30:00-04:00"`}, } { b, err := json.Marshal(p.Days[tc.day].StartBy) if err != nil || string(b) != tc.want { t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want) } } // Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST. ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240) if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil { t.Fatal(err) } if p, err = e.svc.Get(t.Context(), dst); err != nil { t.Fatal(err) } if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` { t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err) } } func TestDraftGivesLastWeek(t *testing.T) { e := newEnv(t) e.start(t) last := mustDate("2026-10-05") q := db.New(e.st.Writer) now := store.Now().String() id := store.NewID() if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil { t.Fatal(err) } if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06", DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { t.Fatal(err) } e.reply("", fullWeek...) e.mustDraft(t, false) if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) { t.Errorf("last week = %v, want Chicken Fajitas", got) } } func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) { e := newEnv(t) e.start(t) // Pad Thai is on a later week's plan, so the Quest log no longer // offers it; an eighth dinner keeps the pool at seven. q := db.New(e.st.Writer) now := store.Now().String() later := store.NewID() if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil { t.Fatal(err) } if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05", DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil { t.Fatal(err) } e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0) names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"} e.reply("", names...) p := e.mustDraft(t, false) if got := dayNames(p); !slices.Equal(got, names) { t.Errorf("days = %v", got) } for _, c := range e.input(0).Pool { if c.Name == "Pad Thai" { t.Errorf("pool has Pad Thai, planned on a later week") } } // A redraft of the week keeps offering its own Quest log dinners. e.reply("", names...) e.mustDraft(t, false) if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) { t.Errorf("redraft pool left out Shawarma Bowls, planned on this week") } } func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) { for _, status := range []string{"archived", "tried"} { t.Run(status, func(t *testing.T) { e := newEnv(t) e.start(t) // Pad Thai leaves the Quest log while draft_week plans it. started, release := make(chan struct{}), make(chan struct{}) r := e.weekReply("", fullWeek...) r.Started, r.Release = started, release e.model.QueueGenerate(r) d, err := e.svc.StartDraft(t.Context(), week, false) if err != nil { t.Fatal(err) } <-started if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil { t.Fatal(err) } close(release) job := e.waitJob(t, d.JobID) if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged { t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged) } if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { t.Errorf("Get = %v, want ErrNotFound: no partial plan", err) } }) } } func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) { e := newEnv(t) e.start(t) // This week's draft plans Pad Thai; while draft_week runs, the other // worker drafts a later week with Pad Thai too and stores it first. started, release := make(chan struct{}), make(chan struct{}) r := e.weekReply("", fullWeek...) r.Started, r.Release = started, release e.model.QueueGenerate(r) d, err := e.svc.StartDraft(t.Context(), week, false) if err != nil { t.Fatal(err) } <-started later := mustDate("2099-01-05") laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"} e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...)) other, err := e.svc.StartDraft(t.Context(), later, false) if err != nil { t.Fatal(err) } if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded { t.Fatalf("later week's draft %s %v", job.Status, job.Error) } close(release) job := e.waitJob(t, d.JobID) if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged { t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged) } if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { t.Errorf("Get = %v, want ErrNotFound: no partial plan", err) } } func TestDraftFailsWithNotEnoughDinners(t *testing.T) { e := newEnv(t) e.start(t) if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil { t.Fatal(err) } job := e.draft(t, false) if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners { t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners) } if n := len(e.model.Requests()); n != 0 { t.Errorf("model called %d times", n) } if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { t.Errorf("Get = %v, want ErrNotFound", err) } } func TestDraftFailsWhenAIFails(t *testing.T) { e := newEnv(t) e.start(t) e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) job := e.draft(t, false) if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed { t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed) } if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { t.Errorf("Get = %v, want ErrNotFound", err) } } func TestStartDraftIsSingleFlight(t *testing.T) { e := newEnv(t) first, err := e.svc.StartDraft(t.Context(), week, false) if err != nil { t.Fatal(err) } again, err := e.svc.StartDraft(t.Context(), week, false) if err != nil { t.Fatal(err) } if !first.Created || again.Created || again.JobID != first.JobID { t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again) } other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false) if err != nil { t.Fatal(err) } if !other.Created || other.JobID == first.JobID { t.Errorf("another week's draft = %+v, want its own job", other) } } func TestSimultaneousStartDraftsShareOneJob(t *testing.T) { for _, confirmFirst := range []bool{false, true} { t.Run(fmt.Sprintf("first confirm %v", confirmFirst), func(t *testing.T) { e := newEnv(t) first, err := e.svc.StartDraft(t.Context(), week, confirmFirst) if err != nil { t.Fatal(err) } // Twenty simultaneous drafts, half of them confirmed: every // one joins the queued job, except a confirmed one while the // job is unconfirmed, which is refused. const n = 20 drafts := make([]plan.Draft, n) errs := make([]error, n) var wg sync.WaitGroup for i := range n { wg.Go(func() { drafts[i], errs[i] = e.svc.StartDraft(context.Background(), week, i%2 == 1) }) } wg.Wait() for i, d := range drafts { if i%2 == 1 && !confirmFirst { if !errors.Is(errs[i], plan.ErrDraftRunning) { t.Errorf("confirmed draft %d = %+v, %v, want ErrDraftRunning", i, d, errs[i]) } continue } if errs[i] != nil || d.JobID != first.JobID || d.Created { t.Errorf("draft %d = %+v, %v, want it to join %s", i, d, errs[i], first.JobID) } } var queued int if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { t.Fatal(err) } if queued != 1 { t.Errorf("%d draft jobs, want 1", queued) } }) } } func TestConfirmedDraftWhileUnconfirmedDraftRuns(t *testing.T) { e := newEnv(t) e.start(t) e.reply("first", fullWeek...) e.mustDraft(t, false) // An unconfirmed redraft is running when the plan is synced; the // household then confirms a redraft. With a second worker idle, a // second job would draft alongside the first, so the confirmed draft // is refused and only the first job runs draft_week. reversed := slices.Clone(fullWeek) slices.Reverse(reversed) started, release := make(chan struct{}), make(chan struct{}) r := e.weekReply("unconfirmed", fullWeek...) r.Started, r.Release = started, release e.model.QueueGenerate(r) e.reply("concurrent", reversed...) unconfirmed, err := e.svc.StartDraft(t.Context(), week, false) if err != nil { t.Fatal(err) } <-started e.setStatus(t, plan.StatusSynced) if d, err := e.svc.StartDraft(t.Context(), week, true); !errors.Is(err, plan.ErrDraftRunning) { t.Fatalf("confirmed draft = %+v, %v, want ErrDraftRunning", d, err) } close(release) if job := e.waitJob(t, unconfirmed.JobID); job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired { t.Errorf("unconfirmed job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired) } var queued int if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { t.Fatal(err) } if queued != 2 { t.Errorf("%d draft jobs, want the first and the unconfirmed redraft", queued) } p, err := e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) { t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p)) } // Asked again once the unconfirmed job has finished, the confirmed // draft replaces the plan. if p := e.mustDraft(t, true); p.Status != plan.StatusChanged || *p.AINote != "concurrent" || !slices.Equal(dayNames(p), reversed) { t.Errorf("plan = %s %q %v, want the confirmed draft, changed", p.Status, *p.AINote, dayNames(p)) } } func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) { e := newEnv(t) e.start(t) e.reply("first", fullWeek...) e.mustDraft(t, false) // The household swaps Wednesday while draft_week runs; the draft must // not erase the swap. reversed := slices.Clone(fullWeek) slices.Reverse(reversed) started, release := make(chan struct{}), make(chan struct{}) r := e.weekReply("second", reversed...) r.Started, r.Release = started, release e.model.QueueGenerate(r) d, err := e.svc.StartDraft(t.Context(), week, false) if err != nil { t.Fatal(err) } <-started e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil { t.Fatal(err) } close(release) job := e.waitJob(t, d.JobID) if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPlanChanged { t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPlanChanged) } p, err := e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } want := slices.Clone(fullWeek) want[2] = "Fish Tacos" if *p.AINote != "first" || !slices.Equal(dayNames(p), want) { t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want) } // A draft after the swap replaces the plan. e.reply("third", reversed...) if p := e.mustDraft(t, false); *p.AINote != "third" || !slices.Equal(dayNames(p), reversed) { t.Errorf("redraft after the swap = %q %v, want the third draft", *p.AINote, dayNames(p)) } } func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) { e := newEnv(t) e.start(t) e.reply("first", fullWeek...) first := e.mustDraft(t, false) reversed := slices.Clone(fullWeek) slices.Reverse(reversed) e.reply("second", reversed...) second := e.mustDraft(t, false) if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" { t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID) } if got := dayNames(second); !slices.Equal(got, reversed) { t.Errorf("days = %v, want %v", got, reversed) } } func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) { for _, status := range []string{plan.StatusSynced, plan.StatusChanged} { t.Run(status, func(t *testing.T) { e := newEnv(t) e.start(t) e.reply("first", fullWeek...) first := e.mustDraft(t, false) e.setStatus(t, status) // Push to Calendar records each day's event id. if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil { t.Fatal(err) } if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) { t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err) } var queued int if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { t.Fatal(err) } if queued != 1 { t.Errorf("%d draft jobs, want only the first", queued) } reversed := slices.Clone(fullWeek) slices.Reverse(reversed) e.reply("second", reversed...) p := e.mustDraft(t, true) if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil { t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID) } if got := dayNames(p); !slices.Equal(got, reversed) { t.Errorf("days = %v, want %v", got, reversed) } // Reversed, only Thursday keeps its dinner, so only Thursday // keeps its event id: Calendar no longer shows the others as // planned. rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT date FROM plan_days WHERE calendar_event_id = 'ev-' || date ORDER BY date`) if err != nil { t.Fatal(err) } var kept []string for rows.Next() { var date string if err := rows.Scan(&date); err != nil { t.Fatal(err) } kept = append(kept, date) } if err := errors.Join(rows.Err(), rows.Close()); err != nil { t.Fatal(err) } if !slices.Equal(kept, []string{"2026-10-15"}) { t.Errorf("days that kept their calendar event id = %v, want only the unchanged 2026-10-15", kept) } }) } } func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) { e := newEnv(t) e.start(t) e.reply("first", fullWeek...) e.mustDraft(t, false) // The redraft is asked for while the plan is draft; the plan is synced // while draft_week runs, so the draft must not replace it. reversed := slices.Clone(fullWeek) slices.Reverse(reversed) started, release := make(chan struct{}), make(chan struct{}) r := e.weekReply("second", reversed...) r.Started, r.Release = started, release e.model.QueueGenerate(r) d, err := e.svc.StartDraft(t.Context(), week, false) if err != nil { t.Fatal(err) } <-started e.setStatus(t, plan.StatusSynced) close(release) job := e.waitJob(t, d.JobID) if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired { t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired) } p, err := e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) { t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p)) } } func TestParseWeekStart(t *testing.T) { if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week { t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err) } // 2026-02-30 and 2027-02-29 would be Mondays were they dates. for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "2026-02-30", "2027-02-29", "2026-13-05", "next-week", ""} { var invalid *plan.InvalidWeekError if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) { t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err) } } } func TestSwapDay(t *testing.T) { e := newEnv(t) e.start(t) e.reply("", fullWeek...) e.mustDraft(t, false) e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300) // A Quest log dinner is new, and a crock-pot one gets its start_by. p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"]) if err != nil { t.Fatal(err) } day := p.Days[2] if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" { t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day) } if p.Status != plan.StatusDraft { t.Errorf("status = %s, want a swapped draft to stay draft", p.Status) } // A Regular in place of a Quest log dinner is not new. p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]) if err != nil { t.Fatal(err) } if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil { t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day) } want := slices.Clone(fullWeek) want[2] = "Fish Tacos" if got := dayNames(p); !slices.Equal(got, want) { t.Errorf("days = %v, want %v", got, want) } got, err := e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } if !slices.Equal(dayNames(got), want) { t.Errorf("Get days = %v, want %v", dayNames(got), want) } } func TestSwapStatusTransitions(t *testing.T) { for _, tc := range []struct{ from, want string }{ {plan.StatusDraft, plan.StatusDraft}, {plan.StatusSynced, plan.StatusChanged}, {plan.StatusChanged, plan.StatusChanged}, } { t.Run(tc.from, func(t *testing.T) { e := newEnv(t) e.start(t) e.reply("", fullWeek...) e.mustDraft(t, false) e.setStatus(t, tc.from) fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos) if err != nil { t.Fatal(err) } if p.Status != tc.want { t.Errorf("status after swap = %s, want %s", p.Status, tc.want) } }) } } func TestSwapRefusals(t *testing.T) { e := newEnv(t) e.start(t) e.reply("", fullWeek...) e.mustDraft(t, false) e.setStatus(t, plan.StatusSynced) suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30) archived := e.dinner(t, "Old Stew", "archived", "", 0) var notPlannable *plan.DinnerNotPlannableError var badDay *plan.InvalidDayError for _, tc := range []struct { name string week store.LocalDate date, id string wantError func(error) bool }{ {"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }}, {"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }}, {"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }}, {"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, ¬Plannable) }}, {"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, ¬Plannable) }}, {"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, {"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, {"impossible date", week, "2026-10-32", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, } { if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) { t.Errorf("%s: Swap = %v", tc.name, err) } } p, err := e.svc.Get(t.Context(), week) if err != nil { t.Fatal(err) } if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) { t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p)) } }