detached 98d36bf

ci_cron_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"bnema.dev/ermite/internal/domain"
13)
14
15func at(t *testing.T, s string) time.Time {
16	t.Helper()
17	v, err := time.Parse("2006-01-02 15:04", s)
18	require.NoError(t, err)
19	return v
20}
21
22func TestCronMatches(t *testing.T) {
23	tests := []struct {
24		spec  string
25		yes   []string
26		no    []string
27		extra string
28	}{
29		{"* * * * *", []string{"2026-03-04 05:06", "2026-12-31 23:59"}, nil, ""},
30		{"*/15 * * * *", []string{"2026-03-04 05:00", "2026-03-04 05:45"}, []string{"2026-03-04 05:46", "2026-03-04 05:14"}, ""},
31		{"30 4 * * *", []string{"2026-03-04 04:30"}, []string{"2026-03-04 04:31", "2026-03-04 05:30"}, ""},
32		{"0 0 1 1 *", []string{"2027-01-01 00:00"}, []string{"2027-01-02 00:00", "2027-02-01 00:00"}, ""},
33		{"5,10-12 */6 * * *", []string{"2026-03-04 06:05", "2026-03-04 12:11"}, []string{"2026-03-04 06:06", "2026-03-04 07:05"}, ""},
34		{"10/20 * * * *", []string{"2026-03-04 00:10", "2026-03-04 00:30", "2026-03-04 00:50"}, []string{"2026-03-04 00:00", "2026-03-04 00:20"}, "a/n runs from a to the maximum"},
35		{"0 12 * JAN-mar mon-fri", []string{"2026-02-17 12:00"}, []string{"2026-02-21 12:00", "2026-04-01 12:00"}, "names in any case"},
36		{"0 0 * * 0", []string{"2026-03-01 00:00"}, []string{"2026-03-02 00:00"}, "0 is Sunday"},
37		// Both day fields restricted: either matches (2026-03-13 is a Friday).
38		{"0 0 13 * fri", []string{"2026-03-13 00:00", "2026-03-20 00:00", "2026-04-13 00:00"}, []string{"2026-03-14 00:00"}, "day of month OR day of week"},
39		// One of them a star: both must.
40		{"0 0 13 * *", []string{"2026-03-13 00:00"}, []string{"2026-03-20 00:00"}, ""},
41		{"0 0 * * fri", []string{"2026-03-13 00:00"}, []string{"2026-03-12 00:00"}, ""},
42	}
43	for _, tt := range tests {
44		c, err := domain.ParseCron(tt.spec)
45		require.NoError(t, err, tt.spec)
46		for _, y := range tt.yes {
47			assert.True(t, c.Matches(at(t, y)), "%s at %s %s", tt.spec, y, tt.extra)
48		}
49		for _, n := range tt.no {
50			assert.False(t, c.Matches(at(t, n)), "%s at %s %s", tt.spec, n, tt.extra)
51		}
52	}
53	// Only the UTC minute counts, whatever the zone of the time given.
54	c, err := domain.ParseCron("0 10 * * *")
55	require.NoError(t, err)
56	paris := time.FixedZone("CEST", 2*3600)
57	assert.True(t, c.Matches(time.Date(2026, 6, 1, 12, 0, 0, 0, paris)))
58	assert.False(t, c.Matches(time.Date(2026, 6, 1, 10, 0, 0, 0, paris)))
59}
60
61func TestCronLeapDay(t *testing.T) {
62	c, err := domain.ParseCron("0 0 29 2 *")
63	require.NoError(t, err)
64	assert.True(t, c.Matches(at(t, "2028-02-29 00:00")), "2028 is a leap year")
65	assert.False(t, c.Matches(at(t, "2028-02-28 00:00")))
66	assert.False(t, c.Matches(at(t, "2028-03-01 00:00")))
67	for d := at(t, "2027-02-01 00:00"); d.Month() == time.February; d = d.AddDate(0, 0, 1) {
68		assert.False(t, c.Matches(d), "no 29 February in 2027: %s", d)
69	}
70	any31, err := domain.ParseCron("0 0 31 * *")
71	require.NoError(t, err)
72	assert.True(t, any31.Matches(at(t, "2026-03-31 00:00")))
73	assert.False(t, any31.Matches(at(t, "2026-04-30 00:00")), "no 31st in April")
74}
75
76// A step larger than the field keeps its first value only, and cannot overflow.
77func TestCronHugeSteps(t *testing.T) {
78	for _, spec := range []string{"59/9223372036854775807 * * * *", "*/9223372036854775807 * * * *", "0-30/100 * * * *"} {
79		c, err := domain.ParseCron(spec)
80		require.NoError(t, err, spec)
81		var got []int
82		for m := range 60 {
83			if c.Matches(at(t, "2026-03-04 05:00").Add(time.Duration(m) * time.Minute)) {
84				got = append(got, m)
85			}
86		}
87		switch spec[:2] {
88		case "59":
89			assert.Equal(t, []int{59}, got, spec)
90		default:
91			assert.Equal(t, []int{0}, got, spec)
92		}
93	}
94	_, err := domain.ParseCron("*/99999999999999999999 * * * *")
95	require.Error(t, err, "beyond an int64")
96}
97
98func TestParseCronRefuses(t *testing.T) {
99	for _, bad := range []string{
100		"", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * 32 * *", "* * * 13 *", "* * * * 7",
101		"*/0 * * * *", "*/x * * * *", "5-3 * * * *", "1,,2 * * * *", "-1 * * * *", "1- * * * *", "foo * * * *", "* * * jun-foo *", "+5 * * * *", "0x5 * * * *",
102	} {
103		_, err := domain.ParseCron(bad)
104		require.Error(t, err, bad)
105		assert.ErrorIs(t, err, domain.ErrInvalid, bad)
106	}
107}
108
109func TestParseCronTimezone(t *testing.T) {
110	for _, ok := range []string{"", "UTC", "America/New_York", "Asia/Kolkata", "Europe/Paris"} {
111		_, err := domain.ParseCronTimezone(ok)
112		assert.NoError(t, err, ok)
113	}
114	for _, bad := range []string{"Mars/Olympus", "Local", "/etc/localtime", "../zoneinfo/UTC", "new york"} {
115		_, err := domain.ParseCronTimezone(bad)
116		assert.ErrorIs(t, err, domain.ErrInvalid, bad)
117	}
118}
119
120// firesOn returns the UTC minutes of the day (from start, for span) at which
121// the schedule is due.
122func firesOn(t *testing.T, spec, zone string, start time.Time, span time.Duration) []string {
123	t.Helper()
124	c, err := domain.ParseCron(spec)
125	require.NoError(t, err)
126	loc, err := domain.ParseCronTimezone(zone)
127	require.NoError(t, err)
128	s := domain.CronSchedule{Cron: c, Loc: loc}
129	var out []string
130	for m := start; m.Before(start.Add(span)); m = m.Add(time.Minute) {
131		if s.Due(m) {
132			out = append(out, m.UTC().Format("2006-01-02 15:04"))
133		}
134	}
135	return out
136}
137
138func TestCronScheduleTimezone(t *testing.T) {
139	day := func(s string) time.Time { return at(t, s+" 00:00") }
140	// Paris in summer is UTC+2.
141	assert.Equal(t, []string{"2026-06-01 07:00"}, firesOn(t, "0 9 * * *", "Europe/Paris", day("2026-06-01"), 24*time.Hour))
142	// No timezone: UTC.
143	assert.Equal(t, []string{"2026-06-01 09:00"}, firesOn(t, "0 9 * * *", "", day("2026-06-01"), 24*time.Hour))
144	// The day of week is the zone's: Monday 00:30 in Tokyo is Sunday 15:30 UTC.
145	assert.Equal(t, []string{"2026-05-31 15:30"}, firesOn(t, "30 0 * * mon", "Asia/Tokyo", day("2026-05-31"), 24*time.Hour))
146}
147
148// New York springs forward on 2026-03-08 (02:00 EST becomes 03:00 EDT) and
149// falls back on 2026-11-01 (02:00 EDT becomes 01:00 EST).
150func TestCronScheduleDST(t *testing.T) {
151	ny := "America/New_York"
152	spring, fall := at(t, "2026-03-08 04:00"), at(t, "2026-11-01 03:00") // local midnight, UTC
153
154	// A time the transition skips runs at the next valid time, 03:00 EDT
155	// (GitHub's docs: "a 2:30 AM schedule advances to 3:00 AM"), once.
156	assert.Equal(t, []string{"2026-03-08 07:00"}, firesOn(t, "30 2 * * *", ny, spring, 24*time.Hour))
157	assert.Equal(t, []string{"2026-03-09 06:30"}, firesOn(t, "30 2 * * *", ny, spring.Add(24*time.Hour), 24*time.Hour), "the day after: 02:30 EDT")
158	// Times around the gap are not moved, and 03:00 runs once.
159	assert.Equal(t, []string{"2026-03-08 06:59"}, firesOn(t, "59 1 * * *", ny, spring, 24*time.Hour))
160	assert.Equal(t, []string{"2026-03-08 07:00"}, firesOn(t, "0 3 * * *", ny, spring, 24*time.Hour))
161	assert.Equal(t, []string{"2026-03-08 07:00", "2026-03-08 07:15", "2026-03-08 07:30", "2026-03-08 07:45"},
162		firesOn(t, "*/15 2-3 * * *", ny, spring, 24*time.Hour), "the skipped 02:00-02:45 entries run once at 03:00, then 03:15-03:45")
163	// Hourly: 23 hours that day.
164	assert.Len(t, firesOn(t, "0 * * * *", ny, spring, 23*time.Hour), 23)
165
166	// A time that happens twice runs once, at its first occurrence (EDT).
167	assert.Equal(t, []string{"2026-11-01 05:30"}, firesOn(t, "30 1 * * *", ny, fall, 25*time.Hour))
168	assert.Equal(t, []string{"2026-11-01 07:00"}, firesOn(t, "0 2 * * *", ny, fall, 25*time.Hour), "02:00 EST, once")
169	// Hourly: 24 runs over the 25 hours of that day, none at 01:00 EST.
170	hourly := firesOn(t, "0 * * * *", ny, fall, 25*time.Hour)
171	assert.Len(t, hourly, 24)
172	assert.Contains(t, hourly, "2026-11-01 05:00")
173	assert.NotContains(t, hourly, "2026-11-01 06:00")
174}

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 4

Tags 45

↑↓ moveenter openesc close