| 1 | package schedule |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func at(s string) time.Time { |
| 9 | t, err := time.Parse(time.RFC3339, s) |
| 10 | if err != nil { |
| 11 | panic(err) |
| 12 | } |
| 13 | return t.UTC() |
| 14 | } |
| 15 | |
| 16 | func TestParseCronAcceptsTheGrammar(t *testing.T) { |
| 17 | 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 * * * *"} { |
| 18 | if _, err := ParseCron(ok); err != nil { |
| 19 | t.Errorf("ParseCron(%q) = %v, want ok", ok, err) |
| 20 | } |
| 21 | } |
| 22 | for _, bad := range []string{"", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * 32 * *", "* * * 13 *", "* * * * 8", "@daily", "*/0 * * * *", "5-3 * * * *", "a * * * *", "0 8 * * MON"} { |
| 23 | if _, err := ParseCron(bad); err == nil { |
| 24 | t.Errorf("ParseCron(%q) = ok, want error", bad) |
| 25 | } |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestCronNextIsStrictlyAfterAndUTC(t *testing.T) { |
| 30 | cases := []struct{ expr, after, want string }{ |
| 31 | {"0 8 * * *", "2026-08-23T07:59:00Z", "2026-08-23T08:00:00Z"}, |
| 32 | {"0 8 * * *", "2026-08-23T08:00:00Z", "2026-08-24T08:00:00Z"}, // strictly after |
| 33 | {"0 8 * * *", "2026-08-23T08:00:30Z", "2026-08-24T08:00:00Z"}, // seconds truncated: 08:00 has passed |
| 34 | {"*/15 * * * *", "2026-08-23T08:07:00Z", "2026-08-23T08:15:00Z"}, |
| 35 | {"0 */6 * * *", "2026-08-23T13:00:00Z", "2026-08-23T18:00:00Z"}, |
| 36 | {"0 9 * * 1", "2026-08-23T09:00:00Z", "2026-08-24T09:00:00Z"}, // 2026-08-23 is a Sunday |
| 37 | {"0 0 31 * *", "2026-09-01T00:00:00Z", "2026-10-31T00:00:00Z"}, // September has no 31st |
| 38 | {"0 0 29 2 *", "2026-03-01T00:00:00Z", "2028-02-29T00:00:00Z"}, |
| 39 | {"0 0 * * 7", "2026-08-23T00:00:00Z", "2026-08-30T00:00:00Z"}, // 7 = Sunday |
| 40 | } |
| 41 | for _, c := range cases { |
| 42 | expr, err := ParseCron(c.expr) |
| 43 | if err != nil { |
| 44 | t.Fatalf("%q: %v", c.expr, err) |
| 45 | } |
| 46 | if got := expr.Next(at(c.after)); !got.Equal(at(c.want)) { |
| 47 | t.Errorf("%q after %s = %s, want %s", c.expr, c.after, got.Format(time.RFC3339), c.want) |
| 48 | } |
| 49 | if got := expr.Next(at(c.after)); got.Location() != time.UTC { |
| 50 | t.Errorf("%q: Next not in UTC", c.expr) |
| 51 | } |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // dayMatches applies Vixie cron's rule: when BOTH day-of-month and |
| 56 | // day-of-week are restricted (neither is "*"), a day matches if EITHER |
| 57 | // field matches — not both. This pins that OR branch, which nothing else |
| 58 | // exercised (every other case above leaves at least one day field as "*"). |
| 59 | func TestCronBothDayFieldsRestrictedIsOR(t *testing.T) { |
| 60 | c, err := ParseCron("0 0 1 * 3") // day-of-month = 1, weekday = Wednesday |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | // 2026-08-26 is a Wednesday but not the 1st of the month: only AND |
| 65 | // semantics would skip it (waiting for a 1st that falls on a |
| 66 | // Wednesday); OR semantics fire here because dow alone matches. |
| 67 | if got, want := c.Next(at("2026-08-23T00:00:00Z")), at("2026-08-26T00:00:00Z"); !got.Equal(want) { |
| 68 | 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)) |
| 69 | } |
| 70 | // 2026-09-01 is a Tuesday, not the Wednesday dow wants, but it is the |
| 71 | // 1st: dom alone matches, which is the other half of the OR. |
| 72 | if got, want := c.Next(at("2026-08-27T00:00:00Z")), at("2026-09-01T00:00:00Z"); !got.Equal(want) { |
| 73 | 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)) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestCronNextHonoursNonUTCInput(t *testing.T) { |
| 78 | expr, _ := ParseCron("0 8 * * *") |
| 79 | loc := time.FixedZone("plus2", 2*3600) |
| 80 | // 09:30 +02:00 is 07:30Z — the next 08:00Z is the same day. |
| 81 | got := expr.Next(time.Date(2026, 8, 23, 9, 30, 0, 0, loc)) |
| 82 | if !got.Equal(at("2026-08-23T08:00:00Z")) { |
| 83 | t.Fatalf("got %s", got.Format(time.RFC3339)) |
| 84 | } |
| 85 | } |
| 86 | |