]> git.giacomolli.uk - onechat/blob - schedule_cron_test.go
Reflow comments over the reflow width
[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 }