]> git.giacomolli.uk - onechat/blob - schedule_test.go
Log each schedule run by its ID
[onechat] / schedule_test.go
1 package main
2
3 import (
4         "context"
5         "io"
6         "log"
7         "os"
8         "path/filepath"
9         "strings"
10         "sync"
11         "testing"
12         "time"
13
14         "github.com/stretchr/testify/require"
15 )
16
17 func TestSchedulesSet(t *testing.T) {
18         testCases := []struct {
19                 name   string
20                 text   string
21                 at     *string
22                 cron   *string
23                 answer string
24                 listed string
25         }{
26                 {
27                         name:   "a one-time schedule at a local time",
28                         text:   "Remind the user to drink water.",
29                         at:     new("2026-11-09 09:00"),
30                         answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
31                         listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
32                 },
33                 {
34                         name:   "a repeating schedule at the next match",
35                         text:   "Tell the user\nthe time.",
36                         cron:   new("*/5  * * * *"),
37                         answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
38                         listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.",
39                 },
40         }
41
42         for _, tc := range testCases {
43                 t.Run(tc.name, func(t *testing.T) {
44                         schedules, _ := scheduleTestOpen(t)
45
46                         answer, err := schedules.Set(tc.text, tc.at, tc.cron)
47                         require.NoError(t, err)
48                         require.Equal(t, tc.answer, answer)
49                         require.Equal(t, tc.listed, schedules.List())
50                 })
51         }
52 }
53
54 func TestSchedulesSetRefuses(t *testing.T) {
55         testCases := []struct {
56                 name    string
57                 text    string
58                 at      *string
59                 cron    *string
60                 message string
61         }{
62                 {
63                         name:    "an empty text",
64                         text:    " \n",
65                         at:      new("2026-11-09 09:00"),
66                         message: "the text must have 1 to 4096 bytes",
67                 },
68                 {
69                         name:    "a text that is too long",
70                         text:    strings.Repeat("x", scheduleTextBytes+1),
71                         at:      new("2026-11-09 09:00"),
72                         message: "the text must have 1 to 4096 bytes",
73                 },
74                 {
75                         name:    "neither at nor cron",
76                         text:    "Tell the user the time.",
77                         message: "give either at or cron, and null for the other",
78                 },
79                 {
80                         name:    "both at and cron",
81                         text:    "Tell the user the time.",
82                         at:      new("2026-11-09 09:00"),
83                         cron:    new("0 9 * * *"),
84                         message: "give either at or cron, and null for the other",
85                 },
86                 {
87                         name:    "a time in another layout",
88                         text:    "Tell the user the time.",
89                         at:      new("2026-11-09T09:00:00-03:00"),
90                         message: "at must be a local time such as 2026-11-08 20:00",
91                 },
92                 {
93                         name:    "a time that is past",
94                         text:    "Tell the user the time.",
95                         at:      new("2026-11-08 20:00"),
96                         message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
97                 },
98                 {
99                         name:    "runs less than 5 minutes apart",
100                         text:    "Tell the user the time.",
101                         cron:    new("* * * * *"),
102                         message: "at least 5 minutes apart",
103                 },
104         }
105
106         for _, tc := range testCases {
107                 t.Run(tc.name, func(t *testing.T) {
108                         schedules, path := scheduleTestOpen(t)
109
110                         _, err := schedules.Set(tc.text, tc.at, tc.cron)
111                         require.ErrorContains(t, err, tc.message)
112                         require.NoFileExists(t, path)
113                 })
114         }
115 }
116
117 func TestSchedulesSurviveARestart(t *testing.T) {
118         schedules, path := scheduleTestOpen(t)
119
120         _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
121         require.NoError(t, err)
122
123         _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5"))
124         require.NoError(t, err)
125
126         _, err = schedules.Remove(1)
127         require.NoError(t, err)
128
129         info, err := os.Stat(path)
130         require.NoError(t, err)
131         require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
132
133         reopened, err := openSchedules(path, schedules.clock, log.New(io.Discard, "", 0))
134         require.NoError(t, err)
135         require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
136
137         answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil)
138         require.NoError(t, err)
139         require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer)
140 }
141
142 func TestSchedulesRemove(t *testing.T) {
143         schedules, _ := scheduleTestOpen(t)
144
145         _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
146         require.NoError(t, err)
147
148         answer, err := schedules.Remove(1)
149         require.NoError(t, err)
150         require.Equal(t, "Schedule 1 removed.", answer)
151         require.Equal(t, "No schedules.", schedules.List())
152
153         _, err = schedules.Remove(1)
154         require.EqualError(t, err, "no schedule 1")
155 }
156
157 func TestSchedulesDone(t *testing.T) {
158         schedules, _ := scheduleTestOpen(t)
159         start := schedules.clock()
160
161         _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil)
162         require.NoError(t, err)
163
164         _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *"))
165         require.NoError(t, err)
166
167         wait, found := schedules.Wait(start)
168         require.True(t, found)
169         require.Equal(t, time.Hour, wait)
170
171         // The daemon was stopped for three hours, so each schedule missed its
172         // times and runs once.
173         now := start.Add(3*time.Hour + 30*time.Minute)
174         due := schedules.Due(now)
175         require.Len(t, due, 2)
176         for _, schedule := range due {
177                 require.NoError(t, schedules.Done(schedule, now))
178         }
179
180         require.Empty(t, schedules.Due(now))
181         require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
182
183         _, err = schedules.Remove(2)
184         require.NoError(t, err)
185         require.NoError(t, schedules.Done(due[1], now))
186         require.Equal(t, "No schedules.", schedules.List())
187
188         _, found = schedules.Wait(now)
189         require.False(t, found)
190 }
191
192 func TestScheduleMessage(t *testing.T) {
193         zone := time.FixedZone("", -3*60*60)
194         due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone)
195         testCases := []struct {
196                 name     string
197                 schedule Schedule
198                 now      time.Time
199                 message  string
200         }{
201                 {
202                         name: "a one-time run on time",
203                         schedule: Schedule{
204                                 ID:   3,
205                                 Text: "Remind the user to drink water.",
206                                 Due:  due,
207                         },
208                         now:     due.Add(time.Second),
209                         message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
210                 },
211                 {
212                         name: "a late repeating run",
213                         schedule: Schedule{
214                                 ID:   4,
215                                 Text: "Tell the user the news.",
216                                 Cron: "0 9 * * 1-5",
217                                 Due:  due,
218                         },
219                         now:     due.Add(2 * time.Hour),
220                         message: "[Schedule 4] Monday 2026-11-09 09:00 -03:00, repeats 0 9 * * 1-5\nTell the user the news.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
221                 },
222         }
223
224         for _, tc := range testCases {
225                 t.Run(tc.name, func(t *testing.T) {
226                         require.Equal(t, tc.message, tc.schedule.Message(tc.now))
227                 })
228         }
229 }
230
231 func TestOpenSchedulesRefusesABadCron(t *testing.T) {
232         path := filepath.Join(t.TempDir(), scheduleFileName)
233         require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
234
235         _, err := openSchedules(path, time.Now, log.New(io.Discard, "", 0))
236         require.ErrorContains(t, err, "schedule 1: the runs of")
237 }
238
239 func TestSchedulesRunStartsATurnWhenDue(t *testing.T) {
240         clock := &ScheduleTestClock{
241                 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
242         }
243
244         logs := &SyncBuffer{}
245         schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, log.New(logs, "", 0))
246         require.NoError(t, err)
247
248         server := newModelFake(t)
249         test := scheduleTestRun(t, schedules, server)
250
251         _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
252         require.NoError(t, err)
253
254         clock.Set(clock.Now().Add(5 * time.Minute))
255         test.events.WaitFor(t, "idle", 1)
256         require.Equal(t, 1, server.Calls())
257
258         texts := turnInputTexts(t, server.Call(0))
259         require.Equal(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply, texts[len(texts)-1])
260         require.Equal(t, scheduleSource, test.events.LastEvent("work").Source)
261         require.Equal(t, "No schedules.", schedules.List())
262         require.Equal(t, "schedule 1 runs\n", logs.String())
263 }
264
265 func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) {
266         path := filepath.Join(t.TempDir(), scheduleFileName)
267         require.NoError(t, os.WriteFile(path, []byte(`{"next":2,"schedules":[{"id":1,"text":"Remind the user to call.","cron":"","due":"2026-11-08T09:00:00-03:00"},{"id":2,"text":"Tell the user the time.","cron":"0 * * * *","due":"2026-11-08T10:00:00-03:00"}]}`), 0o600))
268
269         clock := &ScheduleTestClock{
270                 now: time.Date(2026, time.November, 8, 20, 30, 0, 0, time.FixedZone("", -3*60*60)),
271         }
272
273         logs := &SyncBuffer{}
274         schedules, err := openSchedules(path, clock.Now, log.New(logs, "", 0))
275         require.NoError(t, err)
276
277         server := newModelFake(t)
278         test := scheduleTestRun(t, schedules, server)
279         test.events.WaitFor(t, "idle", 1)
280         require.Equal(t, 1, server.Calls())
281
282         texts := turnInputTexts(t, server.Call(0))
283         require.Equal(t, []string{
284                 "[Schedule 1] Sunday 2026-11-08 09:00 -03:00, once\nRemind the user to call.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
285                 "[Schedule 2] Sunday 2026-11-08 10:00 -03:00, repeats 0 * * * *\nTell the user the time.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
286         }, texts[len(texts)-2:])
287         require.Equal(t, "2 | Sunday 2026-11-08 21:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
288         require.Equal(t, "schedule 1 runs 11h30m0s late, because the daemon was stopped at its time\nschedule 2 runs 10h30m0s late, because the daemon was stopped at its time\n", logs.String())
289 }
290
291 func TestDaemonRunsADueSchedule(t *testing.T) {
292         clock := &ScheduleTestClock{
293                 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
294         }
295
296         schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, log.New(io.Discard, "", 0))
297         require.NoError(t, err)
298
299         schedules.check = time.Millisecond
300         whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
301         socket, _ := daemonStartTestWithSchedules(t, newModelFake(t), nil, whatsapp, schedules, clock.Now)
302         client := terminalStartTestClient(t, socket)
303         client.WaitFor(t, "[connected to")
304
305         _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
306         require.NoError(t, err)
307
308         clock.Set(clock.Now().Add(5 * time.Minute))
309         client.WaitFor(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply+"\n[working]\na line\n[done]")
310 }
311
312 func TestToolboxRunsTheScheduleTools(t *testing.T) {
313         schedules, _ := scheduleTestOpen(t)
314         box := &Toolbox{
315                 schedules: schedules,
316         }
317
318         require.Equal(t, "Schedule 1 set. Next: Monday 2026-11-09 08:00 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Tell the user the news.","at":null,"cron":"0 8 * * 1-5"}`))
319         require.Equal(t, "Schedule 2 set. Next: Sunday 2026-11-08 20:02 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Remind the user to drink water.","at":"2026-11-08 20:02","cron":null}`))
320         require.Equal(t, "2 | Sunday 2026-11-08 20:02 -03:00 | once | Remind the user to drink water.\n1 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", box.run(t.Context(), "schedule_list", `{}`))
321         require.Equal(t, "Schedule 1 removed.", box.run(t.Context(), "schedule_remove", `{"id":1}`))
322         require.Equal(t, "error: no schedule 1", box.run(t.Context(), "schedule_remove", `{"id":1}`))
323         require.Equal(t, "error: give either at or cron, and null for the other", box.run(t.Context(), "schedule_set", `{"text":"x","at":null,"cron":null}`))
324 }
325
326 func TestSubagentHasNoScheduleTools(t *testing.T) {
327         box := &Toolbox{}
328         require.Equal(t, `error: unknown tool "schedule_set"`, box.run(t.Context(), "schedule_set", `{"text":"x","at":"2026-11-09 09:00","cron":null}`))
329         require.Equal(t, `error: unknown tool "schedule_list"`, box.run(t.Context(), "schedule_list", `{}`))
330         require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
331 }
332
333 // ScheduleTestClock is a clock that a test sets while Run reads it.
334 type ScheduleTestClock struct {
335         mutex sync.Mutex
336         now   time.Time
337 }
338
339 // Now returns the time of the clock.
340 func (c *ScheduleTestClock) Now() time.Time {
341         c.mutex.Lock()
342         defer c.mutex.Unlock()
343
344         return c.now
345 }
346
347 // Set sets the time of the clock.
348 func (c *ScheduleTestClock) Set(now time.Time) {
349         c.mutex.Lock()
350         defer c.mutex.Unlock()
351
352         c.now = now
353 }
354
355 // scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
356 // 2026-11-08 20:00 -03:00, and returns them and the path of the file.
357 func scheduleTestOpen(t *testing.T) (*Schedules, string) {
358         path := filepath.Join(t.TempDir(), scheduleFileName)
359         clock := &TestClock{
360                 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
361         }
362
363         schedules, err := openSchedules(path, clock.Now, log.New(io.Discard, "", 0))
364         require.NoError(t, err)
365
366         return schedules, path
367 }
368
369 // scheduleTestRun runs schedules in a new turn loop that calls server, until
370 // the test ends. Run looks at the times every millisecond.
371 func scheduleTestRun(t *testing.T, schedules *Schedules, server *ModelFake) *TurnTestLoop {
372         t.Helper()
373
374         schedules.check = time.Millisecond
375         test := newTestTurnLoop(t, server)
376         ctx, cancel := context.WithCancel(t.Context())
377         done := make(chan error)
378         go func() {
379                 done <- schedules.Run(ctx, test.loop)
380         }()
381
382         t.Cleanup(func() {
383                 cancel()
384                 require.NoError(t, <-done)
385         })
386
387         return test
388 }