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 TestCalculateNextRunInterval(t *testing.T) {
55 now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
56
57 next, err := calculateNextRun(now, domain.CronSchedule{Interval: 6 * time.Hour})
58
59 require.NoError(t, err)
60 assert.Equal(t, now.Add(6*time.Hour), next)
61}
62
63func TestCalculateNextRunRejectsIntervalAndPreset(t *testing.T) {
64 _, err := calculateNextRun(time.Now(), domain.CronSchedule{Preset: domain.ScheduleDaily, Interval: time.Hour})
65
66 require.Error(t, err)
67 assert.Contains(t, err.Error(), "both interval and preset")
68}
69
70func TestSchedulerAddListAndRunNow(t *testing.T) {
71 s := NewScheduler(zerowrap.Default())
72 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
73 s.nowFn = func() time.Time { return now }
74
75 runs := 0
76 err := s.Add("backup-daily", "daily backup", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
77 runs++
78 return nil
79 })
80 require.NoError(t, err)
81
82 entries := s.List()
83 require.Len(t, entries, 1)
84 assert.Equal(t, "backup-daily", entries[0].ID)
85 assert.Equal(t, "daily backup", entries[0].Name)
86
87 err = s.RunNow(context.Background(), "backup-daily")
88 require.NoError(t, err)
89 assert.Equal(t, 1, runs)
90
91 entries = s.List()
92 require.Len(t, entries, 1)
93 assert.Equal(t, now, entries[0].LastRun)
94 assert.Equal(t, time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC), entries[0].NextRun)
95}
96
97func TestSchedulerWithMaxJobsOption(t *testing.T) {
98 s := NewScheduler(zerowrap.Default(), WithMaxJobs(9))
99 assert.Equal(t, 9, s.maxJobs)
100}
101
102func TestSchedulerRemoveRejectsRunningJob(t *testing.T) {
103 s := NewScheduler(zerowrap.Default())
104
105 started := make(chan struct{})
106 release := make(chan struct{})
107 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
108 close(started)
109 <-release
110 return nil
111 })
112 require.NoError(t, err)
113
114 runErrCh := make(chan error, 1)
115 go func() {
116 runErrCh <- s.RunNow(context.Background(), "backup-hourly")
117 }()
118
119 <-started
120 err = s.Remove("backup-hourly")
121 require.Error(t, err)
122 assert.ErrorIs(t, err, ErrJobRunning)
123
124 close(release)
125 require.NoError(t, <-runErrCh)
126}
127
128func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) {
129 s := NewScheduler(zerowrap.Default())
130
131 started := make(chan struct{})
132 release := make(chan struct{})
133 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
134 close(started)
135 <-release
136 return nil
137 })
138 require.NoError(t, err)
139
140 var wg sync.WaitGroup
141 firstErrCh := make(chan error, 1)
142 wg.Add(1)
143 go func() {
144 defer wg.Done()
145 firstErrCh <- s.RunNow(context.Background(), "backup-hourly")
146 }()
147
148 <-started
149 err2 := s.RunNow(context.Background(), "backup-hourly")
150 assert.Error(t, err2)
151
152 close(release)
153 wg.Wait()
154 err1 := <-firstErrCh
155 require.NoError(t, err1)
156}
157
158func TestSchedulerRunNowRecoversFromPanics(t *testing.T) {
159 s := NewScheduler(zerowrap.Default())
160 err := s.Add("panic-job", "panic job", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
161 panic("boom")
162 })
163 require.NoError(t, err)
164
165 err = s.RunNow(context.Background(), "panic-job")
166 require.Error(t, err)
167 assert.Contains(t, err.Error(), "panic")
168
169 entries := s.List()
170 require.Len(t, entries, 1)
171 assert.False(t, entries[0].Running)
172 assert.False(t, entries[0].LastRun.IsZero())
173 assert.False(t, entries[0].NextRun.IsZero())
174}
175
176func TestSchedulerStartNoOpWhenStopped(t *testing.T) {
177 s := NewScheduler(zerowrap.Default())
178 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
179 s.nowFn = func() time.Time { return now }
180
181 runs := 0
182 err := s.Add("stopped-job", "stopped job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
183 runs++
184 return nil
185 })
186 require.NoError(t, err)
187
188 s.mu.Lock()
189 s.entries["stopped-job"].nextRun = now.Add(-time.Minute)
190 s.mu.Unlock()
191
192 s.Stop()
193 s.Start(context.Background())
194
195 time.Sleep(50 * time.Millisecond)
196 assert.Equal(t, 0, runs)
197 assert.False(t, s.started.Load())
198}
199
200func TestSchedulerRunNowComputesNextRunFromJobFinishTime(t *testing.T) {
201 s := NewScheduler(zerowrap.Default())
202 current := time.Date(2026, 2, 7, 12, 59, 0, 0, time.UTC)
203 s.nowFn = func() time.Time { return current }
204
205 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
206 current = time.Date(2026, 2, 7, 13, 1, 0, 0, time.UTC)
207 return nil
208 })
209 require.NoError(t, err)
210
211 err = s.RunNow(context.Background(), "backup-hourly")
212 require.NoError(t, err)
213
214 entries := s.List()
215 require.Len(t, entries, 1)
216 assert.Equal(t, time.Date(2026, 2, 7, 14, 0, 0, 0, time.UTC), entries[0].NextRun)
217}
218
219func TestSchedulerStartNoOpWhenContextAlreadyCanceled(t *testing.T) {
220 s := NewScheduler(zerowrap.Default())
221 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
222 s.nowFn = func() time.Time { return now }
223
224 runs := 0
225 err := s.Add("canceled-job", "canceled job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
226 runs++
227 return nil
228 })
229 require.NoError(t, err)
230
231 s.mu.Lock()
232 s.entries["canceled-job"].nextRun = now.Add(-time.Minute)
233 s.mu.Unlock()
234
235 ctx, cancel := context.WithCancel(context.Background())
236 cancel()
237
238 s.Start(ctx)
239 time.Sleep(50 * time.Millisecond)
240
241 assert.Equal(t, 0, runs)
242 assert.False(t, s.started.Load())
243}