package schedule import ( "testing" "time" ) func at(s string) time.Time { t, err := time.Parse(time.RFC3339, s) if err != nil { panic(err) } return t.UTC() } func TestParseCronAcceptsTheGrammar(t *testing.T) { for _, ok := range []string{"* * * * *", "0 8 * * *", "*/15 * * * *", "0 */6 * * *", "0 9 1-5 * *", "30 7 * * 1,3,5", "0 0 1 1 *", "0 0 * * 7", "0-59/20 * * * *"} { if _, err := ParseCron(ok); err != nil { t.Errorf("ParseCron(%q) = %v, want ok", ok, err) } } for _, bad := range []string{"", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * 32 * *", "* * * 13 *", "* * * * 8", "@daily", "*/0 * * * *", "5-3 * * * *", "a * * * *", "0 8 * * MON"} { if _, err := ParseCron(bad); err == nil { t.Errorf("ParseCron(%q) = ok, want error", bad) } } } func TestCronNextIsStrictlyAfterAndUTC(t *testing.T) { cases := []struct{ expr, after, want string }{ {"0 8 * * *", "2026-08-23T07:59:00Z", "2026-08-23T08:00:00Z"}, {"0 8 * * *", "2026-08-23T08:00:00Z", "2026-08-24T08:00:00Z"}, // strictly after {"0 8 * * *", "2026-08-23T08:00:30Z", "2026-08-24T08:00:00Z"}, // seconds truncated: 08:00 has passed {"*/15 * * * *", "2026-08-23T08:07:00Z", "2026-08-23T08:15:00Z"}, {"0 */6 * * *", "2026-08-23T13:00:00Z", "2026-08-23T18:00:00Z"}, {"0 9 * * 1", "2026-08-23T09:00:00Z", "2026-08-24T09:00:00Z"}, // 2026-08-23 is a Sunday {"0 0 31 * *", "2026-09-01T00:00:00Z", "2026-10-31T00:00:00Z"}, // September has no 31st {"0 0 29 2 *", "2026-03-01T00:00:00Z", "2028-02-29T00:00:00Z"}, {"0 0 * * 7", "2026-08-23T00:00:00Z", "2026-08-30T00:00:00Z"}, // 7 = Sunday } for _, c := range cases { expr, err := ParseCron(c.expr) if err != nil { t.Fatalf("%q: %v", c.expr, err) } if got := expr.Next(at(c.after)); !got.Equal(at(c.want)) { t.Errorf("%q after %s = %s, want %s", c.expr, c.after, got.Format(time.RFC3339), c.want) } if got := expr.Next(at(c.after)); got.Location() != time.UTC { t.Errorf("%q: Next not in UTC", c.expr) } } } // dayMatches applies Vixie cron's rule: when BOTH day-of-month and // day-of-week are restricted (neither is "*"), a day matches if EITHER // field matches — not both. This pins that OR branch, which nothing else // exercised (every other case above leaves at least one day field as "*"). func TestCronBothDayFieldsRestrictedIsOR(t *testing.T) { c, err := ParseCron("0 0 1 * 3") // day-of-month = 1, weekday = Wednesday if err != nil { t.Fatal(err) } // 2026-08-26 is a Wednesday but not the 1st of the month: only AND // semantics would skip it (waiting for a 1st that falls on a // Wednesday); OR semantics fire here because dow alone matches. if got, want := c.Next(at("2026-08-23T00:00:00Z")), at("2026-08-26T00:00:00Z"); !got.Equal(want) { t.Fatalf("dom=1 dow=Wed after a Sunday = %s, want %s (dow-only match, OR semantics)", got.Format(time.RFC3339), want.Format(time.RFC3339)) } // 2026-09-01 is a Tuesday, not the Wednesday dow wants, but it is the // 1st: dom alone matches, which is the other half of the OR. if got, want := c.Next(at("2026-08-27T00:00:00Z")), at("2026-09-01T00:00:00Z"); !got.Equal(want) { t.Fatalf("dom=1 dow=Wed after 2026-08-27 = %s, want %s (dom-only match, OR semantics)", got.Format(time.RFC3339), want.Format(time.RFC3339)) } } func TestCronNextHonoursNonUTCInput(t *testing.T) { expr, _ := ParseCron("0 8 * * *") loc := time.FixedZone("plus2", 2*3600) // 09:30 +02:00 is 07:30Z — the next 08:00Z is the same day. got := expr.Next(time.Date(2026, 8, 23, 9, 30, 0, 0, loc)) if !got.Equal(at("2026-08-23T08:00:00Z")) { t.Fatalf("got %s", got.Format(time.RFC3339)) } }