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