v3.0.0 f19f453

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 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}

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