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}