package schedule import ( "context" "errors" "testing" "time" ) func TestTickRunsDueAndStampsNext(t *testing.T) { ctx := context.Background() now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) var ran []string marked := map[string]time.Time{} r := &Runner{ Now: func() time.Time { return now }, Due: func(_ context.Context, _ time.Time) ([]Due, error) { return []Due{{ID: "a", Cron: "hourly"}, {ID: "b", Cron: "bad"}}, nil }, Run: func(_ context.Context, id string) error { ran = append(ran, id); return nil }, Mark: func(_ context.Context, id string, _, next time.Time) error { marked[id] = next; return nil }, Next: func(cron string, after time.Time) (time.Time, error) { if cron == "bad" { return time.Time{}, errors.New("unparseable") } return after.Add(time.Hour), nil }, } if err := r.Tick(ctx); err != nil { t.Fatal(err) } // Next is checked first, so the bad-cron job is skipped BEFORE Run — only // the parseable job runs and gets stamped (no hot-loop of a bad-cron Run). if len(ran) != 1 || ran[0] != "a" { t.Errorf("ran = %v, want only [a] (bad-cron b skipped before Run)", ran) } if marked["a"] != now.Add(time.Hour) { t.Errorf("a next = %v, want +1h", marked["a"]) } if _, ok := marked["b"]; ok { t.Errorf("b should not be stamped (bad cron), got %v", marked["b"]) } } func TestTickRunFailureDoesNotStampOrStall(t *testing.T) { ctx := context.Background() var ran []string marked := map[string]bool{} r := &Runner{ Due: func(_ context.Context, _ time.Time) ([]Due, error) { return []Due{{ID: "x", Cron: "h"}, {ID: "y", Cron: "h"}}, nil }, Run: func(_ context.Context, id string) error { ran = append(ran, id) if id == "x" { return errors.New("boom") } return nil }, Mark: func(_ context.Context, id string, _, _ time.Time) error { marked[id] = true; return nil }, Next: func(string, time.Time) (time.Time, error) { return time.Now(), nil }, } if err := r.Tick(ctx); err != nil { t.Fatal(err) } if len(ran) != 2 { // y still runs despite x failing t.Errorf("ran = %v, want both attempted", ran) } if marked["x"] { // failed job NOT stamped -> stays due, retries t.Error("failed job x should not be stamped") } if !marked["y"] { t.Error("y should be stamped") } } func TestTickDueErrorIsFatalToPass(t *testing.T) { r := &Runner{ Due: func(context.Context, time.Time) ([]Due, error) { return nil, errors.New("store down") }, Run: func(context.Context, string) error { return nil }, Mark: func(context.Context, string, time.Time, time.Time) error { return nil }, Next: func(string, time.Time) (time.Time, error) { return time.Now(), nil }, } if err := r.Tick(context.Background()); err == nil { t.Error("Tick should surface the Due lister error") } } func TestUnparseableCronSkipsRunEntirely(t *testing.T) { var ran []string r := &Runner{ Due: func(context.Context, time.Time) ([]Due, error) { return []Due{{ID: "z", Cron: "bad"}}, nil }, Run: func(_ context.Context, id string) error { ran = append(ran, id); return nil }, Mark: func(context.Context, string, time.Time, time.Time) error { return nil }, Next: func(string, time.Time) (time.Time, error) { return time.Time{}, errors.New("bad cron") }, } if err := r.Tick(context.Background()); err != nil { t.Fatal(err) } if len(ran) != 0 { t.Errorf("a job with an unparseable cron must NOT be run (avoids hot-loop), ran=%v", ran) } } func TestValidateRejectsNilCallbacks(t *testing.T) { r := &Runner{Due: func(context.Context, time.Time) ([]Due, error) { return nil, nil }} // missing Run/Mark/Next if err := r.Tick(context.Background()); err == nil { t.Error("Tick should return a validation error for a partially-wired Runner, not panic") } }