package bot import ( "strings" "testing" "time" ) func TestScheduleSpecValidate(t *testing.T) { t.Setenv("LCHAT_BOT_MIN_EVERY", "1s") // tests watch bots fire in seconds ok := []ScheduleSpec{ {At: "08:00"}, {At: "23:59", TZ: "Europe/Dublin"}, {Every: "30m"}, {Every: "1s"}, } for _, s := range ok { s := s if err := s.validate(); err != nil { t.Errorf("%+v rejected: %v", s, err) } } bad := []struct { spec ScheduleSpec want string }{ {ScheduleSpec{}, "exactly one"}, {ScheduleSpec{At: "08:00", Every: "1h"}, "exactly one"}, {ScheduleSpec{At: "25:00"}, "bad at"}, {ScheduleSpec{At: "8am"}, "bad at"}, {ScheduleSpec{At: "08:00", TZ: "Mars/Olympus"}, "bad tz"}, {ScheduleSpec{Every: "0s"}, "bad every"}, {ScheduleSpec{Every: "soon"}, "bad every"}, {ScheduleSpec{Every: "500ms"}, "bad every"}, {ScheduleSpec{Every: "1h", TZ: "Europe/Dublin"}, "tz belongs"}, } for _, tc := range bad { tc := tc err := tc.spec.validate() if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%+v: want error containing %q, got %v", tc.spec, tc.want, err) } } } // TestMinEveryDefault pins the production cost valve: with no override, an // interval below 15 minutes must not validate — every interval is a standing // scheduled model run. func TestMinEveryDefault(t *testing.T) { s := ScheduleSpec{Every: "5m"} if err := s.validate(); err == nil { t.Fatal("5m should be under the 15m production floor") } ok := ScheduleSpec{Every: "15m"} if err := ok.validate(); err != nil { t.Fatalf("15m rejected: %v", err) } } func TestScheduleNext(t *testing.T) { // Interval: strictly after now. s := ScheduleSpec{Every: "30m"} if err := s.validate(); err != nil { t.Fatal(err) } now := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC) if got := s.next(now); got != now.Add(30*time.Minute) { t.Fatalf("every: got %v", got) } // Daily, before today's slot: fires today. s = ScheduleSpec{At: "18:30", TZ: "Europe/Dublin"} if err := s.validate(); err != nil { t.Fatal(err) } dublin, _ := time.LoadLocation("Europe/Dublin") now = time.Date(2026, 7, 9, 10, 0, 0, 0, dublin) got := s.next(now) want := time.Date(2026, 7, 9, 18, 30, 0, 0, dublin) if !got.Equal(want) { t.Fatalf("today's slot: got %v want %v", got, want) } // Daily, after today's slot (and exactly on it): tomorrow. now = time.Date(2026, 7, 9, 18, 30, 0, 0, dublin) got = s.next(now) want = time.Date(2026, 7, 10, 18, 30, 0, 0, dublin) if !got.Equal(want) { t.Fatalf("tomorrow's slot: got %v want %v", got, want) } } // earliestCharterDue: the wake-time the host reports to the instance — the // soonest next occurrence across live charters, 0 with none. func TestEarliestCharterDue(t *testing.T) { mk := func(spec ScheduleSpec) *Charter { if err := spec.validate(); err != nil { t.Fatal(err) } return &Charter{Schedule: spec} } now := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC) if got := earliestCharterDue(map[string]*Charter{}, now); got != 0 { t.Fatalf("no charters: got %d, want 0", got) } live := map[string]*Charter{ "bot-a": mk(ScheduleSpec{Every: "6h"}), // 16:00 "bot-b": mk(ScheduleSpec{At: "12:30"}), // 12:30 UTC today — the soonest "bot-c": mk(ScheduleSpec{Every: "180m"}), // 13:00 } want := time.Date(2026, 7, 9, 12, 30, 0, 0, time.UTC).Unix() if got := earliestCharterDue(live, now); got != want { t.Fatalf("earliest = %d, want %d (12:30 UTC)", got, want) } // The result is always strictly in the future (Schedule.next's contract) — // the report can never be born stale. if got := earliestCharterDue(live, now); got <= now.Unix() { t.Fatalf("earliest %d not after now %d", got, now.Unix()) } } func TestValidateCharter(t *testing.T) { good := func() *Charter { return &Charter{ V: 1, Name: "morning-news", Title: "Morning News", Glyph: "📰", Schedule: ScheduleSpec{At: "08:00"}, Task: TaskSpec{Prompt: "fetch and summarise", Tools: []string{"fetch"}, Fetch: &FetchSpec{Allow: []string{"https://rss.example.com/"}}}, Owner: "u1", } } if err := validateCharter(good()); err != nil { t.Fatalf("valid charter rejected: %v", err) } mut := []struct { mutate func(*Charter) want string }{ {func(c *Charter) { c.V = 2 }, "unknown charter version"}, {func(c *Charter) { c.Name = "Bad Name" }, "bad bot name"}, {func(c *Charter) { c.Title = "" }, "title"}, {func(c *Charter) { c.Glyph = "" }, "glyph"}, {func(c *Charter) { c.Owner = "" }, "no owner"}, {func(c *Charter) { c.Schedule = ScheduleSpec{} }, "exactly one"}, {func(c *Charter) { c.Task.Prompt = "" }, "needs a prompt"}, } for _, tc := range mut { c := good() tc.mutate(c) err := validateCharter(c) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("want error containing %q, got %v", tc.want, err) } } } func TestParseBotBuildReq(t *testing.T) { input := map[string]any{ "name": "morning-news", "title": "Morning News", "glyph": "📰", "at": "08:00", "tz": "Europe/Dublin", "sources": []any{"rte.ie", "nytimes.com"}, "prompt": "Fetch today's headlines and write a short digest.", } req, err := parseBotBuildReq(input) if err != nil { t.Fatalf("valid input rejected: %v", err) } if req.Name != "morning-news" || req.Schedule.At != "08:00" || len(req.Sources) != 2 { t.Fatalf("bad parse: %+v", req) } bad := []struct { mutate func(map[string]any) want string }{ {func(m map[string]any) { m["name"] = "Morning News" }, "bot name"}, {func(m map[string]any) { delete(m, "prompt") }, "prompt"}, {func(m map[string]any) { m["every"] = "1h" }, "exactly one"}, {func(m map[string]any) { delete(m, "at"); delete(m, "tz") }, "exactly one"}, {func(m map[string]any) { m["bot_id"] = "NOT-AN-ID!" }, "bad bot_id"}, {func(m map[string]any) { m["sources"] = []any{"a", "b", "c", "d", "e", "f", "g"} }, "at most 6"}, } for _, tc := range bad { m := map[string]any{} for k, v := range input { m[k] = v } tc.mutate(m) if _, err := parseBotBuildReq(m); err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("want error containing %q, got %v", tc.want, err) } } } func TestScheduleSummary(t *testing.T) { s := ScheduleSpec{At: "08:00", TZ: "Europe/Dublin"} if got := s.Summary(); got != "daily at 08:00 (Europe/Dublin)" { t.Fatalf("got %q", got) } s = ScheduleSpec{Every: "4h"} if got := s.Summary(); got != "every 4h" { t.Fatalf("got %q", got) } }