carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package schedule
2
3import (
4 "testing"
5 "time"
6)
7
8func 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
16func 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
29func 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 "*").
59func 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
77func 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