]> git.giacomolli.uk - onechat/blob - schedule_test.go
Keep the schedules in a file
[onechat] / schedule_test.go
1 package main
2
3 import (
4         "os"
5         "path/filepath"
6         "strings"
7         "testing"
8         "time"
9
10         "github.com/stretchr/testify/require"
11 )
12
13 func TestSchedulesSet(t *testing.T) {
14         testCases := []struct {
15                 name   string
16                 text   string
17                 at     *string
18                 cron   *string
19                 answer string
20                 listed string
21         }{
22                 {
23                         name:   "a one-time schedule at a local time",
24                         text:   "Remind the user to drink water.",
25                         at:     new("2026-11-09 09:00"),
26                         answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
27                         listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
28                 },
29                 {
30                         name:   "a repeating schedule at the next match",
31                         text:   "Tell the user\nthe time.",
32                         cron:   new("*/5  * * * *"),
33                         answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
34                         listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.",
35                 },
36         }
37
38         for _, tc := range testCases {
39                 t.Run(tc.name, func(t *testing.T) {
40                         schedules, _ := scheduleTestOpen(t)
41
42                         answer, err := schedules.Set(tc.text, tc.at, tc.cron)
43                         require.NoError(t, err)
44                         require.Equal(t, tc.answer, answer)
45                         require.Equal(t, tc.listed, schedules.List())
46                 })
47         }
48 }
49
50 func TestSchedulesSetRefuses(t *testing.T) {
51         testCases := []struct {
52                 name    string
53                 text    string
54                 at      *string
55                 cron    *string
56                 message string
57         }{
58                 {
59                         name:    "an empty text",
60                         text:    " \n",
61                         at:      new("2026-11-09 09:00"),
62                         message: "the text must have 1 to 4096 bytes",
63                 },
64                 {
65                         name:    "a text that is too long",
66                         text:    strings.Repeat("x", scheduleTextBytes+1),
67                         at:      new("2026-11-09 09:00"),
68                         message: "the text must have 1 to 4096 bytes",
69                 },
70                 {
71                         name:    "neither at nor cron",
72                         text:    "Tell the user the time.",
73                         message: "give either at or cron, and null for the other",
74                 },
75                 {
76                         name:    "both at and cron",
77                         text:    "Tell the user the time.",
78                         at:      new("2026-11-09 09:00"),
79                         cron:    new("0 9 * * *"),
80                         message: "give either at or cron, and null for the other",
81                 },
82                 {
83                         name:    "a time in another layout",
84                         text:    "Tell the user the time.",
85                         at:      new("2026-11-09T09:00:00-03:00"),
86                         message: "at must be a local time such as 2026-11-08 20:00",
87                 },
88                 {
89                         name:    "a time that is past",
90                         text:    "Tell the user the time.",
91                         at:      new("2026-11-08 20:00"),
92                         message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
93                 },
94                 {
95                         name:    "runs less than 5 minutes apart",
96                         text:    "Tell the user the time.",
97                         cron:    new("* * * * *"),
98                         message: "at least 5 minutes apart",
99                 },
100         }
101
102         for _, tc := range testCases {
103                 t.Run(tc.name, func(t *testing.T) {
104                         schedules, path := scheduleTestOpen(t)
105
106                         _, err := schedules.Set(tc.text, tc.at, tc.cron)
107                         require.ErrorContains(t, err, tc.message)
108                         require.NoFileExists(t, path)
109                 })
110         }
111 }
112
113 func TestSchedulesSurviveARestart(t *testing.T) {
114         schedules, path := scheduleTestOpen(t)
115
116         _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
117         require.NoError(t, err)
118
119         _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5"))
120         require.NoError(t, err)
121
122         _, err = schedules.Remove(1)
123         require.NoError(t, err)
124
125         info, err := os.Stat(path)
126         require.NoError(t, err)
127         require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
128
129         reopened, err := openSchedules(path, schedules.clock)
130         require.NoError(t, err)
131         require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
132
133         answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil)
134         require.NoError(t, err)
135         require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer)
136 }
137
138 func TestSchedulesRemove(t *testing.T) {
139         schedules, _ := scheduleTestOpen(t)
140
141         _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
142         require.NoError(t, err)
143
144         answer, err := schedules.Remove(1)
145         require.NoError(t, err)
146         require.Equal(t, "Schedule 1 removed.", answer)
147         require.Equal(t, "No schedules.", schedules.List())
148
149         _, err = schedules.Remove(1)
150         require.EqualError(t, err, "no schedule 1")
151 }
152
153 func TestSchedulesDone(t *testing.T) {
154         schedules, _ := scheduleTestOpen(t)
155         start := schedules.clock()
156
157         _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil)
158         require.NoError(t, err)
159
160         _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *"))
161         require.NoError(t, err)
162
163         wait, found := schedules.Wait(start)
164         require.True(t, found)
165         require.Equal(t, time.Hour, wait)
166
167         // The daemon was stopped for three hours, so each schedule missed its
168         // times and runs once.
169         now := start.Add(3*time.Hour + 30*time.Minute)
170         due := schedules.Due(now)
171         require.Len(t, due, 2)
172         for _, schedule := range due {
173                 require.NoError(t, schedules.Done(schedule, now))
174         }
175
176         require.Empty(t, schedules.Due(now))
177         require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
178
179         _, err = schedules.Remove(2)
180         require.NoError(t, err)
181         require.NoError(t, schedules.Done(due[1], now))
182         require.Equal(t, "No schedules.", schedules.List())
183
184         _, found = schedules.Wait(now)
185         require.False(t, found)
186 }
187
188 func TestScheduleMessage(t *testing.T) {
189         zone := time.FixedZone("", -3*60*60)
190         due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone)
191         testCases := []struct {
192                 name     string
193                 schedule Schedule
194                 now      time.Time
195                 message  string
196         }{
197                 {
198                         name: "a one-time run on time",
199                         schedule: Schedule{
200                                 ID:   3,
201                                 Text: "Remind the user to drink water.",
202                                 Due:  due,
203                         },
204                         now:     due.Add(time.Second),
205                         message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
206                 },
207                 {
208                         name: "a late repeating run",
209                         schedule: Schedule{
210                                 ID:   4,
211                                 Text: "Tell the user the news.",
212                                 Cron: "0 9 * * 1-5",
213                                 Due:  due,
214                         },
215                         now:     due.Add(2 * time.Hour),
216                         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,
217                 },
218         }
219
220         for _, tc := range testCases {
221                 t.Run(tc.name, func(t *testing.T) {
222                         require.Equal(t, tc.message, tc.schedule.Message(tc.now))
223                 })
224         }
225 }
226
227 func TestOpenSchedulesRefusesABadCron(t *testing.T) {
228         path := filepath.Join(t.TempDir(), scheduleFileName)
229         require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
230
231         _, err := openSchedules(path, time.Now)
232         require.ErrorContains(t, err, "schedule 1: the runs of")
233 }
234
235 // scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
236 // 2026-11-08 20:00 -03:00, and returns them and the path of the file.
237 func scheduleTestOpen(t *testing.T) (*Schedules, string) {
238         path := filepath.Join(t.TempDir(), scheduleFileName)
239         clock := &TestClock{
240                 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
241         }
242
243         schedules, err := openSchedules(path, clock.Now)
244         require.NoError(t, err)
245
246         return schedules, path
247 }