Skip to content

Commit 1892c9a

Browse files
fix(calendar): OR day-of-month and day-of-week when both restricted (#644)
* fix(calendar): OR day-of-month and day-of-week when both restricted * test(calendar): cover DOM/DOW OR semantics in CronSchedule.matches Pin the new behavior so future regressions to AND semantics are caught: the canonical "0 9 1 * 1" OR case, DOM-only and DOW-only specs ignoring the unrestricted axis, an unrestricted-day wildcard, and a month-restricted spec confirming month still short-circuits regardless of day fields. * test(calendar): cover both-DOM-and-DOW match in OR test --------- Co-authored-by: Fran Dias <frandias@gmail.com> Co-authored-by: Francisco Dias <FranDias@users.noreply.github.com>
1 parent eae4576 commit 1892c9a

2 files changed

Lines changed: 143 additions & 5 deletions

File tree

internal/calendar/cron.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,24 @@ func (cs CronSchedule) matches(t time.Time) bool {
116116
if len(cs.Hours) > 0 && !contains(cs.Hours, t.Hour()) {
117117
return false
118118
}
119-
if len(cs.DaysOfMonth) > 0 && !contains(cs.DaysOfMonth, t.Day()) {
120-
return false
121-
}
122119
if len(cs.Months) > 0 && !contains(cs.Months, int(t.Month())) {
123120
return false
124121
}
125-
if len(cs.DaysOfWeek) > 0 && !contains(cs.DaysOfWeek, int(t.Weekday())) {
126-
return false
122+
domRestricted := len(cs.DaysOfMonth) > 0
123+
dowRestricted := len(cs.DaysOfWeek) > 0
124+
switch {
125+
case domRestricted && dowRestricted:
126+
if !contains(cs.DaysOfMonth, t.Day()) && !contains(cs.DaysOfWeek, int(t.Weekday())) {
127+
return false
128+
}
129+
case domRestricted:
130+
if !contains(cs.DaysOfMonth, t.Day()) {
131+
return false
132+
}
133+
case dowRestricted:
134+
if !contains(cs.DaysOfWeek, int(t.Weekday())) {
135+
return false
136+
}
127137
}
128138
return true
129139
}

internal/calendar/cron_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package calendar
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
// 2026-03-01 is a Sunday, 2026-03-02 a Monday, 2026-03-03 a Tuesday.
9+
// Pinning these dates keeps the day-of-week math obvious in the cases below.
10+
11+
func TestMatches_DOMandDOW_ORWhenBothRestricted(t *testing.T) {
12+
sched, err := ParseCron("0 9 1 * 1")
13+
if err != nil {
14+
t.Fatalf("ParseCron error: %v", err)
15+
}
16+
17+
cases := []struct {
18+
name string
19+
when time.Time
20+
want bool
21+
}{
22+
{"first of month, not Monday", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC), true},
23+
{"Monday, not first of month", time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC), true},
24+
// 2026-06-01 is both the 1st and a Monday; both fields match, OR still true.
25+
{"both dom and dow match", time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC), true},
26+
{"neither dom nor dow", time.Date(2026, 3, 3, 9, 0, 0, 0, time.UTC), false},
27+
{"correct day, wrong hour", time.Date(2026, 3, 2, 8, 0, 0, 0, time.UTC), false},
28+
{"correct day, wrong minute", time.Date(2026, 3, 2, 9, 30, 0, 0, time.UTC), false},
29+
}
30+
for _, tc := range cases {
31+
t.Run(tc.name, func(t *testing.T) {
32+
if got := sched.matches(tc.when); got != tc.want {
33+
t.Errorf("matches(%v) = %v, want %v", tc.when, got, tc.want)
34+
}
35+
})
36+
}
37+
}
38+
39+
func TestMatches_DOMOnly_IgnoresWeekday(t *testing.T) {
40+
sched, err := ParseCron("0 9 15 * *")
41+
if err != nil {
42+
t.Fatalf("ParseCron error: %v", err)
43+
}
44+
45+
cases := []struct {
46+
name string
47+
when time.Time
48+
want bool
49+
}{
50+
{"15th, any weekday", time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC), true}, // Sunday
51+
{"15th, weekday again", time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), true},
52+
{"not 15th", time.Date(2026, 3, 16, 9, 0, 0, 0, time.UTC), false},
53+
}
54+
for _, tc := range cases {
55+
t.Run(tc.name, func(t *testing.T) {
56+
if got := sched.matches(tc.when); got != tc.want {
57+
t.Errorf("matches(%v) = %v, want %v", tc.when, got, tc.want)
58+
}
59+
})
60+
}
61+
}
62+
63+
func TestMatches_DOWOnly_IgnoresDayOfMonth(t *testing.T) {
64+
sched, err := ParseCron("0 9 * * 1")
65+
if err != nil {
66+
t.Fatalf("ParseCron error: %v", err)
67+
}
68+
69+
cases := []struct {
70+
name string
71+
when time.Time
72+
want bool
73+
}{
74+
{"Monday the 2nd", time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC), true},
75+
{"Monday the 9th", time.Date(2026, 3, 9, 9, 0, 0, 0, time.UTC), true},
76+
{"Sunday", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC), false},
77+
}
78+
for _, tc := range cases {
79+
t.Run(tc.name, func(t *testing.T) {
80+
if got := sched.matches(tc.when); got != tc.want {
81+
t.Errorf("matches(%v) = %v, want %v", tc.when, got, tc.want)
82+
}
83+
})
84+
}
85+
}
86+
87+
func TestMatches_NoDayRestrictions_FiresEveryDay(t *testing.T) {
88+
sched, err := ParseCron("0 9 * * *")
89+
if err != nil {
90+
t.Fatalf("ParseCron error: %v", err)
91+
}
92+
93+
for day := 1; day <= 7; day++ {
94+
when := time.Date(2026, 3, day, 9, 0, 0, 0, time.UTC)
95+
if !sched.matches(when) {
96+
t.Errorf("matches(%v) = false, want true (wildcard day fields)", when)
97+
}
98+
}
99+
if sched.matches(time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)) {
100+
t.Errorf("matches at wrong hour returned true")
101+
}
102+
}
103+
104+
func TestMatches_MonthRestrictionShortCircuits(t *testing.T) {
105+
// June only, OR semantics on the day fields should not rescue a non-June time.
106+
sched, err := ParseCron("0 9 1 6 1")
107+
if err != nil {
108+
t.Fatalf("ParseCron error: %v", err)
109+
}
110+
111+
cases := []struct {
112+
name string
113+
when time.Time
114+
want bool
115+
}{
116+
{"June 1st", time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC), true},
117+
{"June Monday not 1st", time.Date(2026, 6, 8, 9, 0, 0, 0, time.UTC), true},
118+
{"March 1st (wrong month, would match dom)", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC), false},
119+
{"March Monday (wrong month, would match dow)", time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC), false},
120+
}
121+
for _, tc := range cases {
122+
t.Run(tc.name, func(t *testing.T) {
123+
if got := sched.matches(tc.when); got != tc.want {
124+
t.Errorf("matches(%v) = %v, want %v", tc.when, got, tc.want)
125+
}
126+
})
127+
}
128+
}

0 commit comments

Comments
 (0)