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