8 "github.com/stretchr/testify/require"
11 func TestScheduleParseCronAccepts(t *testing.T) {
12 testCases := []struct {
17 name: "every 5 minutes is the shortest gap",
18 expression: "*/5 * * * *",
21 name: "weekdays at 8",
22 expression: "0 8 * * 1-5",
25 name: "lists and ranges in office hours",
26 expression: "0,30 9-17 * * 1-5",
29 name: "a range with a step",
30 expression: "0 0 1-31/10 * *",
33 name: "February 29 of the leap years",
34 expression: "0 12 29 2 *",
37 name: "5 minutes across the end of an hour",
38 expression: "0,55 * * * *",
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())
51 func TestScheduleParseCronRefuses(t *testing.T) {
52 testCases := []struct {
59 expression: "* * * *",
60 message: "has 4 fields, not 5",
63 name: "a seconds field",
64 expression: "0 0 8 * * *",
65 message: "has 6 fields, not 5",
70 message: "has 1 fields, not 5",
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`,
79 expression: "0 8 * JAN *",
80 message: `"JAN" is not a number from 1 to 12`,
83 name: "a question mark",
84 expression: "0 8 ? * *",
85 message: `"?" is not a number from 1 to 31`,
89 expression: "0 8 L * *",
90 message: `"L" is not a number`,
93 name: "the nearest weekday",
94 expression: "0 8 15W * *",
95 message: `"15W" is not a number`,
98 name: "the nth weekday",
99 expression: "0 8 * * 1#2",
100 message: `"1#2" is not a number`,
103 name: "a step after a single value",
104 expression: "5/10 * * * *",
105 message: "has a step after a single value",
108 name: "a minute out of range",
109 expression: "60 * * * *",
110 message: `"60" is not a number from 0 to 59`,
113 name: "an hour out of range",
114 expression: "0 24 * * *",
115 message: `"24" is not a number from 0 to 23`,
119 expression: "0 8 0 * *",
120 message: `"0" is not a number from 1 to 31`,
124 expression: "0 8 * 13 *",
125 message: `"13" is not a number from 1 to 12`,
129 expression: "0 8 * * 8",
130 message: `"8" is not a number from 0 to 7`,
133 name: "a negative number",
134 expression: "0 8 * * -1",
135 message: `the range "-1"`,
138 name: "a range with its start after its end",
139 expression: "0 10-5 * * *",
140 message: "the start is after the end",
144 expression: "0 */0 * * *",
145 message: `the step of "*/0"`,
148 name: "an empty item",
149 expression: "0,,30 * * * *",
150 message: `"" is not a number`,
153 name: "a day that never comes",
154 expression: "0 0 30 2 *",
155 message: "matches no day in 8 years",
158 name: "every minute",
159 expression: "* * * * *",
160 message: "the runs of a schedule must be at least 5 minutes apart",
163 name: "every 2 minutes",
164 expression: "*/2 * * * *",
165 message: "the runs of a schedule must be at least 5 minutes apart",
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",
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",
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",
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)
192 func TestScheduleCronNext(t *testing.T) {
193 newYork, err := time.LoadLocation("America/New_York")
194 require.NoError(t, err)
196 testCases := []struct {
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),
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),
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),
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),
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),
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),
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),
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),
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),
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),
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),
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),
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),
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),
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),
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)
299 next, found := cron.Next(tc.after)
300 require.True(t, found)
301 require.True(t, tc.next.Equal(next), "next is %s", next)
306 func TestScheduleWallTime(t *testing.T) {
307 newYork, err := time.LoadLocation("America/New_York")
308 require.NoError(t, err)
310 testCases := []struct {
317 name: "a normal wall time",
320 time: time.Date(2026, time.November, 1, 14, 0, 0, 0, time.UTC),
323 name: "a wall time in the repeated hour is its first occurrence",
326 time: time.Date(2026, time.November, 1, 5, 0, 0, 0, time.UTC),
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)