Files

279 lines
9.5 KiB
Go
Raw Permalink Normal View History

2026-08-14 09:40:03 +12:00
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"})
}
2026-08-17 07:34:23 +12:00
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")
}
}
2026-08-14 09:40:03 +12:00
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)
}
}