Files
navidrome/scheduler/crontab_schedule_test.go
T
Deluan 2a43c4683e chore: go fix
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-28 22:13:05 -03:00

195 lines
5.8 KiB
Go

package scheduler
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/robfig/cron/v3"
)
var _ = Describe("ParseCrontab", func() {
Describe("standard expressions", func() {
It("parses a 5-field expression", func() {
sched, err := ParseCrontab("5 * * * *")
Expect(err).ToNot(HaveOccurred())
Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{}))
})
It("parses a 6-field expression with seconds", func() {
sched, err := ParseCrontab("30 5 * * * *")
Expect(err).ToNot(HaveOccurred())
Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{}))
})
It("converts duration string to @every", func() {
sched, err := ParseCrontab("5m")
Expect(err).ToNot(HaveOccurred())
Expect(sched).To(BeAssignableToTypeOf(cron.ConstantDelaySchedule{}))
})
It("returns error for empty string", func() {
_, err := ParseCrontab("")
Expect(err).To(HaveOccurred())
})
})
Describe("random ~ syntax", func() {
It("resolves A~B to a value within range", func() {
sched, err := ParseCrontab("0~30 * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 0))
Expect(minute).To(BeNumerically("<=", 30))
})
It("resolves ~ alone to full field range", func() {
sched, err := ParseCrontab("~ * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 0))
Expect(minute).To(BeNumerically("<=", 59))
})
It("resolves ~B as min~B", func() {
sched, err := ParseCrontab("~15 * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 0))
Expect(minute).To(BeNumerically("<=", 15))
})
It("resolves A~ as A~max", func() {
sched, err := ParseCrontab("15~ * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
minute := findSetBit(spec.Minute)
Expect(minute).To(BeNumerically(">=", 15))
Expect(minute).To(BeNumerically("<=", 59))
})
It("resolves multiple random fields independently", func() {
sched, err := ParseCrontab("0~30 0~12 * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
Expect(findSetBit(spec.Minute)).To(BeNumerically("<=", 30))
Expect(findSetBit(spec.Hour)).To(BeNumerically("<=", 12))
})
It("resolves ~ in DOM field with correct bounds", func() {
sched, err := ParseCrontab("0 0 ~ * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
dom := findSetBit(spec.Dom)
Expect(dom).To(BeNumerically(">=", 1))
Expect(dom).To(BeNumerically("<=", 31))
})
It("resolves ~ in month field with correct bounds", func() {
sched, err := ParseCrontab("0 0 1 ~ *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
month := findSetBit(spec.Month)
Expect(month).To(BeNumerically(">=", 1))
Expect(month).To(BeNumerically("<=", 12))
})
It("resolves ~ in DOW field with correct bounds", func() {
sched, err := ParseCrontab("0 0 * * ~")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
dow := findSetBit(spec.Dow)
Expect(dow).To(BeNumerically(">=", 0))
Expect(dow).To(BeNumerically("<=", 6))
})
It("preserves TZ= prefix through resolution", func() {
sched, err := ParseCrontab("TZ=America/New_York 0~30 * * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
nyc, _ := time.LoadLocation("America/New_York")
Expect(spec.Location).To(Equal(nyc))
})
It("preserves non-random fields", func() {
sched, err := ParseCrontab("0~30 10 * * *")
Expect(err).ToNot(HaveOccurred())
spec := sched.(*cron.SpecSchedule)
Expect(spec.Hour & (1 << 10)).ToNot(BeZero())
})
It("resolves to a stable value across repeated Next calls", func() {
sched, err := ParseCrontab("0~30 * * * *")
Expect(err).ToNot(HaveOccurred())
ref := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
first := sched.Next(ref)
for range 50 {
Expect(sched.Next(ref)).To(Equal(first))
}
})
})
Describe("error cases", func() {
It("rejects min > max", func() {
_, err := ParseCrontab("30~0 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("beyond end"))
})
It("rejects value above field maximum", func() {
_, err := ParseCrontab("0~60 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("above maximum"))
})
It("rejects value below field minimum", func() {
_, err := ParseCrontab("0 0 0~15 * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("below minimum"))
})
It("rejects ~ mixed with comma (list)", func() {
_, err := ParseCrontab("0~30,45 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot be combined"))
})
It("rejects ~ mixed with slash (step)", func() {
_, err := ParseCrontab("0~30/5 * * * *")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot be combined"))
})
It("rejects @ descriptor with ~", func() {
_, err := ParseCrontab("@every 0~30m")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("descriptor"))
})
It("rejects wrong number of fields", func() {
_, err := ParseCrontab("0~30 * *")
Expect(err).To(HaveOccurred())
})
It("rejects non-numeric range values", func() {
_, err := ParseCrontab("a~b * * * *")
Expect(err).To(HaveOccurred())
})
})
})
// findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63).
func findSetBit(v uint64) int {
v &^= 1 << 63 // clear starBit
for i := range 63 {
if v&(1<<uint(i)) != 0 {
return i
}
}
return -1
}