package schedule import ( "testing" "time" ) func TestParseSpecRoundTrips(t *testing.T) { for _, s := range []string{"every 6h", "every 1h30m", "cron 0 8 * * *", "at 1787004000"} { sp, err := ParseSpec(s) if err != nil { t.Fatalf("%q: %v", s, err) } if got := sp.String(); got != s { t.Errorf("%q → %q", s, got) } } for _, bad := range []string{"", "every", "every 0s", "every 30s", "every 800h", "cron * *", "at x", "sometimes"} { if _, err := ParseSpec(bad); err == nil { t.Errorf("ParseSpec(%q) = ok, want error", bad) } } } // Spec.String is what lands in the timers.spec column and what `carlos // schedule ls` shows verbatim (spec §4.3's example is literally "every // 6h"). time.Duration.String would render that "6h0m0s" — always spelling // out hours, minutes and seconds — so String must render the canonical // short form instead: whatever a member typed, or the minimal equivalent // for whatever a box already had on disk. func TestSpecStringRendersCanonicalShortDuration(t *testing.T) { cases := map[string]string{ "every 6h0m0s": "every 6h", // what stdlib Duration.String produces "every 60m": "every 1h", // minutes that add up to whole hours "every 90m": "every 1h30m", "every 45m": "every 45m", // already minimal "every 1m": "every 1m", "every 720h": "every 720h", // the 30-day ceiling } for in, want := range cases { sp, err := ParseSpec(in) if err != nil { t.Fatalf("%q: %v", in, err) } if got := sp.String(); got != want { t.Errorf("ParseSpec(%q).String() = %q, want %q", in, got, want) } } } // Epoch alignment (spec §6.1): "every 6h" fires at 00/06/12/18 UTC on every // box, whatever time the row was created, so there is no phase to persist // and nothing drifts across restarts. func TestEveryIsEpochAligned(t *testing.T) { sp := Spec{Kind: KindSchedule, Every: 6 * time.Hour} cases := map[string]string{ "2026-08-23T13:07:00Z": "2026-08-23T18:00:00Z", "2026-08-23T18:00:00Z": "2026-08-24T00:00:00Z", // strictly after "2026-08-23T23:59:59Z": "2026-08-24T00:00:00Z", } for now, want := range cases { if got := sp.Next(at(now)); !got.Equal(at(want)) { t.Errorf("Next(%s) = %s, want %s", now, got.Format(time.RFC3339), want) } } } // Coalescing (spec §6.1): Next is computed from NOW, so a box that was down // across several occurrences produces one next fire, not a burst. func TestNextNeverReturnsThePast(t *testing.T) { sp := Spec{Kind: KindSchedule, Every: time.Hour} now := at("2026-08-23T13:07:00Z") if got := sp.Next(now); !got.After(now) { t.Fatalf("Next(%s) = %s is not after now", now, got) } } func TestOnceFiresOnceThenNever(t *testing.T) { sp := Spec{Kind: KindOnce, At: at("2026-09-01T08:00:00Z")} if got := sp.Next(at("2026-08-23T00:00:00Z")); !got.Equal(sp.At) { t.Fatalf("before: %s", got) } if got := sp.Next(at("2026-09-01T08:00:00Z")); !got.IsZero() { t.Fatalf("after firing: %s, want zero", got) } }