| 1 | package bot |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | func TestScheduleSpecValidate(t *testing.T) { |
| 10 | t.Setenv("LCHAT_BOT_MIN_EVERY", "1s") // tests watch bots fire in seconds |
| 11 | ok := []ScheduleSpec{ |
| 12 | {At: "08:00"}, |
| 13 | {At: "23:59", TZ: "Europe/Dublin"}, |
| 14 | {Every: "30m"}, |
| 15 | {Every: "1s"}, |
| 16 | } |
| 17 | for _, s := range ok { |
| 18 | s := s |
| 19 | if err := s.validate(); err != nil { |
| 20 | t.Errorf("%+v rejected: %v", s, err) |
| 21 | } |
| 22 | } |
| 23 | bad := []struct { |
| 24 | spec ScheduleSpec |
| 25 | want string |
| 26 | }{ |
| 27 | {ScheduleSpec{}, "exactly one"}, |
| 28 | {ScheduleSpec{At: "08:00", Every: "1h"}, "exactly one"}, |
| 29 | {ScheduleSpec{At: "25:00"}, "bad at"}, |
| 30 | {ScheduleSpec{At: "8am"}, "bad at"}, |
| 31 | {ScheduleSpec{At: "08:00", TZ: "Mars/Olympus"}, "bad tz"}, |
| 32 | {ScheduleSpec{Every: "0s"}, "bad every"}, |
| 33 | {ScheduleSpec{Every: "soon"}, "bad every"}, |
| 34 | {ScheduleSpec{Every: "500ms"}, "bad every"}, |
| 35 | {ScheduleSpec{Every: "1h", TZ: "Europe/Dublin"}, "tz belongs"}, |
| 36 | } |
| 37 | for _, tc := range bad { |
| 38 | tc := tc |
| 39 | err := tc.spec.validate() |
| 40 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 41 | t.Errorf("%+v: want error containing %q, got %v", tc.spec, tc.want, err) |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // TestMinEveryDefault pins the production cost valve: with no override, an |
| 47 | // interval below 15 minutes must not validate — every interval is a standing |
| 48 | // scheduled model run. |
| 49 | func TestMinEveryDefault(t *testing.T) { |
| 50 | s := ScheduleSpec{Every: "5m"} |
| 51 | if err := s.validate(); err == nil { |
| 52 | t.Fatal("5m should be under the 15m production floor") |
| 53 | } |
| 54 | ok := ScheduleSpec{Every: "15m"} |
| 55 | if err := ok.validate(); err != nil { |
| 56 | t.Fatalf("15m rejected: %v", err) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestScheduleNext(t *testing.T) { |
| 61 | // Interval: strictly after now. |
| 62 | s := ScheduleSpec{Every: "30m"} |
| 63 | if err := s.validate(); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | now := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC) |
| 67 | if got := s.next(now); got != now.Add(30*time.Minute) { |
| 68 | t.Fatalf("every: got %v", got) |
| 69 | } |
| 70 | |
| 71 | // Daily, before today's slot: fires today. |
| 72 | s = ScheduleSpec{At: "18:30", TZ: "Europe/Dublin"} |
| 73 | if err := s.validate(); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | dublin, _ := time.LoadLocation("Europe/Dublin") |
| 77 | now = time.Date(2026, 7, 9, 10, 0, 0, 0, dublin) |
| 78 | got := s.next(now) |
| 79 | want := time.Date(2026, 7, 9, 18, 30, 0, 0, dublin) |
| 80 | if !got.Equal(want) { |
| 81 | t.Fatalf("today's slot: got %v want %v", got, want) |
| 82 | } |
| 83 | |
| 84 | // Daily, after today's slot (and exactly on it): tomorrow. |
| 85 | now = time.Date(2026, 7, 9, 18, 30, 0, 0, dublin) |
| 86 | got = s.next(now) |
| 87 | want = time.Date(2026, 7, 10, 18, 30, 0, 0, dublin) |
| 88 | if !got.Equal(want) { |
| 89 | t.Fatalf("tomorrow's slot: got %v want %v", got, want) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // earliestCharterDue: the wake-time the host reports to the instance — the |
| 94 | // soonest next occurrence across live charters, 0 with none. |
| 95 | func TestEarliestCharterDue(t *testing.T) { |
| 96 | mk := func(spec ScheduleSpec) *Charter { |
| 97 | if err := spec.validate(); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | return &Charter{Schedule: spec} |
| 101 | } |
| 102 | now := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC) |
| 103 | |
| 104 | if got := earliestCharterDue(map[string]*Charter{}, now); got != 0 { |
| 105 | t.Fatalf("no charters: got %d, want 0", got) |
| 106 | } |
| 107 | |
| 108 | live := map[string]*Charter{ |
| 109 | "bot-a": mk(ScheduleSpec{Every: "6h"}), // 16:00 |
| 110 | "bot-b": mk(ScheduleSpec{At: "12:30"}), // 12:30 UTC today — the soonest |
| 111 | "bot-c": mk(ScheduleSpec{Every: "180m"}), // 13:00 |
| 112 | } |
| 113 | want := time.Date(2026, 7, 9, 12, 30, 0, 0, time.UTC).Unix() |
| 114 | if got := earliestCharterDue(live, now); got != want { |
| 115 | t.Fatalf("earliest = %d, want %d (12:30 UTC)", got, want) |
| 116 | } |
| 117 | |
| 118 | // The result is always strictly in the future (Schedule.next's contract) — |
| 119 | // the report can never be born stale. |
| 120 | if got := earliestCharterDue(live, now); got <= now.Unix() { |
| 121 | t.Fatalf("earliest %d not after now %d", got, now.Unix()) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestValidateCharter(t *testing.T) { |
| 126 | good := func() *Charter { |
| 127 | return &Charter{ |
| 128 | V: 1, Name: "morning-news", Title: "Morning News", Glyph: "📰", |
| 129 | Schedule: ScheduleSpec{At: "08:00"}, |
| 130 | Task: TaskSpec{Prompt: "fetch and summarise", Tools: []string{"fetch"}, |
| 131 | Fetch: &FetchSpec{Allow: []string{"https://rss.example.com/"}}}, |
| 132 | Owner: "u1", |
| 133 | } |
| 134 | } |
| 135 | if err := validateCharter(good()); err != nil { |
| 136 | t.Fatalf("valid charter rejected: %v", err) |
| 137 | } |
| 138 | mut := []struct { |
| 139 | mutate func(*Charter) |
| 140 | want string |
| 141 | }{ |
| 142 | {func(c *Charter) { c.V = 2 }, "unknown charter version"}, |
| 143 | {func(c *Charter) { c.Name = "Bad Name" }, "bad bot name"}, |
| 144 | {func(c *Charter) { c.Title = "" }, "title"}, |
| 145 | {func(c *Charter) { c.Glyph = "" }, "glyph"}, |
| 146 | {func(c *Charter) { c.Owner = "" }, "no owner"}, |
| 147 | {func(c *Charter) { c.Schedule = ScheduleSpec{} }, "exactly one"}, |
| 148 | {func(c *Charter) { c.Task.Prompt = "" }, "needs a prompt"}, |
| 149 | } |
| 150 | for _, tc := range mut { |
| 151 | c := good() |
| 152 | tc.mutate(c) |
| 153 | err := validateCharter(c) |
| 154 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 155 | t.Errorf("want error containing %q, got %v", tc.want, err) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | func TestParseBotBuildReq(t *testing.T) { |
| 161 | input := map[string]any{ |
| 162 | "name": "morning-news", "title": "Morning News", "glyph": "📰", |
| 163 | "at": "08:00", "tz": "Europe/Dublin", |
| 164 | "sources": []any{"rte.ie", "nytimes.com"}, |
| 165 | "prompt": "Fetch today's headlines and write a short digest.", |
| 166 | } |
| 167 | req, err := parseBotBuildReq(input) |
| 168 | if err != nil { |
| 169 | t.Fatalf("valid input rejected: %v", err) |
| 170 | } |
| 171 | if req.Name != "morning-news" || req.Schedule.At != "08:00" || len(req.Sources) != 2 { |
| 172 | t.Fatalf("bad parse: %+v", req) |
| 173 | } |
| 174 | |
| 175 | bad := []struct { |
| 176 | mutate func(map[string]any) |
| 177 | want string |
| 178 | }{ |
| 179 | {func(m map[string]any) { m["name"] = "Morning News" }, "bot name"}, |
| 180 | {func(m map[string]any) { delete(m, "prompt") }, "prompt"}, |
| 181 | {func(m map[string]any) { m["every"] = "1h" }, "exactly one"}, |
| 182 | {func(m map[string]any) { delete(m, "at"); delete(m, "tz") }, "exactly one"}, |
| 183 | {func(m map[string]any) { m["bot_id"] = "NOT-AN-ID!" }, "bad bot_id"}, |
| 184 | {func(m map[string]any) { |
| 185 | m["sources"] = []any{"a", "b", "c", "d", "e", "f", "g"} |
| 186 | }, "at most 6"}, |
| 187 | } |
| 188 | for _, tc := range bad { |
| 189 | m := map[string]any{} |
| 190 | for k, v := range input { |
| 191 | m[k] = v |
| 192 | } |
| 193 | tc.mutate(m) |
| 194 | if _, err := parseBotBuildReq(m); err == nil || !strings.Contains(err.Error(), tc.want) { |
| 195 | t.Errorf("want error containing %q, got %v", tc.want, err) |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestScheduleSummary(t *testing.T) { |
| 201 | s := ScheduleSpec{At: "08:00", TZ: "Europe/Dublin"} |
| 202 | if got := s.Summary(); got != "daily at 08:00 (Europe/Dublin)" { |
| 203 | t.Fatalf("got %q", got) |
| 204 | } |
| 205 | s = ScheduleSpec{Every: "4h"} |
| 206 | if got := s.Summary(); got != "every 4h" { |
| 207 | t.Fatalf("got %q", got) |
| 208 | } |
| 209 | } |
| 210 | |