1package cron
2
3import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 "github.com/bnema/gordon/internal/domain"
10 "github.com/bnema/zerowrap"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13)
14
15func TestCalculateNextRunPresets(t *testing.T) {
16 now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
17
18 tests := []struct {
19 name string
20 schedule domain.BackupSchedule
21 expected time.Time
22 }{
23 {
24 name: "hourly",
25 schedule: domain.ScheduleHourly,
26 expected: time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC),
27 },
28 {
29 name: "daily",
30 schedule: domain.ScheduleDaily,
31 expected: time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC),
32 },
33 {
34 name: "weekly",
35 schedule: domain.ScheduleWeekly,
36 expected: time.Date(2026, 2, 8, 3, 0, 0, 0, time.UTC),
37 },
38 {
39 name: "monthly",
40 schedule: domain.ScheduleMonthly,
41 expected: time.Date(2026, 3, 1, 4, 0, 0, 0, time.UTC),
42 },
43 }
44
45 for _, tt := range tests {
46 t.Run(tt.name, func(t *testing.T) {
47 next, err := calculateNextRun(now, domain.CronSchedule{Preset: tt.schedule})
48 require.NoError(t, err)
49 assert.Equal(t, tt.expected, next)
50 })
51 }
52}
53
54func TestSchedulerAddListAndRunNow(t *testing.T) {
55 s := NewScheduler(zerowrap.Default())
56 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
57 s.nowFn = func() time.Time { return now }
58
59 runs := 0
60 err := s.Add("backup-daily", "daily backup", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
61 runs++
62 return nil
63 })
64 require.NoError(t, err)
65
66 entries := s.List()
67 require.Len(t, entries, 1)
68 assert.Equal(t, "backup-daily", entries[0].ID)
69 assert.Equal(t, "daily backup", entries[0].Name)
70
71 err = s.RunNow(context.Background(), "backup-daily")
72 require.NoError(t, err)
73 assert.Equal(t, 1, runs)
74
75 entries = s.List()
76 require.Len(t, entries, 1)
77 assert.Equal(t, now, entries[0].LastRun)
78 assert.Equal(t, time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC), entries[0].NextRun)
79}
80
81func TestSchedulerWithMaxJobsOption(t *testing.T) {
82 s := NewScheduler(zerowrap.Default(), WithMaxJobs(9))
83 assert.Equal(t, 9, s.maxJobs)
84}
85
86func TestSchedulerRemoveRejectsRunningJob(t *testing.T) {
87 s := NewScheduler(zerowrap.Default())
88
89 started := make(chan struct{})
90 release := make(chan struct{})
91 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
92 close(started)
93 <-release
94 return nil
95 })
96 require.NoError(t, err)
97
98 runErrCh := make(chan error, 1)
99 go func() {
100 runErrCh <- s.RunNow(context.Background(), "backup-hourly")
101 }()
102
103 <-started
104 err = s.Remove("backup-hourly")
105 require.Error(t, err)
106 assert.ErrorIs(t, err, ErrJobRunning)
107
108 close(release)
109 require.NoError(t, <-runErrCh)
110}
111
112func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) {
113 s := NewScheduler(zerowrap.Default())
114
115 started := make(chan struct{})
116 release := make(chan struct{})
117 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
118 close(started)
119 <-release
120 return nil
121 })
122 require.NoError(t, err)
123
124 var wg sync.WaitGroup
125 firstErrCh := make(chan error, 1)
126 wg.Add(1)
127 go func() {
128 defer wg.Done()
129 firstErrCh <- s.RunNow(context.Background(), "backup-hourly")
130 }()
131
132 <-started
133 err2 := s.RunNow(context.Background(), "backup-hourly")
134 assert.Error(t, err2)
135
136 close(release)
137 wg.Wait()
138 err1 := <-firstErrCh
139 require.NoError(t, err1)
140}
141
142func TestSchedulerRunNowRecoversFromPanics(t *testing.T) {
143 s := NewScheduler(zerowrap.Default())
144 err := s.Add("panic-job", "panic job", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
145 panic("boom")
146 })
147 require.NoError(t, err)
148
149 err = s.RunNow(context.Background(), "panic-job")
150 require.Error(t, err)
151 assert.Contains(t, err.Error(), "panic")
152
153 entries := s.List()
154 require.Len(t, entries, 1)
155 assert.False(t, entries[0].Running)
156 assert.False(t, entries[0].LastRun.IsZero())
157 assert.False(t, entries[0].NextRun.IsZero())
158}
159
160func TestSchedulerStartNoOpWhenStopped(t *testing.T) {
161 s := NewScheduler(zerowrap.Default())
162 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
163 s.nowFn = func() time.Time { return now }
164
165 runs := 0
166 err := s.Add("stopped-job", "stopped job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
167 runs++
168 return nil
169 })
170 require.NoError(t, err)
171
172 s.mu.Lock()
173 s.entries["stopped-job"].nextRun = now.Add(-time.Minute)
174 s.mu.Unlock()
175
176 s.Stop()
177 s.Start(context.Background())
178
179 time.Sleep(50 * time.Millisecond)
180 assert.Equal(t, 0, runs)
181 assert.False(t, s.started.Load())
182}
183
184func TestSchedulerRunNowComputesNextRunFromJobFinishTime(t *testing.T) {
185 s := NewScheduler(zerowrap.Default())
186 current := time.Date(2026, 2, 7, 12, 59, 0, 0, time.UTC)
187 s.nowFn = func() time.Time { return current }
188
189 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
190 current = time.Date(2026, 2, 7, 13, 1, 0, 0, time.UTC)
191 return nil
192 })
193 require.NoError(t, err)
194
195 err = s.RunNow(context.Background(), "backup-hourly")
196 require.NoError(t, err)
197
198 entries := s.List()
199 require.Len(t, entries, 1)
200 assert.Equal(t, time.Date(2026, 2, 7, 14, 0, 0, 0, time.UTC), entries[0].NextRun)
201}
202
203func TestSchedulerStartNoOpWhenContextAlreadyCanceled(t *testing.T) {
204 s := NewScheduler(zerowrap.Default())
205 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
206 s.nowFn = func() time.Time { return now }
207
208 runs := 0
209 err := s.Add("canceled-job", "canceled job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
210 runs++
211 return nil
212 })
213 require.NoError(t, err)
214
215 s.mu.Lock()
216 s.entries["canceled-job"].nextRun = now.Add(-time.Minute)
217 s.mu.Unlock()
218
219 ctx, cancel := context.WithCancel(context.Background())
220 cancel()
221
222 s.Start(ctx)
223 time.Sleep(50 * time.Millisecond)
224
225 assert.Equal(t, 0, runs)
226 assert.False(t, s.started.Load())
227}