package scheduler import ( "context" "io" "log" "testing" "time" ) func discardScheduler() *Scheduler { return New(log.New(io.Discard, "", 0)) } func noopJob(context.Context) error { return nil } // farFuture returns an HH:MM roughly n hours ahead in Budapest, so a registered daily job parks on a // long timer (never fires during a sub-second test) yet is a valid, distinct schedule to switch to. func farFuture(hoursAhead int) string { return time.Now().In(getBudapestLocation()).Add(time.Duration(hoursAhead) * time.Hour).Format("15:04") } // Group C — UpdateDaily mutates the schedule and delivers the reschedule signal. Asserted on a // NOT-started scheduler so no goroutine competes for the buffered signal (race-free). func TestUpdateDaily_MutatesAndSignals(t *testing.T) { s := discardScheduler() s.Daily("db-dump", "02:30", noopJob) if !s.UpdateDaily("db-dump", "22:00") { t.Fatal("UpdateDaily returned false for a valid change") } if got := s.jobs[0].Schedule; got != "22:00" { t.Errorf("Schedule = %q, want 22:00", got) } // The buffered reschedule signal must be present (no goroutine drained it). select { case <-s.jobs[0].resched: default: t.Error("no reschedule signal was sent by UpdateDaily") } } // Group C — unknown or non-daily job name, and an invalid time, both return false without panicking // and leave everything untouched. func TestUpdateDaily_UnknownAndInvalid(t *testing.T) { s := discardScheduler() s.Daily("db-dump", "02:30", noopJob) if s.UpdateDaily("does-not-exist", "03:00") { t.Error("UpdateDaily on an unknown job returned true") } if s.UpdateDaily("db-dump", "25:99") { t.Error("UpdateDaily with an invalid time returned true") } if got := s.jobs[0].Schedule; got != "02:30" { t.Errorf("Schedule was mutated by a rejected update: %q", got) } } // Group C — a running daily goroutine picks up the new time on the next pass: it CONSUMES the // reschedule signal from the select and recomputes. Red-proof: remove the `case <-job.resched` // from runDailyJob's select and this fails (the signal stays buffered — never consumed). func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) { s := discardScheduler() s.Daily("db-dump", farFuture(6), noopJob) // parked on a ~6h timer ctx, cancel := context.WithCancel(context.Background()) s.Start(ctx) if !s.UpdateDaily("db-dump", farFuture(8)) { t.Fatal("UpdateDaily returned false") } time.Sleep(150 * time.Millisecond) // let the goroutine wake on resched and recompute cancel() s.Stop() // waits for the goroutine to exit — after this, reading resched is race-free select { case <-s.jobs[0].resched: t.Fatal("reschedule signal still buffered — runDailyJob never consumed it (no resched case)") default: // drained by the goroutine → immediacy works } }