]> git.giacomolli.uk - onechat/blob - schedule_cron_test.go
Keep the schedules in a file
[onechat] / schedule_cron_test.go
1 package main
2
3 import (
4         "testing"
5         "time"
6         _ "time/tzdata"
7
8         "github.com/stretchr/testify/require"
9 )
10
11 func TestScheduleParseCronAccepts(t *testing.T) {
12         testCases := []struct {
13                 name       string
14                 expression string
15         }{
16                 {
17                         name:       "every 5 minutes is the shortest gap",
18                         expression: "*/5 * * * *",
19                 },
20                 {
21                         name:       "weekdays at 8",
22                         expression: "0 8 * * 1-5",
23                 },
24                 {
25                         name:       "lists and ranges in office hours",
26                         expression: "0,30 9-17 * * 1-5",
27                 },
28                 {
29                         name:       "a range with a step",
30                         expression: "0 0 1-31/10 * *",
31                 },
32                 {
33                         name:       "February 29 of the leap years",
34                         expression: "0 12 29 2 *",
35                 },
36                 {
37                         name:       "5 minutes across the end of an hour",
38                         expression: "0,55 * * * *",
39                 },
40         }
41
42         for _, tc := range testCases {
43                 t.Run(tc.name, func(t *testing.T) {
44                         cron, err := scheduleParseCron(tc.expression)
45                         require.NoError(t, err)
46                         require.Equal(t, tc.expression, cron.String())
47                 })
48         }
49 }
50
51 func TestScheduleParseCronRefuses(t *testing.T) {
52         testCases := []struct {
53                 name       string
54                 expression string
55                 message    string
56         }{
57                 {
58                         name:       "four fields",
59                         expression: "* * * *",
60                         message:    "has 4 fields, not 5",
61                 },
62                 {
63                         name:       "a seconds field",
64                         expression: "0 0 8 * * *",
65                         message:    "has 6 fields, not 5",
66                 },
67                 {
68                         name:       "a macro",
69                         expression: "@daily",
70                         message:    "has 1 fields, not 5",
71                 },
72                 {
73                         name:       "a day name",
74                         expression: "0 8 * * MON",
75                         message:    `the day of week field of "0 8 * * MON": "MON" is not a number from 0 to 7`,
76                 },
77                 {
78                         name:       "a month name",
79                         expression: "0 8 * JAN *",
80                         message:    `"JAN" is not a number from 1 to 12`,
81                 },
82                 {
83                         name:       "a question mark",
84                         expression: "0 8 ? * *",
85                         message:    `"?" is not a number from 1 to 31`,
86                 },
87                 {
88                         name:       "the last day",
89                         expression: "0 8 L * *",
90                         message:    `"L" is not a number`,
91                 },
92                 {
93                         name:       "the nearest weekday",
94                         expression: "0 8 15W * *",
95                         message:    `"15W" is not a number`,
96                 },
97                 {
98                         name:       "the nth weekday",
99                         expression: "0 8 * * 1#2",
100                         message:    `"1#2" is not a number`,
101                 },
102                 {
103                         name:       "a step after a single value",
104                         expression: "5/10 * * * *",
105                         message:    "has a step after a single value",
106                 },
107                 {
108                         name:       "a minute out of range",
109                         expression: "60 * * * *",
110                         message:    `"60" is not a number from 0 to 59`,
111                 },
112                 {
113                         name:       "an hour out of range",
114                         expression: "0 24 * * *",
115                         message:    `"24" is not a number from 0 to 23`,
116                 },
117                 {
118                         name:       "day 0",
119                         expression: "0 8 0 * *",
120                         message:    `"0" is not a number from 1 to 31`,
121                 },
122                 {
123                         name:       "month 13",
124                         expression: "0 8 * 13 *",
125                         message:    `"13" is not a number from 1 to 12`,
126                 },
127                 {
128                         name:       "weekday 8",
129                         expression: "0 8 * * 8",
130                         message:    `"8" is not a number from 0 to 7`,
131                 },
132                 {
133                         name:       "a negative number",
134                         expression: "0 8 * * -1",
135                         message:    `the range "-1"`,
136                 },
137                 {
138                         name:       "a range with its start after its end",
139                         expression: "0 10-5 * * *",
140                         message:    "the start is after the end",
141                 },
142                 {
143                         name:       "a step of 0",
144                         expression: "0 */0 * * *",
145                         message:    `the step of "*/0"`,
146                 },
147                 {
148                         name:       "an empty item",
149                         expression: "0,,30 * * * *",
150                         message:    `"" is not a number`,
151                 },
152                 {
153                         name:       "a day that never comes",
154                         expression: "0 0 30 2 *",
155                         message:    "matches no day in 8 years",
156                 },
157                 {
158                         name:       "every minute",
159                         expression: "* * * * *",
160                         message:    "the runs of a schedule must be at least 5 minutes apart",
161                 },
162                 {
163                         name:       "every 2 minutes",
164                         expression: "*/2 * * * *",
165                         message:    "the runs of a schedule must be at least 5 minutes apart",
166                 },
167                 {
168                         name:       "two runs in one minute of the hour",
169                         expression: "58,59 * * * *",
170                         message:    "the runs of a schedule must be at least 5 minutes apart",
171                 },
172                 {
173                         name:       "two runs across the end of an hour",
174                         expression: "0,59 * * * *",
175                         message:    "the runs of a schedule must be at least 5 minutes apart",
176                 },
177                 {
178                         name:       "two runs across midnight",
179                         expression: "0,59 0,23 * * *",
180                         message:    "the runs of a schedule must be at least 5 minutes apart",
181                 },
182         }
183
184         for _, tc := range testCases {
185                 t.Run(tc.name, func(t *testing.T) {
186                         _, err := scheduleParseCron(tc.expression)
187                         require.ErrorContains(t, err, tc.message)
188                 })
189         }
190 }
191
192 func TestScheduleCronNext(t *testing.T) {
193         newYork, err := time.LoadLocation("America/New_York")
194         require.NoError(t, err)
195
196         testCases := []struct {
197                 name       string
198                 expression string
199                 after      time.Time
200                 next       time.Time
201         }{
202                 {
203                         name:       "a weekday schedule skips the weekend",
204                         expression: "0 8 * * 1-5",
205                         after:      time.Date(2026, time.November, 6, 9, 0, 0, 0, time.UTC),
206                         next:       time.Date(2026, time.November, 9, 8, 0, 0, 0, time.UTC),
207                 },
208                 {
209                         name:       "the next time is after the given time",
210                         expression: "0 8 * * *",
211                         after:      time.Date(2026, time.November, 6, 8, 0, 0, 0, time.UTC),
212                         next:       time.Date(2026, time.November, 7, 8, 0, 0, 0, time.UTC),
213                 },
214                 {
215                         name:       "every 5 minutes",
216                         expression: "*/5 * * * *",
217                         after:      time.Date(2026, time.November, 6, 10, 3, 0, 0, time.UTC),
218                         next:       time.Date(2026, time.November, 6, 10, 5, 0, 0, time.UTC),
219                 },
220                 {
221                         name:       "day 31 skips the months with 30 days",
222                         expression: "0 0 31 * *",
223                         after:      time.Date(2026, time.November, 1, 0, 0, 0, 0, time.UTC),
224                         next:       time.Date(2026, time.December, 31, 0, 0, 0, 0, time.UTC),
225                 },
226                 {
227                         name:       "the first day after the end of a year",
228                         expression: "0 9 1 * *",
229                         after:      time.Date(2026, time.December, 31, 10, 0, 0, 0, time.UTC),
230                         next:       time.Date(2027, time.January, 1, 9, 0, 0, 0, time.UTC),
231                 },
232                 {
233                         name:       "February 29 waits for a leap year",
234                         expression: "0 12 29 2 *",
235                         after:      time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC),
236                         next:       time.Date(2028, time.February, 29, 12, 0, 0, 0, time.UTC),
237                 },
238                 {
239                         name:       "weekday 7 is Sunday",
240                         expression: "0 10 * * 7",
241                         after:      time.Date(2026, time.November, 6, 0, 0, 0, 0, time.UTC),
242                         next:       time.Date(2026, time.November, 8, 10, 0, 0, 0, time.UTC),
243                 },
244                 {
245                         name:       "a day and a weekday match either",
246                         expression: "0 0 13 * 5",
247                         after:      time.Date(2026, time.November, 1, 0, 0, 0, 0, time.UTC),
248                         next:       time.Date(2026, time.November, 6, 0, 0, 0, 0, time.UTC),
249                 },
250                 {
251                         name:       "a day with a star and a weekday match both",
252                         expression: "0 0 */2 * 5",
253                         after:      time.Date(2026, time.November, 1, 0, 0, 0, 0, time.UTC),
254                         next:       time.Date(2026, time.November, 13, 0, 0, 0, 0, time.UTC),
255                 },
256                 {
257                         name:       "a time in the skipped hour runs later by the skip",
258                         expression: "30 2 * * *",
259                         after:      time.Date(2027, time.March, 14, 0, 0, 0, 0, newYork),
260                         next:       time.Date(2027, time.March, 14, 7, 30, 0, 0, time.UTC),
261                 },
262                 {
263                         name:       "the day after the skip runs at its time",
264                         expression: "30 2 * * *",
265                         after:      time.Date(2027, time.March, 14, 7, 30, 0, 0, time.UTC).In(newYork),
266                         next:       time.Date(2027, time.March, 15, 6, 30, 0, 0, time.UTC),
267                 },
268                 {
269                         name:       "every 30 minutes goes from before the skip to after it",
270                         expression: "*/30 * * * *",
271                         after:      time.Date(2027, time.March, 14, 6, 30, 0, 0, time.UTC).In(newYork),
272                         next:       time.Date(2027, time.March, 14, 7, 0, 0, 0, time.UTC),
273                 },
274                 {
275                         name:       "a time in the repeated hour runs at its first occurrence",
276                         expression: "30 1 * * *",
277                         after:      time.Date(2026, time.November, 1, 0, 0, 0, 0, newYork),
278                         next:       time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC),
279                 },
280                 {
281                         name:       "a time in the repeated hour runs once",
282                         expression: "30 1 * * *",
283                         after:      time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC).In(newYork),
284                         next:       time.Date(2026, time.November, 2, 6, 30, 0, 0, time.UTC),
285                 },
286                 {
287                         name:       "every 30 minutes does not repeat the repeated hour",
288                         expression: "*/30 * * * *",
289                         after:      time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC).In(newYork),
290                         next:       time.Date(2026, time.November, 1, 7, 0, 0, 0, time.UTC),
291                 },
292         }
293
294         for _, tc := range testCases {
295                 t.Run(tc.name, func(t *testing.T) {
296                         cron, err := scheduleParseCron(tc.expression)
297                         require.NoError(t, err)
298
299                         next, found := cron.Next(tc.after)
300                         require.True(t, found)
301                         require.True(t, tc.next.Equal(next), "next is %s", next)
302                 })
303         }
304 }
305
306 func TestScheduleWallTime(t *testing.T) {
307         newYork, err := time.LoadLocation("America/New_York")
308         require.NoError(t, err)
309
310         testCases := []struct {
311                 name string
312                 hour int
313                 day  int
314                 time time.Time
315         }{
316                 {
317                         name: "a normal wall time",
318                         hour: 9,
319                         day:  1,
320                         time: time.Date(2026, time.November, 1, 14, 0, 0, 0, time.UTC),
321                 },
322                 {
323                         name: "a wall time in the repeated hour is its first occurrence",
324                         hour: 1,
325                         day:  1,
326                         time: time.Date(2026, time.November, 1, 5, 0, 0, 0, time.UTC),
327                 },
328         }
329
330         for _, tc := range testCases {
331                 t.Run(tc.name, func(t *testing.T) {
332                         got := scheduleWallTime(2026, time.November, tc.day, tc.hour, 0, newYork)
333                         require.True(t, tc.time.Equal(got), "time is %s", got)
334                 })
335         }
336 }