+package main
+
+import (
+ "testing"
+ "time"
+ _ "time/tzdata"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestScheduleParseCronAccepts(t *testing.T) {
+ testCases := []struct {
+ name string
+ expression string
+ }{
+ {
+ name: "every 5 minutes is the shortest gap",
+ expression: "*/5 * * * *",
+ },
+ {
+ name: "weekdays at 8",
+ expression: "0 8 * * 1-5",
+ },
+ {
+ name: "lists and ranges in office hours",
+ expression: "0,30 9-17 * * 1-5",
+ },
+ {
+ name: "a range with a step",
+ expression: "0 0 1-31/10 * *",
+ },
+ {
+ name: "February 29 of the leap years",
+ expression: "0 12 29 2 *",
+ },
+ {
+ name: "5 minutes across the end of an hour",
+ expression: "0,55 * * * *",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ cron, err := scheduleParseCron(tc.expression)
+ require.NoError(t, err)
+ require.Equal(t, tc.expression, cron.String())
+ })
+ }
+}
+
+func TestScheduleParseCronRefuses(t *testing.T) {
+ testCases := []struct {
+ name string
+ expression string
+ message string
+ }{
+ {
+ name: "four fields",
+ expression: "* * * *",
+ message: "has 4 fields, not 5",
+ },
+ {
+ name: "a seconds field",
+ expression: "0 0 8 * * *",
+ message: "has 6 fields, not 5",
+ },
+ {
+ name: "a macro",
+ expression: "@daily",
+ message: "has 1 fields, not 5",
+ },
+ {
+ name: "a day name",
+ expression: "0 8 * * MON",
+ message: `the day of week field of "0 8 * * MON": "MON" is not a number from 0 to 7`,
+ },
+ {
+ name: "a month name",
+ expression: "0 8 * JAN *",
+ message: `"JAN" is not a number from 1 to 12`,
+ },
+ {
+ name: "a question mark",
+ expression: "0 8 ? * *",
+ message: `"?" is not a number from 1 to 31`,
+ },
+ {
+ name: "the last day",
+ expression: "0 8 L * *",
+ message: `"L" is not a number`,
+ },
+ {
+ name: "the nearest weekday",
+ expression: "0 8 15W * *",
+ message: `"15W" is not a number`,
+ },
+ {
+ name: "the nth weekday",
+ expression: "0 8 * * 1#2",
+ message: `"1#2" is not a number`,
+ },
+ {
+ name: "a step after a single value",
+ expression: "5/10 * * * *",
+ message: "has a step after a single value",
+ },
+ {
+ name: "a minute out of range",
+ expression: "60 * * * *",
+ message: `"60" is not a number from 0 to 59`,
+ },
+ {
+ name: "an hour out of range",
+ expression: "0 24 * * *",
+ message: `"24" is not a number from 0 to 23`,
+ },
+ {
+ name: "day 0",
+ expression: "0 8 0 * *",
+ message: `"0" is not a number from 1 to 31`,
+ },
+ {
+ name: "month 13",
+ expression: "0 8 * 13 *",
+ message: `"13" is not a number from 1 to 12`,
+ },
+ {
+ name: "weekday 8",
+ expression: "0 8 * * 8",
+ message: `"8" is not a number from 0 to 7`,
+ },
+ {
+ name: "a negative number",
+ expression: "0 8 * * -1",
+ message: `the range "-1"`,
+ },
+ {
+ name: "a range with its start after its end",
+ expression: "0 10-5 * * *",
+ message: "the start is after the end",
+ },
+ {
+ name: "a step of 0",
+ expression: "0 */0 * * *",
+ message: `the step of "*/0"`,
+ },
+ {
+ name: "an empty item",
+ expression: "0,,30 * * * *",
+ message: `"" is not a number`,
+ },
+ {
+ name: "a day that never comes",
+ expression: "0 0 30 2 *",
+ message: "matches no day in 8 years",
+ },
+ {
+ name: "every minute",
+ expression: "* * * * *",
+ message: "the runs of a schedule must be at least 5 minutes apart",
+ },
+ {
+ name: "every 2 minutes",
+ expression: "*/2 * * * *",
+ message: "the runs of a schedule must be at least 5 minutes apart",
+ },
+ {
+ name: "two runs in one minute of the hour",
+ expression: "58,59 * * * *",
+ message: "the runs of a schedule must be at least 5 minutes apart",
+ },
+ {
+ name: "two runs across the end of an hour",
+ expression: "0,59 * * * *",
+ message: "the runs of a schedule must be at least 5 minutes apart",
+ },
+ {
+ name: "two runs across midnight",
+ expression: "0,59 0,23 * * *",
+ message: "the runs of a schedule must be at least 5 minutes apart",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := scheduleParseCron(tc.expression)
+ require.ErrorContains(t, err, tc.message)
+ })
+ }
+}
+
+func TestScheduleCronNext(t *testing.T) {
+ newYork, err := time.LoadLocation("America/New_York")
+ require.NoError(t, err)
+
+ testCases := []struct {
+ name string
+ expression string
+ after time.Time
+ next time.Time
+ }{
+ {
+ name: "a weekday schedule skips the weekend",
+ expression: "0 8 * * 1-5",
+ after: time.Date(2026, time.November, 6, 9, 0, 0, 0, time.UTC),
+ next: time.Date(2026, time.November, 9, 8, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "the next time is after the given time",
+ expression: "0 8 * * *",
+ after: time.Date(2026, time.November, 6, 8, 0, 0, 0, time.UTC),
+ next: time.Date(2026, time.November, 7, 8, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "every 5 minutes",
+ expression: "*/5 * * * *",
+ after: time.Date(2026, time.November, 6, 10, 3, 0, 0, time.UTC),
+ next: time.Date(2026, time.November, 6, 10, 5, 0, 0, time.UTC),
+ },
+ {
+ name: "day 31 skips the months with 30 days",
+ expression: "0 0 31 * *",
+ after: time.Date(2026, time.November, 1, 0, 0, 0, 0, time.UTC),
+ next: time.Date(2026, time.December, 31, 0, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "the first day after the end of a year",
+ expression: "0 9 1 * *",
+ after: time.Date(2026, time.December, 31, 10, 0, 0, 0, time.UTC),
+ next: time.Date(2027, time.January, 1, 9, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "February 29 waits for a leap year",
+ expression: "0 12 29 2 *",
+ after: time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC),
+ next: time.Date(2028, time.February, 29, 12, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "weekday 7 is Sunday",
+ expression: "0 10 * * 7",
+ after: time.Date(2026, time.November, 6, 0, 0, 0, 0, time.UTC),
+ next: time.Date(2026, time.November, 8, 10, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "a day and a weekday match either",
+ expression: "0 0 13 * 5",
+ after: time.Date(2026, time.November, 1, 0, 0, 0, 0, time.UTC),
+ next: time.Date(2026, time.November, 6, 0, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "a day with a star and a weekday match both",
+ expression: "0 0 */2 * 5",
+ after: time.Date(2026, time.November, 1, 0, 0, 0, 0, time.UTC),
+ next: time.Date(2026, time.November, 13, 0, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "a time in the skipped hour runs later by the skip",
+ expression: "30 2 * * *",
+ after: time.Date(2027, time.March, 14, 0, 0, 0, 0, newYork),
+ next: time.Date(2027, time.March, 14, 7, 30, 0, 0, time.UTC),
+ },
+ {
+ name: "the day after the skip runs at its time",
+ expression: "30 2 * * *",
+ after: time.Date(2027, time.March, 14, 7, 30, 0, 0, time.UTC).In(newYork),
+ next: time.Date(2027, time.March, 15, 6, 30, 0, 0, time.UTC),
+ },
+ {
+ name: "every 30 minutes goes from before the skip to after it",
+ expression: "*/30 * * * *",
+ after: time.Date(2027, time.March, 14, 6, 30, 0, 0, time.UTC).In(newYork),
+ next: time.Date(2027, time.March, 14, 7, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "a time in the repeated hour runs at its first occurrence",
+ expression: "30 1 * * *",
+ after: time.Date(2026, time.November, 1, 0, 0, 0, 0, newYork),
+ next: time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC),
+ },
+ {
+ name: "a time in the repeated hour runs once",
+ expression: "30 1 * * *",
+ after: time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC).In(newYork),
+ next: time.Date(2026, time.November, 2, 6, 30, 0, 0, time.UTC),
+ },
+ {
+ name: "every 30 minutes does not repeat the repeated hour",
+ expression: "*/30 * * * *",
+ after: time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC).In(newYork),
+ next: time.Date(2026, time.November, 1, 7, 0, 0, 0, time.UTC),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ cron, err := scheduleParseCron(tc.expression)
+ require.NoError(t, err)
+
+ next, found := cron.Next(tc.after)
+ require.True(t, found)
+ require.True(t, tc.next.Equal(next), "next is %s", next)
+ })
+ }
+}
+
+func TestScheduleWallTime(t *testing.T) {
+ newYork, err := time.LoadLocation("America/New_York")
+ require.NoError(t, err)
+
+ testCases := []struct {
+ name string
+ hour int
+ day int
+ time time.Time
+ }{
+ {
+ name: "a normal wall time",
+ hour: 9,
+ day: 1,
+ time: time.Date(2026, time.November, 1, 14, 0, 0, 0, time.UTC),
+ },
+ {
+ name: "a wall time in the repeated hour is its first occurrence",
+ hour: 1,
+ day: 1,
+ time: time.Date(2026, time.November, 1, 5, 0, 0, 0, time.UTC),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := scheduleWallTime(2026, time.November, tc.day, tc.hour, 0, newYork)
+ require.True(t, tc.time.Equal(got), "time is %s", got)
+ })
+ }
+}