CI / ci (push) Successful in 8m46s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
422 lines
14 KiB
Go
422 lines
14 KiB
Go
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")
|
||
}
|
||
}
|