v3.5.0 8345e76

scheduler_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close