package scheduler import ( "sync" "testing" "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" ) // recSender is a Sender that records every envelope it gets. Tests // inspect it after a tick to assert the right messages were emitted. type recSender struct { mu sync.Mutex envs []api.Envelope } func (r *recSender) Send(env api.Envelope) error { r.mu.Lock() defer r.mu.Unlock() r.envs = append(r.envs, env) return nil } func (r *recSender) snapshot() []api.Envelope { r.mu.Lock() defer r.mu.Unlock() out := make([]api.Envelope, len(r.envs)) copy(out, r.envs) return out } func TestApplyEmitsAck(t *testing.T) { t.Parallel() tx := &recSender{} s := New() defer s.Stop() s.Apply(api.ScheduleSetPayload{ Version: 7, Schedules: []api.Schedule{ {ID: "s1", Kind: api.JobBackup, CronExpr: "@hourly", Enabled: true}, }, }, tx) if got := s.Version(); got != 7 { t.Fatalf("Version: got %d, want 7", got) } envs := tx.snapshot() if len(envs) != 1 { t.Fatalf("expected 1 envelope (ack), got %d", len(envs)) } if envs[0].Type != api.MsgScheduleAck { t.Fatalf("envelope type: got %s, want %s", envs[0].Type, api.MsgScheduleAck) } var ack api.ScheduleAckPayload _ = envs[0].UnmarshalPayload(&ack) if ack.Version != 7 { t.Fatalf("ack version: got %d", ack.Version) } } func TestApplyTickFiresScheduleFire(t *testing.T) { t.Parallel() tx := &recSender{} s := New() defer s.Stop() // Cron expression that fires roughly every second; close enough // to be reliable in CI without making the test slow. s.Apply(api.ScheduleSetPayload{ Version: 1, Schedules: []api.Schedule{ {ID: "every-second", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true}, }, }, tx) deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { envs := tx.snapshot() for _, e := range envs { if e.Type == api.MsgScheduleFire { var p api.ScheduleFirePayload _ = e.UnmarshalPayload(&p) if p.ScheduleID == "every-second" { return } } } time.Sleep(50 * time.Millisecond) } t.Fatal("schedule.fire did not arrive within 3s") } func TestApplyDisabledEntriesSkipped(t *testing.T) { t.Parallel() tx := &recSender{} s := New() defer s.Stop() s.Apply(api.ScheduleSetPayload{ Version: 1, Schedules: []api.Schedule{ {ID: "off", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: false}, }, }, tx) // A disabled schedule must never fire — give the cron a couple // of ticks to confirm it's silent. time.Sleep(2200 * time.Millisecond) for _, e := range tx.snapshot() { if e.Type == api.MsgScheduleFire { t.Fatalf("disabled schedule fired: %+v", e) } } } func TestApplyReplacesPriorState(t *testing.T) { t.Parallel() tx := &recSender{} s := New() defer s.Stop() s.Apply(api.ScheduleSetPayload{ Version: 1, Schedules: []api.Schedule{ {ID: "old", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true}, }, }, tx) // Wait long enough for the first version to fire at least once. time.Sleep(1500 * time.Millisecond) // Now replace with version 2 that doesn't include "old". s.Apply(api.ScheduleSetPayload{ Version: 2, Schedules: []api.Schedule{}, }, tx) // Snapshot count *after* the replacement. before := 0 for _, e := range tx.snapshot() { if e.Type == api.MsgScheduleFire { before++ } } time.Sleep(2 * time.Second) after := 0 for _, e := range tx.snapshot() { if e.Type == api.MsgScheduleFire { after++ } } if after != before { t.Fatalf("schedule.fire count grew after replacement (before=%d after=%d) — old cron still firing", before, after) } }