package scheduler import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" ) func quietScheduler() *Scheduler { return New(nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) } // The registry's guarantees are the ones worth pinning: a task cannot be registered // twice, a run cannot overlap itself, and a panicking job cannot take the gateway down. func TestDuplicateTaskIDsPanic(t *testing.T) { // Two tasks sharing an id would share a history, an enabled switch and a Run Now // button, and the console would have no way to say which it was describing. sched := quietScheduler() sched.Register(Task{ID: "one", Name: "One", Run: noop}) defer func() { if recover() == nil { t.Fatal("registering a duplicate id should panic at start-up, not be tolerated") } }() sched.Register(Task{ID: "one", Name: "One again", Run: noop}) } func TestATaskNeedsAnIDAndAFunction(t *testing.T) { sched := quietScheduler() defer func() { if recover() == nil { t.Fatal("a task with no function should be refused at registration") } }() sched.Register(Task{ID: "broken"}) } func TestQuietTimePausesManualTasks(t *testing.T) { sched := quietScheduler() ran := false sched.Register(Task{ID: "quiet", Name: "Quiet", Run: func(context.Context) (string, error) { ran = true return "", nil }}) sched.SetPaused(func() bool { return true }) if err := sched.RunNow(context.Background(), "quiet"); err == nil { t.Fatal("manual task started during quiet time") } if ran { t.Fatal("quiet-time task function ran") } } func TestAPanickingTaskBecomesAFailedRun(t *testing.T) { // A background job is the one place a panic takes the whole process down for a reason // nobody is watching for. One housekeeping job with a nil map must not be able to stop // the gateway serving television. sched := quietScheduler() sched.Register(Task{ID: "panics", Name: "Panics", Run: func(context.Context) (string, error) { panic("nil map") }}) if err := sched.RunNow(context.Background(), "panics"); err != nil { t.Fatalf("RunNow: %v", err) } status := waitForRun(t, sched, "panics") if status.LastRun.Status != "failed" { t.Fatalf("expected a failed run, got %q", status.LastRun.Status) } if status.LastRun.Error == "" { t.Fatal("the recovered panic should be recorded as the failure") } } func TestRunNowRefusesToStackASecondRun(t *testing.T) { // The button is pressable while a job is running, and an overnight job started twice // would double whatever it does. release := make(chan struct{}) sched := quietScheduler() sched.Register(Task{ID: "slow", Name: "Slow", Run: func(context.Context) (string, error) { <-release return "", nil }}) if err := sched.RunNow(context.Background(), "slow"); err != nil { t.Fatalf("first run: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && !running(sched, "slow") { time.Sleep(5 * time.Millisecond) } if err := sched.RunNow(context.Background(), "slow"); err == nil { t.Fatal("a second RunNow while the first is in flight should be refused") } close(release) } func TestRunNowRejectsAnUnknownTask(t *testing.T) { if err := quietScheduler().RunNow(context.Background(), "nothing"); err == nil { t.Fatal("an unknown task id should be an error, not a silent no-op") } } func TestAFailingTaskRecordsItsError(t *testing.T) { sched := quietScheduler() sched.Register(Task{ID: "fails", Name: "Fails", Run: func(context.Context) (string, error) { return "", errors.New("postgres did not answer") }}) _ = sched.RunNow(context.Background(), "fails") status := waitForRun(t, sched, "fails") if status.LastRun.Error != "postgres did not answer" { t.Fatalf("unexpected error %q", status.LastRun.Error) } } func TestSetIntervalZeroRestoresTheDeclaredCadence(t *testing.T) { // Zero is "as the code declares", so a task whose schedule changes in a later release // takes effect for every operator who never overrode it. sched := quietScheduler() sched.Register(Task{ID: "daily", Name: "Daily", Interval: 24 * time.Hour, Run: noop}) if err := sched.SetInterval(context.Background(), "daily", 2*time.Hour); err != nil { t.Fatalf("SetInterval: %v", err) } if got := statusOf(t, sched, "daily").Interval; got != int64((2 * time.Hour).Seconds()) { t.Fatalf("override not applied, got %d", got) } if err := sched.SetInterval(context.Background(), "daily", 0); err != nil { t.Fatalf("SetInterval: %v", err) } if got := statusOf(t, sched, "daily").Interval; got != int64((24 * time.Hour).Seconds()) { t.Fatalf("expected the declared interval back, got %d", got) } } func TestIntervalsAreFloored(t *testing.T) { // The scheduler only wakes every thirty seconds, so a one-second interval is a promise // it cannot keep — and a job set to run every second by a slipped decimal point is one // that would sit permanently due. sched := quietScheduler() sched.Register(Task{ID: "daily", Name: "Daily", Interval: 24 * time.Hour, Run: noop}) _ = sched.SetInterval(context.Background(), "daily", time.Second) if got := statusOf(t, sched, "daily").Interval; got != 60 { t.Fatalf("expected a one-minute floor, got %d", got) } } func TestADisabledTaskAdvertisesNoNextRun(t *testing.T) { // The console prints "next run" from this field; a disabled task showing a time would // read as one that is going to run anyway. sched := quietScheduler() sched.Register(Task{ID: "daily", Name: "Daily", Interval: time.Hour, Run: noop}) _ = sched.SetEnabled(context.Background(), "daily", false) if status := statusOf(t, sched, "daily"); status.NextRun != nil { t.Fatalf("a disabled task should advertise no next run, got %v", status.NextRun) } } func TestSnapshotKeepsRegistrationOrder(t *testing.T) { // The registry is written as a readable list, and the console lays it out in that // order; a map iteration would reshuffle the page on every poll. sched := quietScheduler() for _, id := range []string{"a", "b", "c", "d"} { sched.Register(Task{ID: id, Name: id, Run: noop}) } for attempt := 0; attempt < 5; attempt++ { snapshot := sched.Snapshot() for i, id := range []string{"a", "b", "c", "d"} { if snapshot[i].ID != id { t.Fatalf("order changed: %v", snapshot) } } } } func TestGroupsAreDistinctAndSorted(t *testing.T) { sched := quietScheduler() sched.Register(Task{ID: "a", Group: "System", Run: noop}) sched.Register(Task{ID: "b", Group: "Housekeeping", Run: noop}) sched.Register(Task{ID: "c", Group: "System", Run: noop}) sched.Register(Task{ID: "d", Run: noop}) // ungrouped groups := sched.Groups() if len(groups) != 2 || groups[0] != "Housekeeping" || groups[1] != "System" { t.Fatalf("unexpected groups %v", groups) } } func TestNilSchedulerIsSafe(t *testing.T) { // Every unit test in the api package builds a Server with no scheduler, and the admin // handler calls straight through to it. var sched *Scheduler if len(sched.Snapshot()) != 0 { t.Fatal("a nil scheduler should answer with an empty registry") } sched.Register(Task{ID: "a", Run: noop}) sched.Start(context.Background()) } func noop(context.Context) (string, error) { return "", nil } func running(sched *Scheduler, id string) bool { for _, status := range sched.Snapshot() { if status.ID == id { return status.Running } } return false } func statusOf(t *testing.T, sched *Scheduler, id string) Status { t.Helper() for _, status := range sched.Snapshot() { if status.ID == id { return status } } t.Fatalf("no task %q", id) return Status{} } func waitForRun(t *testing.T, sched *Scheduler, id string) Status { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { status := statusOf(t, sched, id) if status.LastRun != nil && !status.Running { return status } time.Sleep(5 * time.Millisecond) } t.Fatalf("task %q did not finish", id) return Status{} } var _ sync.Locker = (*sync.Mutex)(nil) // The console needs both cadences to draw its control honestly: the one in force and the // one the code declares. Reporting only the effective interval made "every ten minutes // because that is the default" and "every ten minutes because somebody chose it" identical // on the wire, so nothing could offer a way back to the default or mark a task as no longer // running as shipped. func TestSnapshotReportsTheDeclaredCadenceBesideTheEffectiveOne(t *testing.T) { sched := quietScheduler() sched.Register(Task{ID: "credits", Name: "Credits", Interval: 10 * time.Minute, Run: noop}) before := sched.Snapshot()[0] if before.Interval != 600 || before.DefaultInterval != 600 { t.Fatalf("unoverridden task: interval %d, default %d, want 600 and 600", before.Interval, before.DefaultInterval) } if err := sched.SetInterval(context.Background(), "credits", time.Hour); err != nil { t.Fatalf("SetInterval: %v", err) } after := sched.Snapshot()[0] if after.Interval != 3600 { t.Fatalf("effective interval %d, want 3600", after.Interval) } // The declared cadence must survive the override, or the way back is lost. if after.DefaultInterval != 600 { t.Fatalf("declared cadence %d, want 600 — an override must not overwrite it", after.DefaultInterval) } } // A task that declares no cadence at all runs only when somebody presses the button, and // the console has to be able to say so rather than printing "every 0 seconds". func TestATaskWithNoDeclaredCadenceReportsZeroForBoth(t *testing.T) { sched := quietScheduler() sched.Register(Task{ID: "manual", Name: "Manual", Run: noop}) status := sched.Snapshot()[0] if status.Interval != 0 || status.DefaultInterval != 0 { t.Fatalf("interval %d, default %d, want 0 and 0", status.Interval, status.DefaultInterval) } }