v2.30.0-pre4 15d9fe3

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

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