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 TestCalculateNextRunAtLocalTime(t *testing.T) {
55 paris, err := time.LoadLocation("Europe/Paris")
56 require.NoError(t, err)
57 at := &domain.TimeOfDay{Hour: 3, Minute: 30}
58
59 tests := []struct {
60 name string
61 preset domain.BackupSchedule
62 now time.Time
63 expected time.Time
64 }{
65 {"daily later today", domain.ScheduleDaily,
66 time.Date(2026, 10, 17, 1, 0, 0, 0, paris), time.Date(2026, 10, 17, 3, 30, 0, 0, paris)},
67 {"daily already passed", domain.ScheduleDaily,
68 time.Date(2026, 10, 17, 7, 24, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
69 {"daily exactly now runs tomorrow", domain.ScheduleDaily,
70 time.Date(2026, 10, 17, 3, 30, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
71 // 2026-10-25: Paris leaves summer time at 03:00, so 03:30 is CET.
72 {"daily across DST end keeps wall clock", domain.ScheduleDaily,
73 time.Date(2026, 10, 24, 12, 0, 0, 0, paris), time.Date(2026, 10, 25, 3, 30, 0, 0, paris)},
74 {"weekly next sunday", domain.ScheduleWeekly,
75 time.Date(2026, 10, 17, 12, 0, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
76 {"monthly next first day", domain.ScheduleMonthly,
77 time.Date(2026, 10, 17, 12, 0, 0, 0, paris), time.Date(2026, 11, 1, 3, 30, 0, 0, paris)},
78 {"hourly uses minute only", domain.ScheduleHourly,
79 time.Date(2026, 10, 17, 12, 40, 0, 0, paris), time.Date(2026, 10, 17, 13, 30, 0, 0, paris)},
80 }
81 for _, tt := range tests {
82 t.Run(tt.name, func(t *testing.T) {
83 next, err := calculateNextRun(tt.now, domain.CronSchedule{Preset: tt.preset, At: at, Location: paris})
84 require.NoError(t, err)
85 assert.True(t, tt.expected.Equal(next), "want %s, got %s", tt.expected, next.In(paris))
86 })
87 }
88}
89
90func TestCalculateNextRunDSTGapSkipsForward(t *testing.T) {
91 paris, err := time.LoadLocation("Europe/Paris")
92 require.NoError(t, err)
93 // 2026-03-29: Paris skips 02:00-03:00. A 02:30 schedule runs once, at
94 // the first valid instant after the gap, never twice or never.
95 now := time.Date(2026, 3, 28, 12, 0, 0, 0, paris)
96 next, err := calculateNextRun(now, domain.CronSchedule{
97 Preset: domain.ScheduleDaily, At: &domain.TimeOfDay{Hour: 2, Minute: 30}, Location: paris,
98 })
99 require.NoError(t, err)
100 // First valid instant after the gap: 03:00 CEST = 01:00 UTC.
101 assert.True(t, time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC).Equal(next), "got %s", next.In(paris))
102}
103
104func TestCalculateNextRunDSTEdgeCasesNewYork(t *testing.T) {
105 ny, err := time.LoadLocation("America/New_York")
106 require.NoError(t, err)
107 at := &domain.TimeOfDay{Hour: 2, Minute: 30}
108 // 2026-03-08: New York jumps from 02:00 EST to 03:00 EDT (07:00 UTC).
109 gapEnd := time.Date(2026, 3, 8, 7, 0, 0, 0, time.UTC)
110 // 2026-11-01: 01:00-02:00 happens twice (EDT then EST).
111 firstOneFortyFive := time.Date(2026, 11, 1, 5, 45, 0, 0, time.UTC) // 01:45 EDT
112 secondOneFortyFive := time.Date(2026, 11, 1, 6, 45, 0, 0, time.UTC) // 01:45 EST
113
114 tests := []struct {
115 name string
116 preset domain.BackupSchedule
117 at *domain.TimeOfDay
118 now time.Time
119 expected time.Time
120 }{
121 {"daily in gap from same morning runs at gap end", domain.ScheduleDaily, at,
122 time.Date(2026, 3, 8, 6, 45, 0, 0, time.UTC), gapEnd}, // now 01:45 EST
123 {"daily in gap from previous day runs at gap end", domain.ScheduleDaily, at,
124 time.Date(2026, 3, 7, 17, 0, 0, 0, time.UTC), gapEnd},
125 {"daily after gap end runs next day", domain.ScheduleDaily, at,
126 gapEnd, time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC)},
127 {"weekly in gap runs at gap end", domain.ScheduleWeekly, at,
128 time.Date(2026, 3, 7, 17, 0, 0, 0, time.UTC), gapEnd},
129 {"hourly in repeated hour, first pass", domain.ScheduleHourly, &domain.TimeOfDay{Minute: 30},
130 firstOneFortyFive, time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)}, // 01:30 EST
131 {"hourly in repeated hour, second pass", domain.ScheduleHourly, &domain.TimeOfDay{Minute: 30},
132 secondOneFortyFive, time.Date(2026, 11, 1, 7, 30, 0, 0, time.UTC)}, // 02:30 EST
133 {"daily in repeated hour runs next day", domain.ScheduleDaily, &domain.TimeOfDay{Hour: 1, Minute: 30},
134 secondOneFortyFive, time.Date(2026, 11, 2, 6, 30, 0, 0, time.UTC)},
135 }
136 for _, tt := range tests {
137 t.Run(tt.name, func(t *testing.T) {
138 next, err := calculateNextRun(tt.now, domain.CronSchedule{Preset: tt.preset, At: tt.at, Location: ny})
139 require.NoError(t, err)
140 assert.True(t, next.After(tt.now), "next %s must be after now %s", next, tt.now)
141 assert.True(t, tt.expected.Equal(next), "want %s, got %s", tt.expected.In(ny), next.In(ny))
142 })
143 }
144}
145
146func TestCalculateNextRunRejectsMissingPreset(t *testing.T) {
147 _, err := calculateNextRun(time.Now(), domain.CronSchedule{})
148 require.Error(t, err)
149}
150
151func TestSchedulerAddListAndRunNow(t *testing.T) {
152 s := NewScheduler(zerowrap.Default())
153 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
154 s.nowFn = func() time.Time { return now }
155
156 runs := 0
157 err := s.Add("backup-daily", "daily backup", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
158 runs++
159 return nil
160 })
161 require.NoError(t, err)
162
163 entries := s.List()
164 require.Len(t, entries, 1)
165 assert.Equal(t, "backup-daily", entries[0].ID)
166 assert.Equal(t, "daily backup", entries[0].Name)
167
168 err = s.RunNow(context.Background(), "backup-daily")
169 require.NoError(t, err)
170 assert.Equal(t, 1, runs)
171
172 entries = s.List()
173 require.Len(t, entries, 1)
174 assert.Equal(t, now, entries[0].LastRun)
175 assert.Equal(t, time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC), entries[0].NextRun)
176}
177
178func TestSchedulerWithMaxJobsOption(t *testing.T) {
179 s := NewScheduler(zerowrap.Default(), WithMaxJobs(9))
180 assert.Equal(t, 9, s.maxJobs)
181}
182
183func TestSchedulerRemoveRejectsRunningJob(t *testing.T) {
184 s := NewScheduler(zerowrap.Default())
185
186 started := make(chan struct{})
187 release := make(chan struct{})
188 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
189 close(started)
190 <-release
191 return nil
192 })
193 require.NoError(t, err)
194
195 runErrCh := make(chan error, 1)
196 go func() {
197 runErrCh <- s.RunNow(context.Background(), "backup-hourly")
198 }()
199
200 <-started
201 err = s.Remove("backup-hourly")
202 require.Error(t, err)
203 assert.ErrorIs(t, err, ErrJobRunning)
204
205 close(release)
206 require.NoError(t, <-runErrCh)
207}
208
209func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) {
210 s := NewScheduler(zerowrap.Default())
211
212 started := make(chan struct{})
213 release := make(chan struct{})
214 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
215 close(started)
216 <-release
217 return nil
218 })
219 require.NoError(t, err)
220
221 var wg sync.WaitGroup
222 firstErrCh := make(chan error, 1)
223 wg.Add(1)
224 go func() {
225 defer wg.Done()
226 firstErrCh <- s.RunNow(context.Background(), "backup-hourly")
227 }()
228
229 <-started
230 err2 := s.RunNow(context.Background(), "backup-hourly")
231 assert.Error(t, err2)
232
233 close(release)
234 wg.Wait()
235 err1 := <-firstErrCh
236 require.NoError(t, err1)
237}
238
239func TestSchedulerRunNowRecoversFromPanics(t *testing.T) {
240 s := NewScheduler(zerowrap.Default())
241 err := s.Add("panic-job", "panic job", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
242 panic("boom")
243 })
244 require.NoError(t, err)
245
246 err = s.RunNow(context.Background(), "panic-job")
247 require.Error(t, err)
248 assert.Contains(t, err.Error(), "panic")
249
250 entries := s.List()
251 require.Len(t, entries, 1)
252 assert.False(t, entries[0].Running)
253 assert.False(t, entries[0].LastRun.IsZero())
254 assert.False(t, entries[0].NextRun.IsZero())
255}
256
257func TestSchedulerStartNoOpWhenStopped(t *testing.T) {
258 s := NewScheduler(zerowrap.Default())
259 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
260 s.nowFn = func() time.Time { return now }
261
262 runs := 0
263 err := s.Add("stopped-job", "stopped job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
264 runs++
265 return nil
266 })
267 require.NoError(t, err)
268
269 s.mu.Lock()
270 s.entries["stopped-job"].nextRun = now.Add(-time.Minute)
271 s.mu.Unlock()
272
273 s.Stop()
274 s.Start(context.Background())
275
276 time.Sleep(50 * time.Millisecond)
277 assert.Equal(t, 0, runs)
278 assert.False(t, s.started.Load())
279}
280
281func TestSchedulerRunNowComputesNextRunFromJobFinishTime(t *testing.T) {
282 s := NewScheduler(zerowrap.Default())
283 current := time.Date(2026, 2, 7, 12, 59, 0, 0, time.UTC)
284 s.nowFn = func() time.Time { return current }
285
286 err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
287 current = time.Date(2026, 2, 7, 13, 1, 0, 0, time.UTC)
288 return nil
289 })
290 require.NoError(t, err)
291
292 err = s.RunNow(context.Background(), "backup-hourly")
293 require.NoError(t, err)
294
295 entries := s.List()
296 require.Len(t, entries, 1)
297 assert.Equal(t, time.Date(2026, 2, 7, 14, 0, 0, 0, time.UTC), entries[0].NextRun)
298}
299
300func TestSchedulerStartNoOpWhenContextAlreadyCanceled(t *testing.T) {
301 s := NewScheduler(zerowrap.Default())
302 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
303 s.nowFn = func() time.Time { return now }
304
305 runs := 0
306 err := s.Add("canceled-job", "canceled job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
307 runs++
308 return nil
309 })
310 require.NoError(t, err)
311
312 s.mu.Lock()
313 s.entries["canceled-job"].nextRun = now.Add(-time.Minute)
314 s.mu.Unlock()
315
316 ctx, cancel := context.WithCancel(context.Background())
317 cancel()
318
319 s.Start(ctx)
320 time.Sleep(50 * time.Millisecond)
321
322 assert.Equal(t, 0, runs)
323 assert.False(t, s.started.Load())
324}