Files
3x-ui/internal/util/cronspec/cronspec_test.go
T
MHSanaei b124af2a1b fix(settings): accept five-field crontab for report and LDAP schedules
The panel scheduler runs WithSeconds(), whose parser rejects a standard
five-field crontab such as "30 8 * * *". An admin who typed one into the
Telegram/Discord notify time or the LDAP sync schedule saved it without
error, AddJob then failed at startup, and the job silently never ran.

Admin-typed schedules now go through internal/util/cronspec, which takes
an optional seconds column plus the @descriptors and hands the scheduler
a parsed cron.Schedule. CheckValid rejects an unparseable schedule on
save, and a stored legacy value falls back to the job's default instead
of dropping the job.
2026-10-11 20:37:04 +02:00

48 lines
1.3 KiB
Go

package cronspec
import (
"strings"
"testing"
"time"
)
// #1648 upstream: the panel's scheduler runs WithSeconds(), whose parser refused
// a standard five-field crontab, so an admin's "30 8 * * *" never fired.
func TestParseNextFire(t *testing.T) {
from := time.Date(2026, 10, 11, 7, 0, 0, 0, time.UTC)
cases := []struct {
spec string
want time.Time
}{
{"30 8 * * *", time.Date(2026, 10, 11, 8, 30, 0, 0, time.UTC)},
{" 15 30 8 * * * ", time.Date(2026, 10, 11, 8, 30, 15, 0, time.UTC)},
{"@daily", time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)},
{"@every 90m", from.Add(90 * time.Minute)},
}
for _, c := range cases {
t.Run(c.spec, func(t *testing.T) {
schedule, err := Parse(c.spec)
if err != nil {
t.Fatalf("Parse(%q): %v", c.spec, err)
}
if got := schedule.Next(from); !got.Equal(c.want) {
t.Errorf("Parse(%q).Next = %v, want %v", c.spec, got, c.want)
}
})
}
}
func TestParseRejects(t *testing.T) {
for _, spec := range []string{"", " ", "every day", "61 * * * *", "* * * *"} {
t.Run(spec, func(t *testing.T) {
_, err := Parse(spec)
if err == nil {
t.Fatalf("Parse(%q): want error, got nil", spec)
}
if !strings.Contains(err.Error(), "cron schedule") {
t.Errorf("Parse(%q) error = %q, want it to name the cron schedule", spec, err)
}
})
}
}