package tasks import ( "testing" "time" ) func mustTime(t *testing.T, s string) time.Time { t.Helper() v, err := time.Parse(time.RFC3339, s) if err != nil { t.Fatal(err) } return v.UTC() } func TestEveryNext(t *testing.T) { // 5-minute grid: next boundary strictly after a mid-slot time. (Periods // that divide 1440 minutes, like 5m, align to :00 every day.) e := Every{Period: 5 * time.Minute} if got, want := e.Next(mustTime(t, "2026-07-30T10:02:30Z")), mustTime(t, "2026-07-30T10:05:00Z"); !got.Equal(want) { t.Errorf("every 5m after 10:02:30 = %s, want %s", got, want) } // Exactly on a boundary advances to the next one (strictly after). if got, want := e.Next(mustTime(t, "2026-07-30T10:05:00Z")), mustTime(t, "2026-07-30T10:10:00Z"); !got.Equal(want) { t.Errorf("every 5m after 10:05:00 = %s, want %s", got, want) } // 2-hour grid is epoch-anchored and 2h divides 24h, so it lands on even hours. e2 := Every{Period: 2 * time.Hour} if got, want := e2.Next(mustTime(t, "2026-07-30T09:15:00Z")), mustTime(t, "2026-07-30T10:00:00Z"); !got.Equal(want) { t.Errorf("every 2h after 09:15 = %s, want %s", got, want) } // No wrap gap: a non-divisor period stays exactly periodic across the hour. // Anchored at the epoch (where midnight is a boundary), 00:56 -> 01:03 is a // full 7 minutes, not the 4-minute gap cron's */7 would produce. epoch := time.Unix(0, 0).UTC() e7 := Every{Period: 7 * time.Minute} if got, want := e7.Next(epoch.Add(56*time.Minute)), epoch.Add(63*time.Minute); !got.Equal(want) { t.Errorf("every 7m after 00:56 = %s, want %s", got, want) } // Coalescing: after a long outage, the next fire is a single upcoming // boundary, not a backfill of every missed slot. if got, want := e.Next(mustTime(t, "2026-07-30T09:47:10Z")), mustTime(t, "2026-07-30T09:50:00Z"); !got.Equal(want) { t.Errorf("every 5m after 09:47:10 = %s, want %s", got, want) } } func TestDailyAtNext(t *testing.T) { d := DailyAt{Hour: 3, Minute: 0} // Before today's time -> today. if got, want := d.Next(mustTime(t, "2026-07-30T02:59:00Z")), mustTime(t, "2026-07-30T03:00:00Z"); !got.Equal(want) { t.Errorf("daily 03:00 after 02:59 = %s, want %s", got, want) } // After today's time -> tomorrow. if got, want := d.Next(mustTime(t, "2026-07-30T03:00:01Z")), mustTime(t, "2026-07-31T03:00:00Z"); !got.Equal(want) { t.Errorf("daily 03:00 after 03:00:01 = %s, want %s", got, want) } // Exactly on time -> tomorrow (strictly after). if got, want := d.Next(mustTime(t, "2026-07-30T03:00:00Z")), mustTime(t, "2026-07-31T03:00:00Z"); !got.Equal(want) { t.Errorf("daily 03:00 after 03:00:00 = %s, want %s", got, want) } // Month/year rollover. d2 := DailyAt{Hour: 0, Minute: 30} if got, want := d2.Next(mustTime(t, "2026-12-31T23:59:00Z")), mustTime(t, "2027-01-01T00:30:00Z"); !got.Equal(want) { t.Errorf("daily 00:30 after Dec 31 23:59 = %s, want %s", got, want) } } func TestWeeklyAtNext(t *testing.T) { // 2026-07-30 is a Thursday. Target Sunday -> the coming Sunday. w := WeeklyAt{Weekday: time.Sunday, Hour: 3, Minute: 0} if got, want := w.Next(mustTime(t, "2026-07-30T10:00:00Z")), mustTime(t, "2026-08-02T03:00:00Z"); !got.Equal(want) { t.Errorf("weekly Sun 03:00 after Thu = %s, want %s", got, want) } // Same weekday, time still ahead -> today. wt := WeeklyAt{Weekday: time.Thursday, Hour: 12, Minute: 0} if got, want := wt.Next(mustTime(t, "2026-07-30T10:00:00Z")), mustTime(t, "2026-07-30T12:00:00Z"); !got.Equal(want) { t.Errorf("weekly Thu 12:00 after Thu 10:00 = %s, want %s", got, want) } // Same weekday, time already passed -> next week. if got, want := wt.Next(mustTime(t, "2026-07-30T13:00:00Z")), mustTime(t, "2026-08-06T12:00:00Z"); !got.Equal(want) { t.Errorf("weekly Thu 12:00 after Thu 13:00 = %s, want %s", got, want) } } func TestParseScheduleRoundTrip(t *testing.T) { for _, s := range []string{"every 5m", "every 2h", "daily 03:00", "weekly Sun 03:00"} { spec, err := ParseSchedule(s) if err != nil { t.Fatalf("ParseSchedule(%q): %v", s, err) } if spec.String() != s { t.Errorf("ParseSchedule(%q).String() = %q, want %q", s, spec.String(), s) } } } func TestParseScheduleRejects(t *testing.T) { bad := []string{ "", // empty "every", // missing duration "every 30s", // sub-minute "every 90s", // not whole minutes "daily 24:00", // hour out of range "daily 03:60", // minute out of range "daily 3pm", // not HH:MM "weekly Sun", // missing time "weekly Xyz 03:00", // bad weekday "hourly", // unknown kind } for _, s := range bad { if _, err := ParseSchedule(s); err == nil { t.Errorf("ParseSchedule(%q) = nil error, want error", s) } } }