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) }) } }