Feature 20: Scheduler and Weekly Automation (#182)
CI / ci (push) Successful in 8m28s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #182.
This commit is contained in:
2026-10-10 19:30:23 -04:00
committed by approver_agent
parent 4dcfc3c27d
commit ecf7c6be89
39 changed files with 4457 additions and 228 deletions
+328
View File
@@ -0,0 +1,328 @@
package scheduler_test
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run plan's day_of_reminder schedules on the fake
// clock against the real plan service and notification outbox. The plan of
// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef
// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the
// seeded 17:30 and its reminder time the seeded 16:30, in Chicago.
// w42 is the Monday of 2026-W42.
var w42 = mustLocalDate("2026-10-12")
// planWeek drafts the plan of the week starting weekStart and waits for it.
func (p *planEnv) planWeek(weekStart store.LocalDate) {
p.e.t.Helper()
p.reply(weekStart)
if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil {
p.e.t.Fatal(err)
}
if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded {
p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs)
}
}
// crockPot makes the dinner name a crock-pot dinner of minutes.
func (p *planEnv) crockPot(name string, minutes int) {
p.e.t.Helper()
if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil {
p.e.t.Fatal(err)
}
}
// reminders returns the day_of_reminder notifications stored, oldest
// first. The seeded preference sends day_of_reminder to push only, so there
// is one row per event.
func (e *env) reminders() []notifytest.Row {
e.t.Helper()
rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder)
for _, r := range rows {
if r.Channel != notify.ChannelPush {
e.t.Fatalf("day_of_reminder row %+v, want push only", r)
}
}
return rows
}
// reminderKeys returns the keys of e.reminders().
func (e *env) reminderKeys() []string {
e.t.Helper()
var keys []string
for _, r := range e.reminders() {
keys = append(keys, r.Key)
}
return keys
}
// wantReminder fails unless the reminder keyed key was stored with title,
// body and the link to the dinner name.
func (p *planEnv) wantReminder(key, title, body, name string) {
p.e.t.Helper()
for _, r := range p.e.reminders() {
if r.Key != key {
continue
}
week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):]))
want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()}
if r.Payload != want {
p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want)
}
return
}
p.e.t.Errorf("no reminder %s", key)
}
// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot
// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed
// day_of_reminder:YYYY-MM-DD and linking to the dinner.
func TestDayOfReminderNormalNight(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.planWeek(w42)
// Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41.
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 16, 29))
wantKeys(t, "before 16:30", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 16, 30))
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"})
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast")
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
}
// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is
// earlier than reminder_time gets the start reminder at start_by minus 15
// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder
// at reminder_time. A crock-pot night whose start_by is not earlier than
// reminder_time gets only the daily one.
func TestDayOfReminderCrockPotNight(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.crockPot("Beef Chili", 480) // Monday: start by 09:30
p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 9, 14))
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 9, 15))
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
r.runUntil(at(2026, 10, 12, 16, 29))
wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
r.runUntil(at(2026, 10, 12, 16, 30))
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
}
// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none
// at all and then only next week's, nothing is emitted and nothing fails;
// next week's crock-pot Monday is reminded on its own day.
func TestDayOfReminderEmptyNight(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "with no plan", e.reminderKeys(), nil)
p.crockPot("Beef Chili", 480)
p.planWeek(w43)
r.runUntil(at(2026, 10, 18, 23, 59))
wantKeys(t, "with only next week's plan", e.reminderKeys(), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder),
[]string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"})
if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") {
t.Errorf("logs = %s, want the empty nights logged and no error", logs)
}
r.runUntil(at(2026, 10, 19, 9, 15))
wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"})
p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
}
// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time
// change takes effect in the running scheduler. Moved from 16:30 to 18:00,
// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by
// of 17:00, now earlier than reminder_time, gets its start reminder at
// 16:45.
func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.crockPot("Beef Chili", 30) // start by 17:00
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
e.update(household.SettingsPatch{ReminderTime: ptr("18:00")})
r.changed()
r.runUntil(at(2026, 10, 12, 16, 44))
wantKeys(t, "before 16:45", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 16, 45))
wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili")
r.runUntil(at(2026, 10, 12, 17, 59))
wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
r.runUntil(at(2026, 10, 12, 18, 0))
wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
}
// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder
// of a crock-pot night, within the catch-up window, emit neither again:
// each period is already claimed.
func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.crockPot("Beef Chili", 480)
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 9, 15))
r.stop()
p.stop()
p2 := e.plans(ids)
r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...)
wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
r2.runUntil(at(2026, 10, 12, 16, 30))
r2.stop()
p2.stop()
p3 := e.plans(ids)
r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...)
r3.runUntil(at(2026, 10, 13, 9, 0))
wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"})
}
// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant
// its lookup gives, its period keyed by the schedule's Key and the date;
// a lookup that fails fires nothing and is looked up again after
// ClaimRetry, then fires its period once.
func TestDailyAtLookupFailureIsRetried(t *testing.T) {
e := newEnv(t)
var rec recorder
var mu sync.Mutex
failing := false
at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) {
mu.Lock()
defer mu.Unlock()
if failing {
return time.Time{}, false, errors.New("database down")
}
y, m, day := d.Date()
return at(y, m, day, 9, 15), true, nil
}
sch := scheduler.Schedule{
Name: scheduler.DayOfReminder,
Key: "start",
Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) },
Run: rec.run,
}
r := e.start(at(2026, 10, 12, 8, 0), sch)
wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"})
mu.Lock()
failing = true
mu.Unlock()
r.runUntil(at(2026, 10, 12, 9, 16))
wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"})
mu.Lock()
failing = false
mu.Unlock()
r.runUntil(at(2026, 10, 12, 9, 17))
wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"})
if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" {
t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p)
}
r.runUntil(at(2026, 10, 13, 12, 0))
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
}
// questLog stores name as a Quest-log dinner, a crock-pot dinner of
// minutes, and returns its id. It is not a Regular.
func (p *planEnv) questLog(name string, minutes int) string {
p.e.t.Helper()
now := store.Now().String()
d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "quest_log",
Origin: "side_quest",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
p.e.t.Fatal(err)
}
p.ids[name] = d.ID
p.crockPot(name, minutes)
return d.ID
}
// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in
// for tonight wakes the scheduler, so its start reminder fires at start_by
// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At
// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's
// next timer is 10:00; at 09:01 the household swaps in an eight-hour
// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once.
func TestStartReminderAfterSwapFiresOnTime(t *testing.T) {
e := newEnv(t)
p := e.wakingPlans(e.pool())
stew := p.questLog("Crock Pot Stew", 480) // start by 09:30
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 9, 0))
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) {
t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next)
}
r.runUntil(at(2026, 10, 12, 9, 1))
if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil {
t.Fatal(err)
}
r.changed()
r.runUntil(at(2026, 10, 12, 9, 14))
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 9, 15))
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Crock Pot Stew", "Crock Pot Stew")
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew")
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
}
+9
View File
@@ -0,0 +1,9 @@
package scheduler
import "time"
// SetClock makes now the clock and after the timer s fires by.
func SetClock(s *Scheduler, now func() time.Time, after func(time.Duration) <-chan time.Time) {
s.now = now
s.after = after
}
+409
View File
@@ -0,0 +1,409 @@
package scheduler_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"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/gcal"
"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/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run plan's plan_draft schedule on the fake clock
// against the real plan service, job runner, harness, draft_week and
// notification outbox, with the AI faked.
// planEnv is a plan service over a scheduler env's store, the "process" a
// scheduler runs it in. model answers draft_week.
type planEnv struct {
e *env
svc *plan.Service
model *llm.Fake
ids map[string]string
logs *syncBuffer
stop func()
}
// poolNames are the seven Regulars pool stores, enough for a week.
var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"}
// pool stores poolNames as Regulars and returns their ids by name.
func (e *env) pool() map[string]string {
e.t.Helper()
ids := map[string]string{}
for _, name := range poolNames {
now := store.Now().String()
d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
e.t.Fatal(err)
}
ids[name] = d.ID
}
return ids
}
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
// noCalendar is a Calendar the schedule must never push to.
type noCalendar struct{ t *testing.T }
func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
c.t.Error("plan_draft pushed to Calendar")
return errors.New("no calendar")
}
// plans builds a plan service drafting from the dinners ids over e with a
// started job runner, which stop stops (and the test's end, if not before).
// Its changes do not wake the scheduler: a draft job's wake would add a
// pass the fake clock's waits do not expect.
func (e *env) plans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, func() {})
}
// wakingPlans is plans whose changes wake the scheduler, as in the server.
// A test using it changes plans only from its own goroutine and waits for
// the wake's pass with changed.
func (e *env) wakingPlans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, e.wake)
}
// newPlans is plans whose service calls wake after a plan change.
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
e.t.Helper()
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
e.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 p.model, nil },
Store: e.st,
Guard: guard,
}
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
return skills.DraftWeek(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(p.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
if err != nil {
e.t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
var once sync.Once
p.stop = func() {
once.Do(func() {
cancel()
runner.Wait()
})
}
e.t.Cleanup(p.stop)
return p
}
// reply scripts the next draft_week answer: poolNames on the days of the
// week starting weekStart, Monday to Sunday.
func (p *planEnv) reply(weekStart store.LocalDate) {
p.e.t.Helper()
dates, err := household.WeekDays(weekStart)
if err != nil {
p.e.t.Fatal(err)
}
var days []string
for i, name := range poolNames {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ","))
p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// draftJob is a draft_week jobs row.
type draftJob struct {
Status, Error string
}
// drafts waits until no draft_week job is queued or running and returns
// them all, oldest first.
func (e *env) drafts() []draftJob {
e.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek)
if err != nil {
e.t.Fatal(err)
}
var out []draftJob
busy := false
for rows.Next() {
var j draftJob
if err := rows.Scan(&j.Status, &j.Error); err != nil {
e.t.Fatal(err)
}
busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning)
out = append(out, j)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
rows.Close()
if !busy {
return out
}
if time.Now().After(deadline) {
e.t.Fatalf("draft jobs still under way: %+v", out)
}
time.Sleep(5 * time.Millisecond)
}
}
// wantDrafts fails unless the draft_week jobs are got.
func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) {
t.Helper()
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want)
}
}
// weekPlans returns the week_start of every plan, oldest first.
func (e *env) weekPlans() []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var w string
if err := rows.Scan(&w); err != nil {
e.t.Fatal(err)
}
out = append(out, w)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return out
}
// planReady returns the distinct plan_ready keys stored, oldest first.
func (e *env) planReady() []string {
e.t.Helper()
var keys []string
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
if len(keys) == 0 || keys[len(keys)-1] != r.Key {
keys = append(keys, r.Key)
}
}
return keys
}
var (
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
// w43 is the Monday of 2026-W43, the week after Friday Oct 16.
w43 = mustLocalDate("2026-10-19")
w44 = mustLocalDate("2026-10-26")
)
func mustLocalDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule
// drafts the next ISO week through the plan service's draft job, period
// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed
// by its Monday once.
func TestPlanDraftDraftsNextWeek(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
p.reply(w44)
// Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed.
r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 8, 59))
wantDrafts(t, "before Friday 09:00", e.drafts())
wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil)
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
got, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft {
t.Errorf("plan of W43 = %+v, want the drafted week", got)
}
r.runUntil(at(2026, 10, 23, 9, 0))
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
}
// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan,
// here one the household drafted by hand, the period drafts nothing,
// notifies no one and says so.
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
p.reply(w43) // a second draft would succeed too
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
wantDrafts(t, "manual draft", e.drafts(), succeeded)
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
before, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "with a plan", e.drafts(), succeeded)
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
after, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(after) != fmt.Sprint(before) {
t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before)
}
if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") {
t.Errorf("logs = %s, want the skipped period logged", logs)
}
}
// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was
// drafted, still within the catch-up window, drafts nothing more and sends
// no second plan_ready: the period is already claimed.
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.reply(w43)
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "first process", e.drafts(), succeeded)
r.stop()
p.stop()
// The restarted process has a new job runner, plan service and
// scheduler; it would answer a second draft if one were started.
p2 := e.plans(ids)
p2.reply(w43)
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
r2.runUntil(at(2026, 10, 23, 8, 59))
wantDrafts(t, "after restart", e.drafts(), succeeded)
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}
// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job
// fails as ai_failed with the failure recorded in ai_runs, no plan is made
// up and no one is notified; a restart within the catch-up window does not
// draft the claimed period again.
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
var status, msg string
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil {
t.Fatalf("ai_runs row of draft_week: %v", err)
}
if status != string(harness.StatusProviderError) || msg == "" {
t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
}
if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) {
t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed)
}
r.stop()
p.stop()
p2 := e.plans(ids)
p2.reply(w43)
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
wantDrafts(t, "after restart", e.drafts(), aiFailed)
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
}
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
// plan_draft_time change takes effect in the running scheduler; the old
// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next
// week.
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")})
r.changed()
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
wantDrafts(t, "before Wednesday 18:30", e.drafts())
r.runUntil(at(2026, 10, 14, 18, 30))
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
// Friday 09:00, the old fire time, drafts nothing more.
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}
+204
View File
@@ -0,0 +1,204 @@
package scheduler_test
import (
"context"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"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/scheduler"
)
// The tests in this file run plan's rotation_check schedule on the fake
// clock against the real plan service and notification outbox. The plan of
// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to
// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for
// Monday. The household's review time is the seeded 09:00, in Chicago.
// orzoDay is the Monday Lemon Orzo is planned on.
var orzoDay = mustLocalDate("2026-10-12")
// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in
// place of Beef Chili, and returns its id.
func (p *planEnv) orzoWeek() string {
p.e.t.Helper()
id := p.questLog("Lemon Orzo", 30)
p.planWeek(w42)
if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil {
p.e.t.Fatal(err)
}
return id
}
// rotationReviews returns the keys of the rotation_review notifications
// stored, oldest first. The seeded preference sends rotation_review to push
// only, so there is one row per event.
func (e *env) rotationReviews() []string {
e.t.Helper()
var keys []string
for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) {
if r.Channel != notify.ChannelPush {
e.t.Fatalf("rotation_review row %+v, want push only", r)
}
keys = append(keys, r.Key)
}
return keys
}
// status returns the status of the dinner name.
func (p *planEnv) status(name string) string {
p.e.t.Helper()
var s string
if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil {
p.e.t.Fatal(err)
}
return s
}
// pendingReviews returns the plan dates of the pending Rotation checks of
// the dinner name.
func (p *planEnv) pendingReviews(name string) []string {
p.e.t.Helper()
rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name])
if err != nil {
p.e.t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
p.e.t.Fatal(err)
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
p.e.t.Fatal(err)
}
return out
}
// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending
// Rotation check for orzoDay and exactly one rotation_review asking about
// it, which opens Home.
func (p *planEnv) wantOrzoTried(id string) {
p.e.t.Helper()
if got := p.status("Lemon Orzo"); got != "tried" {
p.e.t.Errorf("Lemon Orzo status = %s, want tried", got)
}
wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()})
key := "rotation_review:" + id + ":" + orzoDay.String()
wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key})
got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload
want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()}
if got != want {
p.e.t.Errorf("rotation_review = %+v, want %+v", got, want)
}
}
// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day
// after a Quest-log dinner was planned, the schedule, period
// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and
// emits one rotation_review. It never makes the dinner a Regular.
func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
// Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned.
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 8, 59))
wantKeys(t, "before 09:00", e.rotationReviews(), nil)
if got := p.status("Lemon Orzo"); got != "quest_log" {
t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got)
}
r.runUntil(at(2026, 10, 13, 9, 0))
p.wantOrzoTried(id)
// Days later, still tried: only the household's keep makes a Regular.
r.runUntil(at(2026, 10, 16, 12, 0))
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{
"rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14",
"rotation_check:2026-10-15", "rotation_check:2026-10-16",
})
}
// TestRotationCheckLeavesRegulars: a day planning only Regulars marks
// nothing tried, opens no Rotation check and emits nothing.
func TestRotationCheckLeavesRegulars(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.planWeek(w42)
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 9, 0))
wantKeys(t, "rotation_review", e.rotationReviews(), nil)
if got := p.status("Beef Chili"); got != "regular" {
t.Errorf("Beef Chili status = %s, want regular", got)
}
wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the
// period claimed, and MarkTried run again for the same day finds the
// Rotation check open, so neither opens a second check or notification.
func TestRotationCheckNoDuplicateSameDay(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
id := p.orzoWeek()
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 9, 0))
p.wantOrzoTried(id)
r.stop()
p.stop()
p2 := e.plans(ids)
r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule())
r2.runUntil(at(2026, 10, 13, 23, 59))
p2.wantOrzoTried(id)
if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 {
t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err)
}
p2.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00,
// the scheduler runs that day's Rotation check once at boot.
func TestRotationCheckCatchUpAtBoot(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule())
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"})
}
// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change
// takes effect in the running scheduler. Moved from 09:00 to 10:30, the
// Rotation check does not run at 09:00 and runs at 10:30.
func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
e.update(household.SettingsPatch{ReviewTime: ptr("10:30")})
r.changed()
r.runUntil(at(2026, 10, 13, 10, 29))
wantKeys(t, "before 10:30", e.rotationReviews(), nil)
if got := p.status("Lemon Orzo"); got != "quest_log" {
t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got)
}
r.runUntil(at(2026, 10, 13, 10, 30))
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
+746
View File
@@ -0,0 +1,746 @@
// Package scheduler runs the household's timed work: each registered
// schedule fires once per period at a household-local time read from the
// household settings.
//
// # Periods and the claim
//
// Every fire time belongs to one period, named by a period key derived in
// the household zone from the schedule's cadence (see Rule.PeriodKey): the
// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of
// the fire date, or of the week after it, for a weekly rule. Before running,
// the scheduler inserts schedule_runs(schedule, period_key); the insert is
// the claim, and a period already claimed, by an earlier run, an earlier
// process or another instance, is skipped. A period therefore runs at most
// once: a Run that fails is logged and its period is not retried.
//
// # Catch-up
//
// When the scheduler starts, and whenever it wakes late, it handles, oldest
// first, every fire time of each schedule that passed within CatchUpWindow,
// and the most recent fire time even when it passed longer ago. Each
// unclaimed period within CatchUpWindow runs once. A most recent one that
// passed longer ago is claimed without running and logged as missed, so it
// can never run later, not after a restart and not after a settings change
// moves the fire time. Other fire times older than CatchUpWindow are never
// looked at.
//
// # Claim failures
//
// A claim that fails (the database refused the insert) is never skipped:
// the scheduler logs it and keeps that period's fire time, and every
// ClaimRetry claims that same period again until the claim succeeds. Only
// then does it apply the catch-up rule to it, running it if its fire time
// is within CatchUpWindow of the successful claim and recording it as
// missed otherwise, and only then does it go on to later fire times, even
// when one has passed meanwhile. Each period is claimed by its own fire
// time, so the later period runs once as well.
//
// # Time zone and DST
//
// Fire times are wall-clock times in the household zone, never the process
// zone, turned into instants by household.LocalTime. Its rule is the
// scheduler's DST rule: a time that does not exist on a spring-forward day
// fires later by the length of the gap (02:30 fires at 03:30 local), and a
// time that occurs twice on a fall-back day fires once, at its first
// occurrence. Either way the period key is that local date's, so the
// period still runs exactly once.
//
// # Fire times from data
//
// A DailyAt rule fires at a time read from the household's data rather
// than the settings: its function gives, for each household-local date,
// the instant the date's period fires, or none. The period is that date
// even when the instant falls on another day. The scheduler asks again on
// every pass, and whoever changes that data calls Wake once the change is
// committed, so the next pass comes at once and a fire time the change
// moved fires on time rather than up to maxWait late; the claim still runs
// a period at most once. A lookup that fails is logged and repeated every
// ClaimRetry, as a failed claim is.
//
// # Settings changes
//
// The scheduler subscribes to the household settings and records each change
// with the instant it was saved. A change takes effect at once without a
// restart: fire times up to that instant follow the old settings and later
// ones the new, even when the scheduler gets to the change late. So moving
// a fire time to earlier the same day does not fire the passed time, and a
// new fire time that was still ahead when the change was saved fires.
package scheduler
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Schedule names, as the schedule_runs.schedule CHECK constraint allows.
const (
SuggestionsWeekly = "suggestions_weekly"
PlanDraft = "plan_draft"
ShoppingChatAutosend = "shopping_chat_autosend"
DayOfReminder = "day_of_reminder"
RotationCheck = "rotation_check"
WeekRollover = "week_rollover"
AIRunsPurge = "ai_runs_purge"
)
// names are the schedule names Register accepts.
var names = map[string]bool{
SuggestionsWeekly: true,
PlanDraft: true,
ShoppingChatAutosend: true,
DayOfReminder: true,
RotationCheck: true,
WeekRollover: true,
AIRunsPurge: true,
}
// CatchUpWindow is how long after its fire time a period that has not run
// still runs. A period found later than that is recorded as missed.
const CatchUpWindow = 24 * time.Hour
// ClaimRetry is how long the scheduler waits before repeating a pass in
// which a claim failed.
const ClaimRetry = time.Minute
// maxWait bounds one sleep, so the scheduler checks the wall clock at least
// this often even if the host was suspended or its clock stepped.
const maxWait = time.Hour
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
// day, or on Weekday only when Weekly is set, or every day at the instant
// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt.
type Rule struct {
Weekly bool
Weekday time.Weekday
Time string
// NextWeek keys a weekly period by the ISO week after the fire date,
// for work done ahead for the coming week.
NextWeek bool
// At, instead of Time, gives the instant the period of a
// household-local date fires.
At FireAt
}
// FireAt gives the instant the period of household-local date fires, or
// false when that date's period does not fire.
type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error)
// Daily fires every day at hhmm; its period is the local date.
func Daily(hhmm string) Rule {
return Rule{Time: hhmm}
}
// Weekly fires on day at hhmm; its period is the ISO week of the fire date.
func Weekly(day time.Weekday, hhmm string) Rule {
return Rule{Weekly: true, Weekday: day, Time: hhmm}
}
// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after
// the fire date's.
func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
}
// DailyAt fires at most once a day, at the instant at gives for the date;
// its period is that date, wherever the instant falls.
func DailyAt(at FireAt) Rule {
return Rule{At: at}
}
// PeriodKey returns the period key of a fire on household-local date
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
// when NextWeek is set. It panics if fireDate is the zero LocalDate.
func (r Rule) PeriodKey(fireDate store.LocalDate) string {
if !r.Weekly {
return fireDate.String()
}
if r.NextWeek {
return household.PeriodKey(addDays(fireDate, 7))
}
return household.PeriodKey(fireDate)
}
// validate reports whether r can fire.
func (r Rule) validate() error {
if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) {
return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday)
}
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
return errors.New("a daily rule has no weekday or next week")
}
if r.At != nil {
if r.Weekly || r.Time != "" {
return errors.New("a DailyAt rule has no weekday or time")
}
return nil
}
// Any date checks the time's form.
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
return err
}
// Period is one period of a schedule, as given to its Run.
type Period struct {
// Key is the period key claimed in schedule_runs.
Key string
// FireAt is the instant the period fired, in the household zone.
FireAt time.Time
// Date is the household-local date of FireAt.
Date store.LocalDate
// Week is the ISO Monday of the week Key names; for a daily rule, of
// the week containing Date.
Week store.LocalDate
}
// Schedule is registered work that runs once per period.
type Schedule struct {
// Name is one of the schedule name constants.
Name string
// Key, when set, prefixes the schedule's period keys with Key and a
// colon ("start:2026-10-14"), so two schedules of one Name claim
// different periods. Name and Key together are unique.
Key string
// PeriodKey, when set, gives the period key claimed for p in place of
// the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week;
// p.Key is the rule's key. It must give each period its own key. A
// schedule sets Key or PeriodKey, not both.
PeriodKey func(p Period) string
// Rule returns when the schedule fires under settings. It is called
// again after every settings change.
Rule func(settings household.Settings) Rule
// Run does the period's work. It runs on the scheduler's goroutine,
// one schedule at a time, so it must return promptly: long work is
// enqueued as a job. ctx is cancelled when the scheduler stops. An
// error is logged; the period stays claimed and is not retried.
Run func(ctx context.Context, p Period) error
}
// label names sch in errors and logs: its Name, and its Key if set.
func (sch Schedule) label() string {
if sch.Key == "" {
return sch.Name
}
return sch.Name + ":" + sch.Key
}
// Settings is the household settings source: *household.SettingsService.
type Settings interface {
Get(ctx context.Context) (household.Settings, error)
Subscribe(fn func(household.Settings)) (unsubscribe func())
}
// Scheduler runs the registered schedules.
type Scheduler struct {
st *store.Store
settings Settings
logger *slog.Logger
// now is the clock and after the timer; tests replace both.
now func() time.Time
after func(d time.Duration) <-chan time.Time
// wake holds one token when the settings or the data a DailyAt rule
// reads changed since the loop last looked; pending is the settings
// changes to apply, oldest first.
wake chan struct{}
pendingMu sync.Mutex
pending []change
mu sync.Mutex
schedules []Schedule
started bool
wg sync.WaitGroup
}
// New returns a scheduler over st reading the household settings from
// settings.
func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) {
if st == nil {
return nil, errors.New("scheduler: nil store")
}
if settings == nil {
return nil, errors.New("scheduler: nil settings")
}
if logger == nil {
return nil, errors.New("scheduler: nil logger")
}
return &Scheduler{
st: st,
settings: settings,
logger: logger,
now: time.Now,
after: time.After,
wake: make(chan struct{}, 1),
}, nil
}
// Register adds sch. It must be called before Start, once per name.
func (s *Scheduler) Register(sch Schedule) error {
if !names[sch.Name] {
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
}
if sch.Rule == nil {
return fmt.Errorf("scheduler: register %s: nil rule", sch.label())
}
if sch.Run == nil {
return fmt.Errorf("scheduler: register %s: nil run", sch.label())
}
if strings.Contains(sch.Key, ":") {
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
}
if sch.Key != "" && sch.PeriodKey != nil {
return fmt.Errorf("scheduler: register %s: both key and period key", sch.label())
}
s.mu.Lock()
defer s.mu.Unlock()
if s.started {
return fmt.Errorf("scheduler: register %s: already started", sch.label())
}
for _, have := range s.schedules {
if have.Name == sch.Name && have.Key == sch.Key {
return fmt.Errorf("scheduler: register %s: already registered", sch.label())
}
}
s.schedules = append(s.schedules, sch)
return nil
}
// Start reads the settings and starts the scheduler, which first catches
// up on each schedule's recent periods and then fires schedules as
// their times come. Settings whose zone or rules are invalid are an error
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
// for it.
func (s *Scheduler) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.started {
return errors.New("scheduler: already started")
}
// Subscribing first means a change saved after the read below still
// wakes the loop.
unsubscribe := s.settings.Subscribe(s.changed)
settings, err := s.settings.Get(ctx)
if err != nil {
unsubscribe()
return fmt.Errorf("scheduler: %w", err)
}
p, err := compile(s.schedules, settings)
if err != nil {
unsubscribe()
return err
}
s.started = true
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer unsubscribe()
s.loop(ctx, p)
}()
return nil
}
// Wait blocks until the scheduler has stopped.
func (s *Scheduler) Wait() {
s.wg.Wait()
}
// change is a settings change and the instant it was saved.
type change struct {
at time.Time
settings household.Settings
}
// changed is the settings subscriber. It runs on the updating goroutine, so
// it only records the change and wakes the loop.
func (s *Scheduler) changed(settings household.Settings) {
s.pendingMu.Lock()
s.pending = append(s.pending, change{at: s.now(), settings: settings})
s.pendingMu.Unlock()
s.signal()
}
// Wake makes the scheduler look up its fire times again now rather than at
// its next timer. Call it after committing a change to the data a DailyAt
// rule reads, such as tonight's planned dinner. It never blocks.
func (s *Scheduler) Wake() {
s.signal()
}
// signal leaves one token in wake for the loop.
func (s *Scheduler) signal() {
select {
case s.wake <- struct{}{}:
default: // the loop already has a token and looks again then
}
}
// takeChanges returns and clears the pending changes.
func (s *Scheduler) takeChanges() []change {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
out := s.pending
s.pending = nil
return out
}
// plan is the schedules compiled against one version of the settings.
type plan struct {
loc *time.Location
items []item
}
type item struct {
sch Schedule
rule Rule
}
// compile builds the plan for settings.
func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
loc, err := household.LoadZone(settings.Timezone)
if err != nil {
return nil, fmt.Errorf("scheduler: household zone: %w", err)
}
p := &plan{loc: loc, items: make([]item, len(schedules))}
for i, sch := range schedules {
rule := sch.Rule(settings)
if err := rule.validate(); err != nil {
return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err)
}
p.items[i] = item{sch: sch, rule: rule}
}
return p, nil
}
// loop fires schedules until ctx is cancelled. done is the instant up to
// which fire times have been handled; it starts at the zero time so the
// first pass catches up. retry is the periods whose claim failed, each
// with its own fire time. Each pass first claims retry again; while any
// claim there still fails, the pass stops and is repeated after
// ClaimRetry. Otherwise it applies the pending settings changes in order,
// handling fire times up to a change under the plan it replaces, then
// handles fire times up to now under the current plan, stopping after the
// first stretch in which a claim failed.
func (s *Scheduler) loop(ctx context.Context, p *plan) {
var done time.Time
var pending []change
var retry []target
for {
now := s.now()
pending = append(pending, s.takeChanges()...)
retry = s.retry(ctx, retry, now)
for len(retry) == 0 && len(pending) > 0 {
c := pending[0]
if c.at.After(done) {
if p != nil {
retry = s.fire(ctx, p, done, c.at, now)
}
done = c.at
}
p = s.compileChange(ctx, c.settings)
pending = pending[1:]
}
if len(retry) == 0 {
if p != nil {
retry = s.fire(ctx, p, done, now, now)
}
done = now
}
if ctx.Err() != nil {
return
}
var timeout <-chan time.Time
switch {
case len(retry) > 0:
timeout = s.after(ClaimRetry)
case p != nil:
next, ok, err := p.next(ctx, now)
switch {
case err != nil:
s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String())
timeout = s.after(ClaimRetry)
case ok:
timeout = s.after(min(next.Sub(now), maxWait))
}
}
select {
case <-ctx.Done():
return
case <-timeout:
case <-s.wake:
}
}
}
// compileChange returns the plan for changed settings, or nil when they
// are unusable.
func (s *Scheduler) compileChange(ctx context.Context, settings household.Settings) *plan {
p, err := compile(s.schedules, settings)
if err != nil {
// Settings are validated before they are saved, so this is a
// bug; firing at a guessed time would be worse than not firing
// until the settings are fixed.
s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err)
return nil
}
return p
}
// target is one schedule's periods still to handle, oldest first, each
// named by its fire time and the household-local date of its period. A
// target whose fire times could not be looked up has lookup set instead,
// with the stretch (done, until] to look them up in again.
type target struct {
it item
loc *time.Location
fires []fireTime
lookup bool
done, until time.Time
}
// fire handles, for every schedule, its catch-up fire times after done and
// at or before until; now is the current instant. It returns the
// targets whose lookup or claim failed, to be handled again.
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
var failed []target
for _, it := range p.items {
if ctx.Err() != nil {
return failed
}
if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok {
failed = append(failed, t)
}
}
return failed
}
// fireItem handles, oldest first, it's fire times after done and at or
// before until that passed within CatchUpWindow of now, and the most recent
// of them even when it passed longer ago. It reports false, with the target
// to handle again, when the fire times could not be looked up or a period's
// claim failed.
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
fs, err := fireBetween(ctx, it.rule, loc, done, until)
if err != nil {
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
}
i := 0
for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow {
i++
}
return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now)
}
// handleAll handles the periods of t in order, stopping at the first whose
// claim failed. It reports false, with that period and those after it,
// when one did.
func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) {
for i, f := range t.fires {
if !s.handle(ctx, t.it, f, now) {
t.fires = t.fires[i:]
return t, false
}
}
return target{}, true
}
// retry handles targets again at now and returns those whose lookup or
// claim still failed.
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
var failed []target
for i, t := range targets {
if ctx.Err() != nil {
return append(failed, targets[i:]...)
}
if t.lookup {
if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok {
failed = append(failed, again)
}
continue
}
if again, ok := s.handleAll(ctx, t, now); !ok {
failed = append(failed, again)
}
}
return failed
}
// handle claims the period of it's fire time f and runs it, or records it
// as missed when f is more than CatchUpWindow before now. It reports false
// when the claim failed and the period is still unclaimed.
func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool {
at, date := f.at, f.date
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
if it.rule.NextWeek {
period.Week = household.WeekStart(addDays(date, 7))
} else {
period.Week = household.WeekStart(date)
}
switch {
case it.sch.Key != "":
period.Key = it.sch.Key + ":" + period.Key
case it.sch.PeriodKey != nil:
period.Key = it.sch.PeriodKey(period)
}
log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339))
claimed, err := s.claim(ctx, it.sch.Name, period.Key, now)
if err != nil {
log.ErrorContext(ctx, "scheduler: claim period failed; retrying", "error", err, "retry_in", ClaimRetry.String())
return false
}
if !claimed {
return true
}
if late := now.Sub(at); late > CatchUpWindow {
log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String())
return true
}
if err := it.sch.Run(ctx, period); err != nil {
log.ErrorContext(ctx, "scheduler: run failed", "error", err)
return true
}
log.InfoContext(ctx, "scheduler: ran")
return true
}
// claim inserts schedule_runs(name, key) and reports whether this call
// inserted it.
func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) {
n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{
ID: store.NewID(),
Schedule: name,
PeriodKey: key,
CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(),
})
if err != nil {
return false, err
}
return n == 1, nil
}
// next returns the earliest fire time of any schedule after now, or false
// when there is none.
func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) {
var next time.Time
for _, it := range p.items {
f, ok, err := fireAfter(ctx, it.rule, p.loc, now)
if err != nil {
return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err)
}
if ok && (next.IsZero() || f.at.Before(next)) {
next = f.at
}
}
return next, !next.IsZero(), nil
}
// window is how many days either side of a date fireBetween and fireAfter
// search: more than a week, so a weekly rule always has a fire time on
// each side.
const window = 8
// atWindow is how many days either side of a date fireBetween and fireAfter
// look up a DailyAt rule's fire times. A period firing further off is
// looked up on a later pass, which comes within maxWait.
const atWindow = 2
// fireTime is a fire time and the household-local date of its period.
type fireTime struct {
at time.Time
date store.LocalDate
}
// fireBetween returns the fire times of r in zone loc after from and at or
// before t, oldest first.
func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) {
fs, err := fireTimes(ctx, r, loc, t)
if err != nil {
return nil, err
}
var out []fireTime
for _, f := range fs {
if f.at.After(from) && !f.at.After(t) {
out = append(out, f)
}
}
slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) })
return out, nil
}
// fireAfter returns the earliest fire time of r in zone loc after t.
func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
fs, err := fireTimes(ctx, r, loc, t)
if err != nil {
return fireTime{}, false, err
}
var best fireTime
for _, f := range fs {
if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) {
best = f
}
}
return best, !best.at.IsZero(), nil
}
// fireTimes returns r's fire times for the local dates within window days
// of t's, or atWindow days for a DailyAt rule. A Time that fails to
// resolve was validated at compile, so it cannot; a DailyAt lookup that
// fails is an error.
func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) {
today := household.Today(t, loc)
span := window
if r.At != nil {
span = atWindow
}
var out []fireTime
for i := -span; i <= span; i++ {
d := addDays(today, i)
if r.Weekly && weekday(d) != r.Weekday {
continue
}
if r.At != nil {
at, ok, err := r.At(ctx, d)
if err != nil {
return nil, fmt.Errorf("fire time of %s: %w", d, err)
}
if ok {
out = append(out, fireTime{at: at, date: d})
}
continue
}
at, err := household.LocalTime(loc, d, r.Time)
if err != nil {
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
}
out = append(out, fireTime{at: at, date: d})
}
return out, nil
}
// addDays returns d plus n calendar days.
func addDays(d store.LocalDate, n int) store.LocalDate {
y, m, day := d.Date()
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
}
// weekday returns the day of the week of d.
func weekday(d store.LocalDate) time.Weekday {
y, m, day := d.Date()
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday()
}
+805
View File
@@ -0,0 +1,805 @@
package scheduler_test
import (
"context"
"errors"
"io"
"log/slog"
"slices"
"sync"
"sync/atomic"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const zone = "America/Chicago"
// chicago is the household zone of every test.
var chicago = mustZone(zone)
func mustZone(name string) *time.Location {
loc, err := household.LoadZone(name)
if err != nil {
panic(err)
}
return loc
}
// at returns the instant clocks in Chicago read the given time.
func at(year int, month time.Month, day, hour, minute int) time.Time {
return time.Date(year, month, day, hour, minute, 0, 0, chicago)
}
// fakeClock is a clock whose time moves only when the test moves it.
type fakeClock struct {
mu sync.Mutex
now time.Time
timers []fakeTimer
// waits receives one value per After call, so the test knows the
// scheduler finished a pass and is waiting.
waits chan struct{}
}
type fakeTimer struct {
deadline time.Time
ch chan time.Time
}
func newFakeClock(now time.Time) *fakeClock {
return &fakeClock{now: now, waits: make(chan struct{}, 100)}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
// After returns a timer that fires d from now. The scheduler waits on only
// its latest timer, so this one replaces any still pending: a timer the
// scheduler gave up on when a wake came first never fires.
func (c *fakeClock) After(d time.Duration) <-chan time.Time {
c.mu.Lock()
ch := make(chan time.Time, 1)
c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}}
c.mu.Unlock()
c.waits <- struct{}{}
return ch
}
// set moves the clock to t and fires the timers due by then, reporting
// whether any fired.
func (c *fakeClock) set(t time.Time) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.now = t
fired := false
kept := c.timers[:0]
for _, tm := range c.timers {
if tm.deadline.After(t) {
kept = append(kept, tm)
continue
}
tm.ch <- t
fired = true
}
c.timers = kept
return fired
}
// nextDeadline returns the earliest pending timer deadline.
func (c *fakeClock) nextDeadline() (time.Time, bool) {
c.mu.Lock()
defer c.mu.Unlock()
var next time.Time
for _, tm := range c.timers {
if next.IsZero() || tm.deadline.Before(next) {
next = tm.deadline
}
}
return next, !next.IsZero()
}
// recorder records the periods a schedule ran.
type recorder struct {
mu sync.Mutex
runs []scheduler.Period
err error
}
func (r *recorder) run(_ context.Context, p scheduler.Period) error {
r.mu.Lock()
defer r.mu.Unlock()
r.runs = append(r.runs, p)
return r.err
}
func (r *recorder) periods() []scheduler.Period {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.runs)
}
func (r *recorder) keys() []string {
var keys []string
for _, p := range r.periods() {
keys = append(keys, p.Key)
}
return keys
}
// env is a migrated database with household settings in Chicago.
type env struct {
t *testing.T
st *store.Store
settings *household.SettingsService
// sched is the scheduler start last started, which wake wakes.
sched atomic.Pointer[scheduler.Scheduler]
}
// wake wakes the scheduler start last started, as the services' wake does
// in the server; before the first start it does nothing.
func (e *env) wake() {
if s := e.sched.Load(); s != nil {
s.Wake()
}
}
func newEnv(t *testing.T) *env {
t.Helper()
ctx := context.Background()
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, zone); err != nil {
t.Fatal(err)
}
return &env{t: t, st: st, settings: household.NewSettingsService(st)}
}
// update changes the household settings.
func (e *env) update(patch household.SettingsPatch) {
e.t.Helper()
if _, err := e.settings.Update(context.Background(), patch, "member"); err != nil {
e.t.Fatal(err)
}
}
// claimed returns the period keys schedule_runs holds for name.
func (e *env) claimed(name string) []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT period_key FROM schedule_runs WHERE schedule = ? ORDER BY period_key`, name)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
e.t.Fatal(err)
}
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return keys
}
// running is a started scheduler on a fake clock.
type running struct {
t *testing.T
clock *fakeClock
stop func()
}
// start starts a scheduler on e at now with the schedules, and stops it
// when the test ends. It returns once the first pass, the catch-up, is done.
func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running {
e.t.Helper()
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
e.t.Fatal(err)
}
clock := newFakeClock(now)
scheduler.SetClock(s, clock.Now, clock.After)
for _, sch := range schedules {
if err := s.Register(sch); err != nil {
e.t.Fatal(err)
}
}
ctx, cancel := context.WithCancel(context.Background())
if err := s.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
e.sched.Store(s)
var once sync.Once
stop := func() {
once.Do(func() {
cancel()
s.Wait()
})
}
e.t.Cleanup(stop)
r := &running{t: e.t, clock: clock, stop: stop}
r.idle()
return r
}
// idle waits until the scheduler finished a pass and is waiting.
func (r *running) idle() {
r.t.Helper()
select {
case <-r.clock.waits:
case <-time.After(10 * time.Second):
r.t.Fatal("scheduler did not finish its pass")
}
}
// runUntil moves the clock to to, stopping at every instant the scheduler
// asked to wake at on the way, as a real clock would.
func (r *running) runUntil(to time.Time) {
r.t.Helper()
for {
next, ok := r.clock.nextDeadline()
if !ok || next.After(to) {
if r.clock.set(to) {
r.idle()
}
return
}
if r.clock.set(next) {
r.idle()
}
}
}
// changed waits for the scheduler to apply a settings change, or to look
// again after a wake.
func (r *running) changed() {
r.t.Helper()
r.idle()
}
func reminder(rec *recorder) scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.DayOfReminder,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReminderTime) },
Run: rec.run,
}
}
func suggestions(rec *recorder) scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.SuggestionsWeekly,
Rule: func(s household.Settings) scheduler.Rule {
return scheduler.Weekly(time.Weekday(s.SuggestionsDow), s.SuggestionsTime)
},
Run: rec.run,
}
}
func planDraft(rec *recorder) scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.PlanDraft,
Rule: func(s household.Settings) scheduler.Rule {
return scheduler.WeeklyForNextWeek(time.Weekday(s.PlanDraftDow), s.PlanDraftTime)
},
Run: rec.run,
}
}
func ptr[T any](v T) *T { return &v }
func wantKeys(t *testing.T, what string, got, want []string) {
t.Helper()
if !slices.Equal(got, want) {
t.Fatalf("%s = %q, want %q", what, got, want)
}
}
func TestFiresOncePerPeriod(t *testing.T) {
e := newEnv(t)
var rec recorder
// Seeded reminder time is 16:30; boot just after it on Oct 6 runs it.
r := e.start(at(2026, 10, 6, 16, 31), reminder(&rec))
wantKeys(t, "after boot", rec.keys(), []string{"2026-10-06"})
r.runUntil(at(2026, 10, 7, 16, 29))
wantKeys(t, "before the fire time", rec.keys(), []string{"2026-10-06"})
r.runUntil(at(2026, 10, 9, 23, 59))
wantKeys(t, "after three days", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"})
for i, p := range rec.periods()[1:] {
want := at(2026, 10, 7+i, 16, 30)
if !p.FireAt.Equal(want) {
t.Errorf("run %s fired at %s, want %s", p.Key, p.FireAt, want)
}
if p.FireAt.Location().String() != zone {
t.Errorf("run %s fired in zone %s, want %s", p.Key, p.FireAt.Location(), zone)
}
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
}
func TestRestartMidPeriodDoesNotRerun(t *testing.T) {
e := newEnv(t)
var first recorder
r := e.start(at(2026, 10, 7, 12, 0), suggestions(&first)) // Wednesday
// The seeded Sunday 08:00 of Oct 4 is days ago: missed, not run.
wantKeys(t, "first boot", first.keys(), nil)
r.runUntil(at(2026, 10, 11, 9, 0))
wantKeys(t, "first process", first.keys(), []string{"2026-W41"})
r.stop()
// The restart is an hour after the Sunday 08:00 fire, mid-period.
var second recorder
r2 := e.start(at(2026, 10, 11, 9, 0), suggestions(&second))
wantKeys(t, "after restart", second.keys(), nil)
r2.runUntil(at(2026, 10, 18, 7, 59))
wantKeys(t, "rest of the period", second.keys(), nil)
r2.runUntil(at(2026, 10, 18, 8, 0))
wantKeys(t, "next period", second.keys(), []string{"2026-W42"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
}
func TestCatchUpWithin24Hours(t *testing.T) {
e := newEnv(t)
var rec recorder
// Sunday Oct 11 08:00 passed 23h59m before boot.
e.start(at(2026, 10, 12, 7, 59), suggestions(&rec))
wantKeys(t, "catch-up", rec.keys(), []string{"2026-W41"})
if p := rec.periods()[0]; !p.FireAt.Equal(at(2026, 10, 11, 8, 0)) {
t.Errorf("caught-up period fired at %s, want its fire time", p.FireAt)
}
}
func TestNoCatchUpBeyond24Hours(t *testing.T) {
e := newEnv(t)
var rec recorder
// Sunday Oct 11 08:00 passed 24h01m before boot.
r := e.start(at(2026, 10, 12, 8, 1), suggestions(&rec))
wantKeys(t, "boot", rec.keys(), nil)
// The missed period is recorded, so it never runs later.
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W41"})
// Moving the fire time to Sunday 23:00 puts that week's fire time
// within 24 h of now. The change itself never fires a passed time...
e.update(household.SettingsPatch{SuggestionsTime: ptr("23:00")})
r.changed()
wantKeys(t, "after a settings change", rec.keys(), nil)
r.stop()
// ...and the restart's catch-up finds the period already recorded.
var again recorder
r2 := e.start(at(2026, 10, 12, 8, 2), suggestions(&again))
wantKeys(t, "after restart", again.keys(), nil)
r2.runUntil(at(2026, 10, 18, 23, 0))
wantKeys(t, "next period", again.keys(), []string{"2026-W42"})
}
func TestCatchUpOnlyMostRecentPeriod(t *testing.T) {
e := newEnv(t)
var rec recorder
// Days of downtime: only Oct 9 16:30, 30 minutes ago, runs.
e.start(at(2026, 10, 9, 17, 0), reminder(&rec))
wantKeys(t, "catch-up", rec.keys(), []string{"2026-10-09"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"})
}
func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) {
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08,
// so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart.
e := newEnv(t)
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
var rec recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: rec.run,
}
// Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8
// 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m
// ago, is not looked at.
r := e.start(at(2026, 3, 9, 1, 35), review)
wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"})
wantFired(t, rec.periods(),
time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST
time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT
)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"})
r.stop()
// A restart within the window finds both periods claimed.
var again recorder
review.Run = again.run
r2 := e.start(at(2026, 3, 9, 1, 40), review)
r2.runUntil(at(2026, 3, 9, 23, 0))
wantKeys(t, "after restart", again.keys(), nil)
}
func TestSpringForward(t *testing.T) {
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08.
e := newEnv(t)
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("02:30")})
var daily, skipped recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: skipped.run,
}
r := e.start(at(2026, 3, 7, 9, 0), reminder(&daily), review)
r.runUntil(at(2026, 3, 9, 12, 0))
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
wantFired(t, daily.periods(),
time.Date(2026, 3, 7, 14, 0, 0, 0, time.UTC), // 08:00 CST
time.Date(2026, 3, 8, 13, 0, 0, 0, time.UTC), // 08:00 CDT
time.Date(2026, 3, 9, 13, 0, 0, 0, time.UTC),
)
// 02:30 does not exist on Mar 8: it fires once, an hour later at
// 03:30 CDT, keyed by Mar 8.
wantKeys(t, "02:30 runs", skipped.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
wantFired(t, skipped.periods(),
time.Date(2026, 3, 7, 8, 30, 0, 0, time.UTC), // 02:30 CST
time.Date(2026, 3, 8, 8, 30, 0, 0, time.UTC), // 03:30 CDT
time.Date(2026, 3, 9, 7, 30, 0, 0, time.UTC), // 02:30 CDT
)
if got := skipped.periods()[1].FireAt.Format("15:04 MST"); got != "03:30 CDT" {
t.Errorf("skipped 02:30 fired at %s local, want 03:30 CDT", got)
}
}
func TestFallBack(t *testing.T) {
// Clocks in Chicago go back 02:00 CDT → 01:00 CST on Sunday 2026-11-01.
e := newEnv(t)
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("01:30")})
var daily, repeated recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: repeated.run,
}
r := e.start(at(2026, 10, 31, 9, 0), reminder(&daily), review)
r.runUntil(at(2026, 11, 2, 12, 0))
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
wantFired(t, daily.periods(),
time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC), // 08:00 CDT
time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), // 08:00 CST
time.Date(2026, 11, 2, 14, 0, 0, 0, time.UTC),
)
// 01:30 happens twice on Nov 1: it fires once, at the first (CDT).
wantKeys(t, "01:30 runs", repeated.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
wantFired(t, repeated.periods(),
time.Date(2026, 10, 31, 6, 30, 0, 0, time.UTC), // 01:30 CDT
time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // first 01:30, CDT
time.Date(2026, 11, 2, 7, 30, 0, 0, time.UTC), // 01:30 CST
)
}
func TestFallBackSecondOccurrenceDoesNotRerun(t *testing.T) {
e := newEnv(t)
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
var rec recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: rec.run,
}
// Boot between the two 01:30s of Nov 1: the first ran in an earlier
// process, so the second is not a new period.
first := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)
r := e.start(first.Add(-time.Minute), review)
r.runUntil(first)
wantKeys(t, "first 01:30", rec.keys(), []string{"2026-10-31", "2026-11-01"})
r.stop()
var again recorder
review.Run = again.run
r2 := e.start(first.Add(30*time.Minute), review)
r2.runUntil(first.Add(2 * time.Hour)) // past the second 01:30, CST
wantKeys(t, "second 01:30", again.keys(), nil)
}
func wantFired(t *testing.T, got []scheduler.Period, want ...time.Time) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%d runs, want %d", len(got), len(want))
}
for i := range want {
if !got[i].FireAt.Equal(want[i]) {
t.Errorf("run %s fired at %s (%s), want %s", got[i].Key, got[i].FireAt.UTC(), got[i].FireAt, want[i])
}
}
}
func TestISOWeek53Boundary(t *testing.T) {
e := newEnv(t)
var sugg, draft recorder
// Suggestions Sunday 08:00 keys the ISO week ending that Sunday; the
// plan draft Friday 09:00 keys the week after.
r := e.start(at(2026, 12, 20, 9, 0), suggestions(&sugg), planDraft(&draft))
r.runUntil(at(2027, 1, 11, 0, 0))
wantKeys(t, "suggestions", sugg.keys(), []string{"2026-W51", "2026-W52", "2026-W53", "2027-W01"})
// Sunday 2027-01-03 is the last day of 2026-W53.
if p := sugg.periods()[2]; p.Date.String() != "2027-01-03" || p.Week.String() != "2026-12-28" {
t.Errorf("2026-W53 suggestions ran on %s for week %s, want 2027-01-03 for 2026-12-28", p.Date, p.Week)
}
// Friday Dec 18, two days before boot, was missed.
wantKeys(t, "plan drafts", draft.keys(), []string{"2026-W53", "2027-W01", "2027-W02"})
// Friday 2026-12-25 drafts 2026-W53; Friday 2027-01-01 drafts 2027-W01.
if p := draft.periods()[0]; p.Date.String() != "2026-12-25" || p.Week.String() != "2026-12-28" {
t.Errorf("2026-W53 draft ran on %s for week %s, want 2026-12-25 for 2026-12-28", p.Date, p.Week)
}
if p := draft.periods()[1]; p.Date.String() != "2027-01-01" || p.Week.String() != "2027-01-04" {
t.Errorf("2027-W01 draft ran on %s for week %s, want 2027-01-01 for 2027-01-04", p.Date, p.Week)
}
}
func TestPeriodKey(t *testing.T) {
date := func(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
t.Fatal(err)
}
return d
}
tests := []struct {
rule scheduler.Rule
date string
want string
}{
{scheduler.Daily("16:30"), "2026-12-31", "2026-12-31"},
{scheduler.Weekly(time.Monday, "00:00"), "2026-12-28", "2026-W53"},
{scheduler.Weekly(time.Sunday, "08:00"), "2027-01-03", "2026-W53"},
{scheduler.Weekly(time.Monday, "00:00"), "2027-01-04", "2027-W01"},
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2026-12-25", "2026-W53"},
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2027-01-01", "2027-W01"},
}
for _, tt := range tests {
if got := tt.rule.PeriodKey(date(tt.date)); got != tt.want {
t.Errorf("%+v.PeriodKey(%s) = %s, want %s", tt.rule, tt.date, got, tt.want)
}
}
}
func TestSettingsChangeMovesNextFire(t *testing.T) {
e := newEnv(t)
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
// Earlier but still ahead: fires at the new time, not the old.
e.update(household.SettingsPatch{ReminderTime: ptr("13:00")})
r.changed()
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 13, 0)) {
t.Errorf("next wake %s, want the new 13:00", next)
}
r.runUntil(at(2026, 10, 7, 13, 0))
wantKeys(t, "at the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "at the old time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
// Moved to a time already passed today: nothing fires now, and
// tomorrow fires at the new time.
r.runUntil(at(2026, 10, 8, 12, 0))
e.update(household.SettingsPatch{ReminderTime: ptr("11:00")})
r.changed()
wantKeys(t, "moved into the past", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 9, 10, 59))
wantKeys(t, "before the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 9, 11, 0))
wantKeys(t, "the new time", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-09"})
}
// A change saved at 12:00 moving the 16:30 reminder to 12:01 must fire it
// even when the scheduler only gets to the change at 12:02, after the new
// fire time has passed.
func TestSettingsChangeAppliedLateStillFires(t *testing.T) {
e := newEnv(t)
var rec recorder
// blocker holds the scheduler in a Run at 12:00 on 2026-10-07 while
// the settings change and the clock moves on.
started := make(chan struct{})
release := make(chan struct{})
blocker := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(household.Settings) scheduler.Rule { return scheduler.Daily("12:00") },
Run: func(_ context.Context, p scheduler.Period) error {
if p.Key == "2026-10-07" {
close(started)
<-release
}
return nil
},
}
r := e.start(at(2026, 10, 7, 11, 0), reminder(&rec), blocker)
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
r.clock.set(at(2026, 10, 7, 12, 0))
<-started
e.update(household.SettingsPatch{ReminderTime: ptr("12:01")})
r.clock.set(at(2026, 10, 7, 12, 2))
close(release)
r.idle() // the pass the blocker held
r.idle() // the pass that applies the change
wantKeys(t, "after the late change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
}
// failClaims makes every claim fail until recover is called.
func (e *env) failClaims() (recover func()) {
e.t.Helper()
for _, q := range []string{
`CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`,
`INSERT INTO fail_claims VALUES (1)`,
`CREATE TRIGGER fail_claim BEFORE INSERT ON schedule_runs
WHEN (SELECT on_ FROM fail_claims) = 1
BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`,
} {
if _, err := e.st.Writer.Exec(q); err != nil {
e.t.Fatal(err)
}
}
return func() {
e.t.Helper()
if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil {
e.t.Fatal(err)
}
}
}
// A claim that fails is retried, not skipped, and the period then runs once.
func TestClaimFailureIsRetried(t *testing.T) {
e := newEnv(t)
recoverClaims := e.failClaims()
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
wantKeys(t, "boot with failing claims", rec.keys(), nil)
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) {
t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry)
}
recoverClaims()
r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry))
wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"})
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
}
// A claim that keeps failing past the next daily fire is retried for its
// own period, not the most recent one: that period, now more than
// CatchUpWindow old, is recorded as missed, and the later period runs once.
func TestClaimFailureRecoversAfterNextFire(t *testing.T) {
e := newEnv(t)
recoverClaims := e.failClaims()
var rec recorder
// The 7 October 16:30 reminder is due and its claim fails, past the
// 8 October 16:30 fire.
r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec))
r.runUntil(at(2026, 10, 8, 17, 0))
wantKeys(t, "runs while failing", rec.keys(), nil)
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
// Claims recover 24h31m after the 7 October fire.
recoverClaims()
r.runUntil(at(2026, 10, 8, 17, 1))
wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"})
wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"})
r.runUntil(at(2026, 10, 9, 16, 0))
wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"})
}
func TestSettingsChangeTimezone(t *testing.T) {
e := newEnv(t)
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
e.update(household.SettingsPatch{Timezone: ptr("America/New_York")})
r.changed()
// 16:30 in New York is 15:30 in Chicago.
r.runUntil(at(2026, 10, 7, 15, 30))
wantKeys(t, "after the zone change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
if got := rec.periods()[1].FireAt.Location().String(); got != "America/New_York" {
t.Errorf("fired in zone %s, want America/New_York", got)
}
}
func TestRunErrorKeepsPeriodClaimed(t *testing.T) {
e := newEnv(t)
rec := recorder{err: errors.New("boom")}
r := e.start(at(2026, 10, 7, 16, 0), reminder(&rec))
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "runs", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.stop()
var again recorder
e.start(at(2026, 10, 7, 23, 0), reminder(&again))
wantKeys(t, "after restart", again.keys(), nil)
}
func TestRegister(t *testing.T) {
e := newEnv(t)
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
var rec recorder
bad := []scheduler.Schedule{
{Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run},
{Name: scheduler.DayOfReminder, Run: rec.run},
{Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule},
{Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run},
}
for _, sch := range bad {
if err := s.Register(sch); err == nil {
t.Errorf("Register(%q) accepted an invalid schedule", sch.Name)
}
}
if err := s.Register(reminder(&rec)); err != nil {
t.Fatal(err)
}
if err := s.Register(reminder(&rec)); err == nil {
t.Error("Register accepted a second schedule with the same name")
}
}
func TestStartRejectsInvalidRule(t *testing.T) {
e := newEnv(t)
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
var rec recorder
if err := s.Register(scheduler.Schedule{
Name: scheduler.WeekRollover,
Rule: func(household.Settings) scheduler.Rule { return scheduler.Weekly(7, "00:00") },
Run: rec.run,
}); err != nil {
t.Fatal(err)
}
if err := s.Start(context.Background()); err == nil {
s.Wait()
t.Fatal("Start accepted weekday 7")
}
}
func TestStopEndsTheLoop(t *testing.T) {
e := newEnv(t)
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
done := make(chan struct{})
go func() {
r.stop()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("scheduler did not stop")
}
}
+370
View File
@@ -0,0 +1,370 @@
package scheduler_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"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/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/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// The tests in this file run suggest's suggestions_weekly schedule on the
// fake clock against the real suggestion service, job runner, harness and
// notification outbox, with the AI and recipe sources faked.
// suggestEnv is a suggestion service over a scheduler env's store, the
// "process" a scheduler runs it in. side answers suggest_side_quests and
// model normalize_recipe while grounding.
type suggestEnv struct {
e *env
svc *suggest.Service
side *llm.Fake
model *llm.Fake
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
logs *syncBuffer
stop func()
}
// syncBuffer collects log output written by concurrent goroutines.
type syncBuffer struct {
mu sync.Mutex
buf strings.Builder
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch
// has Regulars to suggest from.
func (e *env) regulars() {
e.t.Helper()
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
now := store.Now().String()
if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
e.t.Fatal(err)
}
}
}
// suggestions builds a suggestion service over e with a started job
// runner, which stop stops (and the test's end, if not before).
func (e *env) suggestions() *suggestEnv {
e.t.Helper()
s := &suggestEnv{
e: e,
side: &llm.Fake{},
model: &llm.Fake{},
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
logs: &syncBuffer{},
}
g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
e.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 s.side, nil },
Store: e.st,
Guard: guard,
}
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SuggestSideQuests(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(s.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger)
if err != nil {
e.t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
var once sync.Once
s.stop = func() {
once.Do(func() {
cancel()
runner.Wait()
})
}
e.t.Cleanup(s.stop)
return s
}
// week scripts the next batch: suggest_side_quests answers with the
// Side quests named names, each grounded by its own recipe.
func (s *suggestEnv) week(names ...string) {
s.e.t.Helper()
var quests []string
for _, name := range names {
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
s.search.Results[name] = []recipes.Candidate{{URL: page}}
s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1))
s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name))
}
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// batch is a suggestion_batches row.
type batch struct {
ID, Trigger, Status string
}
// batches returns every suggestion batch, oldest first.
func (e *env) batches() []batch {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []batch
for rows.Next() {
var b batch
if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil {
e.t.Fatal(err)
}
out = append(out, b)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return out
}
// settled waits until no batch is pending or running and returns the
// batches.
func (e *env) settled() []batch {
e.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
bs := e.batches()
busy := false
for _, b := range bs {
busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning
}
if !busy {
return bs
}
if time.Now().After(deadline) {
e.t.Fatalf("batches still under way: %+v", bs)
}
time.Sleep(5 * time.Millisecond)
}
}
// wantBatches fails unless the batches are scheduled with statuses want.
func wantBatches(t *testing.T, what string, got []batch, want ...string) {
t.Helper()
ok := len(got) == len(want)
for i := 0; ok && i < len(got); i++ {
ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i]
}
if !ok {
t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want)
}
}
// notified returns the new_suggestions keys stored, oldest first, and
// reports an error for any whose title is not the one titles gives it.
func (e *env) notified(titles ...string) []string {
e.t.Helper()
var keys []string
rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions)
for i, r := range rows {
keys = append(keys, r.Key)
if i < len(titles) && r.Payload.Title != titles[i] {
e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i])
}
}
return keys
}
// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded
// Sunday 08:00 the schedule starts one scheduled batch a week, period
// suggestions_weekly:YYYY-Www, and each tells the household once, with
// new_suggestions:YYYY-Www counting its new Side quests.
func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas")
// Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed.
r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 7, 59))
wantBatches(t, "before Sunday 08:00", e.settled())
wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil)
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone)
r.runUntil(at(2026, 10, 18, 9, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"),
[]string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
}
// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the
// household's suggestions_dow and suggestions_time.
func TestSuggestionsWeeklyFollowsSettings(t *testing.T) {
e := newEnv(t)
e.regulars()
e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")})
s := e.suggestions()
s.week("Chili Verde")
r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
wantBatches(t, "before Wednesday 18:30", e.settled())
r.runUntil(at(2026, 10, 14, 18, 30))
wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests
// fails the batch fails with the failure logged and recorded in ai_runs,
// and the household is not notified.
func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed)
wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil)
// W40 is the boot's missed period, recorded without running.
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
var status, msg string
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil {
t.Fatalf("ai_runs row of suggest_side_quests: %v", err)
}
if status != string(harness.StatusProviderError) || msg == "" {
t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
}
if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) {
t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed)
}
}
// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's
// batch ran, still within the catch-up window, starts no second batch and
// sends no second notification: the period is already claimed.
func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "first process", e.settled(), suggest.BatchDone)
r.stop()
s.stop()
// The restarted process has a new job runner, suggestion service and
// scheduler; it would answer a second batch if one were started.
s2 := e.suggestions()
s2.week("Lamb Kofta")
r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule())
r2.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "after restart", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
}
// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on
// Monday, in the next ISO week, still runs W41's batch keyed by W41, and
// the next Sunday's batch notifies for W42.
func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde")
s.week("Lamb Kofta")
r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late
wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 8, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that
// fires while a batch is pending starts no second one and says so.
func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.stop() // the manual batch stays pending
manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID {
t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID)
}
if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") {
t.Errorf("logs = %s, want the skipped period logged", logs)
}
}
@@ -0,0 +1,421 @@
package scheduler_test
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"log/slog"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run shopping's shopping_chat_autosend and
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
// against the real shopping service, Google Chat client (posting to a fake
// webhook), notification preferences and ai_runs purge. The seeded
// autosend is Saturday 08:00 in Chicago.
// w41 is the Monday of 2026-W41.
var w41 = mustLocalDate("2026-10-05")
// shopEnv is a shopping service over a scheduler env's store. Its job
// runner never starts, so Generate's title jobs stay queued and every
// Supply run stays untitled until Freeze titles it, which fails.
type shopEnv struct {
e *env
svc *shopping.Service
chat *chattest.Chat
cs *chat.Service
plans *planEnv
logs *syncBuffer
}
// shopping builds a shopping service over e, with Regulars to plan from.
func (e *env) shopping() *shopEnv {
e.t.Helper()
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
e.t.Fatal(err)
}
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
if err != nil {
e.t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(s.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no AI in this test")
}
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
if err != nil {
e.t.Fatal(err)
}
return s
}
// list plans the week starting weekStart and generates its Supply run.
func (s *shopEnv) list(weekStart store.LocalDate) {
s.e.t.Helper()
s.plans.planWeek(weekStart)
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
s.e.t.Fatal(err)
}
}
// webhook stores the fake Google Chat webhook.
func (s *shopEnv) webhook() {
s.e.t.Helper()
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
s.e.t.Fatal(err)
}
}
// get returns the Supply run of the week starting weekStart.
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
s.e.t.Helper()
l, err := s.svc.Get(context.Background(), weekStart)
if err != nil {
s.e.t.Fatal(err)
}
return l
}
// wantLogged fails unless the service logged want and logged no error.
func (s *shopEnv) wantLogged(want string) {
s.e.t.Helper()
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
}
}
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
// schedule posts the coming week's Supply run, W43's, to Google Chat
// through SendToChat, in that week's thread, and records sent_to_chat_at;
// its period is claimed in schedule_runs as list_ready:chat:2026-10-19,
// the coming week's start. It posts once.
func TestChatAutosendSendsComingWeek(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
// Saturday 07:00: the previous Saturday's period is past the
// catch-up window, so it is recorded as missed.
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
r.runUntil(at(2026, 10, 17, 7, 59))
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
}
r.runUntil(at(2026, 10, 17, 8, 0))
posts := s.chat.Posts()
if len(posts) != 1 {
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
}
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
}
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
}
if s.get(w43).SentToChatAt == nil {
t.Error("sent_to_chat_at not recorded")
}
s.wantLogged("Supply run sent to Google Chat")
s.wantLogged("period=list_ready:chat:2026-10-19")
r.runUntil(at(2026, 10, 24, 7, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("a week on: %d posts, want 1", n)
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
}
// TestChatAutosendFollowsSettings: chat_autosend_dow and
// chat_autosend_time move the send without a restart, here to Friday
// 18:00, still for the coming week.
func TestChatAutosendFollowsSettings(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
r.changed()
r.runUntil(at(2026, 10, 16, 17, 59))
if n := len(s.chat.Posts()); n != 0 {
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
}
r.runUntil(at(2026, 10, 16, 18, 0))
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
}
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("Saturday: %d posts, want still 1", n)
}
}
// TestChatAutosendSkips: the schedule posts nothing, records no send and
// fails nothing when the coming week's Supply run was already sent, when
// the week has no Supply run, when list_ready is switched off for Google
// Chat, and when no webhook is stored. Each period is still claimed and
// the reason logged.
func TestChatAutosendSkips(t *testing.T) {
cases := []struct {
name string
setup func(t *testing.T, s *shopEnv)
// posts is how many posts the setup itself made.
posts int
reason string
}{
{
name: "already sent",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
s.webhook()
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
},
posts: 1,
reason: shopping.AutosendAlreadySent,
},
{
name: "no list",
setup: func(t *testing.T, s *shopEnv) {
s.list(w42)
s.webhook()
},
reason: shopping.AutosendNoList,
},
{
name: "chat off for list_ready",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
s.webhook()
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
t.Fatal(err)
}
},
reason: shopping.AutosendChatOff,
},
{
name: "no webhook",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
},
reason: shopping.AutosendNotConfigured,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
s := e.shopping()
c.setup(t, s)
var sentBefore *string
if l, err := s.svc.Get(context.Background(), w43); err == nil {
sentBefore = l.SentToChatAt
}
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != c.posts {
t.Errorf("posts = %d, want %d", n, c.posts)
}
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
}
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
})
}
}
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
// period's error, logged as such; the period stays claimed and is not
// retried, and sent_to_chat_at is not recorded.
func TestChatAutosendFailureSurfaces(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
s.chat.SetStatus(500)
var logs syncBuffer
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
if err != nil {
t.Fatal(err)
}
clock := newFakeClock(at(2026, 10, 17, 7, 0))
scheduler.SetClock(sched, clock.Now, clock.After)
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
sched.Wait()
})
if err := sched.Start(ctx); err != nil {
t.Fatal(err)
}
r := &running{t: t, clock: clock, stop: cancel}
r.idle()
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("posts = %d, want the one refused", n)
}
if s.get(w43).SentToChatAt != nil {
t.Error("sent_to_chat_at recorded for a refused send")
}
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
}
func equalPtr(a, b *string) bool {
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
}
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
// freezes exactly the week that has just ended into the Supply archive,
// not the week before it nor the new week; the next Monday freezes the
// next week; a Monday whose previous week has no Supply run freezes
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
// Monday.
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w41)
s.list(w42)
s.list(w43)
status := func() []string {
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
}
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
// Sunday 23:00: last Monday's rollover is past the catch-up window and
// recorded as missed, so W41 is never frozen by it.
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
r.runUntil(at(2026, 10, 19, 0, 4))
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
r.runUntil(at(2026, 10, 19, 0, 5))
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
if s.get(w42).Title != nil {
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
}
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
}
r.runUntil(at(2026, 10, 26, 0, 5))
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
r.runUntil(at(2026, 11, 2, 0, 5))
s.wantLogged("week rollover froze nothing: the week has no Supply run")
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
}
// aiRun stores an ai_runs row created at created and returns its id.
func (e *env) aiRun(created time.Time) string {
e.t.Helper()
id := store.NewID()
ts := store.Timestamp{Time: created.UTC()}.String()
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
CreatedAt: ts, UpdatedAt: ts,
}); err != nil {
e.t.Fatal(err)
}
return id
}
// aiRuns returns the ids of the ai_runs rows, oldest first.
func (e *env) aiRuns() []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
e.t.Fatal(err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return ids
}
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
// ai_runs rows created more than 30 days before, and no younger row; its
// periods are ai_runs_purge:YYYY-MM-DD.
func TestAIRunsPurgeDaily(t *testing.T) {
e := newEnv(t)
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
edge := e.aiRun(at(2026, 9, 12, 2, 59))
young := e.aiRun(at(2026, 9, 12, 3, 1))
recent := e.aiRun(at(2026, 10, 11, 12, 0))
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
if err != nil {
t.Fatal(err)
}
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
r := e.start(at(2026, 10, 12, 2, 0), sch)
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
r.runUntil(at(2026, 10, 12, 2, 59))
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
r.runUntil(at(2026, 10, 12, 3, 0))
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
r.runUntil(at(2026, 10, 13, 3, 0))
wantKeys(t, "next day", e.aiRuns(), []string{recent})
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
}
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
// logger.
func TestPurgeScheduleRejectsNil(t *testing.T) {
e := newEnv(t)
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
t.Error("nil store accepted")
}
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
t.Error("nil logger accepted")
}
}