| 1 | package schedule |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func TestParseSpecRoundTrips(t *testing.T) { |
| 9 | for _, s := range []string{"every 6h", "every 1h30m", "cron 0 8 * * *", "at 1787004000"} { |
| 10 | sp, err := ParseSpec(s) |
| 11 | if err != nil { |
| 12 | t.Fatalf("%q: %v", s, err) |
| 13 | } |
| 14 | if got := sp.String(); got != s { |
| 15 | t.Errorf("%q → %q", s, got) |
| 16 | } |
| 17 | } |
| 18 | for _, bad := range []string{"", "every", "every 0s", "every 30s", "every 800h", "cron * *", "at x", "sometimes"} { |
| 19 | if _, err := ParseSpec(bad); err == nil { |
| 20 | t.Errorf("ParseSpec(%q) = ok, want error", bad) |
| 21 | } |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | // Spec.String is what lands in the timers.spec column and what `carlos |
| 26 | // schedule ls` shows verbatim (spec §4.3's example is literally "every |
| 27 | // 6h"). time.Duration.String would render that "6h0m0s" — always spelling |
| 28 | // out hours, minutes and seconds — so String must render the canonical |
| 29 | // short form instead: whatever a member typed, or the minimal equivalent |
| 30 | // for whatever a box already had on disk. |
| 31 | func TestSpecStringRendersCanonicalShortDuration(t *testing.T) { |
| 32 | cases := map[string]string{ |
| 33 | "every 6h0m0s": "every 6h", // what stdlib Duration.String produces |
| 34 | "every 60m": "every 1h", // minutes that add up to whole hours |
| 35 | "every 90m": "every 1h30m", |
| 36 | "every 45m": "every 45m", // already minimal |
| 37 | "every 1m": "every 1m", |
| 38 | "every 720h": "every 720h", // the 30-day ceiling |
| 39 | } |
| 40 | for in, want := range cases { |
| 41 | sp, err := ParseSpec(in) |
| 42 | if err != nil { |
| 43 | t.Fatalf("%q: %v", in, err) |
| 44 | } |
| 45 | if got := sp.String(); got != want { |
| 46 | t.Errorf("ParseSpec(%q).String() = %q, want %q", in, got, want) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // Epoch alignment (spec §6.1): "every 6h" fires at 00/06/12/18 UTC on every |
| 52 | // box, whatever time the row was created, so there is no phase to persist |
| 53 | // and nothing drifts across restarts. |
| 54 | func TestEveryIsEpochAligned(t *testing.T) { |
| 55 | sp := Spec{Kind: KindSchedule, Every: 6 * time.Hour} |
| 56 | cases := map[string]string{ |
| 57 | "2026-08-23T13:07:00Z": "2026-08-23T18:00:00Z", |
| 58 | "2026-08-23T18:00:00Z": "2026-08-24T00:00:00Z", // strictly after |
| 59 | "2026-08-23T23:59:59Z": "2026-08-24T00:00:00Z", |
| 60 | } |
| 61 | for now, want := range cases { |
| 62 | if got := sp.Next(at(now)); !got.Equal(at(want)) { |
| 63 | t.Errorf("Next(%s) = %s, want %s", now, got.Format(time.RFC3339), want) |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // Coalescing (spec §6.1): Next is computed from NOW, so a box that was down |
| 69 | // across several occurrences produces one next fire, not a burst. |
| 70 | func TestNextNeverReturnsThePast(t *testing.T) { |
| 71 | sp := Spec{Kind: KindSchedule, Every: time.Hour} |
| 72 | now := at("2026-08-23T13:07:00Z") |
| 73 | if got := sp.Next(now); !got.After(now) { |
| 74 | t.Fatalf("Next(%s) = %s is not after now", now, got) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestOnceFiresOnceThenNever(t *testing.T) { |
| 79 | sp := Spec{Kind: KindOnce, At: at("2026-09-01T08:00:00Z")} |
| 80 | if got := sp.Next(at("2026-08-23T00:00:00Z")); !got.Equal(sp.At) { |
| 81 | t.Fatalf("before: %s", got) |
| 82 | } |
| 83 | if got := sp.Next(at("2026-09-01T08:00:00Z")); !got.IsZero() { |
| 84 | t.Fatalf("after firing: %s, want zero", got) |
| 85 | } |
| 86 | } |
| 87 | |