Files
mesh-host/internal/declaration/cron_test.go
T
jschoubben f5cf9510c1 One kind for the module's own code, with three modes
The first cut of this added a `daemon` for the long-running case alone. That
would have meant a new vocabulary entry for each of the others — a scheduled
task, a run-once migration, a health check — when they are one thing run at
different cadences. That is a field, not four entries in a vocabulary where every
entry widens what a compromised control plane can express.

So it mirrors a container exactly, because it IS a container's twin: the same
intent, hosted by the machine's own supervisor instead of a runtime. Stays up,
runs once, or runs on a schedule.

Tools, hooks and event consumers are not further modes. They are loaded by a tool
host, which is itself a process that stays up — so the generic case already
covers them, which is the test of whether it is generic.

A scheduled process gets a timer and a unit that finishes; a long-running one
gets a unit that is restarted when it exits. Getting that wrong either way is a
second copy running continuously between fires, or a schedule that never fires.
The modes are exclusive and validation says so near the author: something that
runs once does not run on a schedule, and something not running between fires
cannot be restarted when a file changes.

A missed fire happens when the machine comes back rather than being skipped,
which is the difference between a machine that was down and a schedule that
quietly stopped.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 10:26:31 +02:00

101 lines
3.3 KiB
Go

package declaration
import (
"testing"
"time"
)
// A scheduled step is due when its cron says so, and the host both refuses a malformed cron on
// arrival and computes the next due minute the scheduler fires on (novox/hq ADR 0053). These test
// the parsing and the evaluation; the firing is tested against the scheduler.
func TestParseCronRefusesMalformed(t *testing.T) {
for _, expr := range []string{
"", // nothing
"* * * *", // four fields
"* * * * * *", // six fields
"60 * * * *", // minute out of range
"* 24 * * *", // hour out of range
"* * 0 * *", // day-of-month below 1
"* * 32 * *", // day-of-month above 31
"* * * 13 *", // month out of range
"* * * * 8", // day-of-week above 7
"a * * * *", // not a number
"*/0 * * * *", // zero step
"5-1 * * * *", // inverted range
"1,,2 * * * *", // empty element
} {
if _, err := ParseCron(expr); err == nil {
t.Errorf("a malformed cron %q was accepted", expr)
}
}
}
func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
for _, expr := range []string{
"* * * * *", // every minute
"0 3 * * *", // 03:00 daily
"*/15 * * * *", // every 15 minutes
"0 0 1 1 *", // new year
"0 9-17 * * 1-5", // business hours, weekdays
"0 0 * * 7", // Sunday as 7
"0,30 * * * *", // twice an hour
} {
if _, err := ParseCron(expr); err != nil {
t.Errorf("a valid cron %q was refused: %v", expr, err)
}
}
}
func TestCronNextIsTheNextMatchingMinute(t *testing.T) {
// A Monday at 12:00:30, so "next" must round up to a whole minute and land on the first match
// strictly after now — never re-firing the minute we are already in.
now := time.Date(2026, 9, 7, 12, 0, 30, 0, time.UTC) // 2026-09-07 is a Monday
cases := []struct {
expr string
want time.Time
}{
{"* * * * *", time.Date(2026, 9, 7, 12, 1, 0, 0, time.UTC)},
{"0 3 * * *", time.Date(2026, 9, 8, 3, 0, 0, 0, time.UTC)},
{"*/15 * * * *", time.Date(2026, 9, 7, 12, 15, 0, 0, time.UTC)},
{"0 0 1 1 *", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)},
}
for _, c := range cases {
cron, err := ParseCron(c.expr)
if err != nil {
t.Fatalf("%q: %v", c.expr, err)
}
got, ok := cron.Next(now)
if !ok {
t.Errorf("%q: no next time found", c.expr)
continue
}
if !got.Equal(c.want) {
t.Errorf("%q: next is %s, want %s", c.expr, got, c.want)
}
}
}
func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
// The standard cron surprise: when day-of-month and day-of-week are both set, a day matches if
// EITHER does. "1 * 13 * 5" is due on the 13th OR on any Friday. 2026-09-07 is a Monday the 7th
// — neither — and 2026-09-11 is a Friday, and 2026-11-13 is the 13th.
cron, err := ParseCron("0 0 13 * 5")
if err != nil {
t.Fatal(err)
}
friday := time.Date(2026, 9, 11, 0, 0, 0, 0, time.UTC)
if !cron.Matches(friday) {
t.Error("a Friday did not match a cron restricted to the 13th OR Friday")
}
thirteenth := time.Date(2026, 11, 13, 0, 0, 0, 0, time.UTC) // a non-Friday 13th
if !cron.Matches(thirteenth) {
t.Error("the 13th did not match a cron restricted to the 13th OR Friday")
}
neither := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
if cron.Matches(neither) {
t.Error("a Monday the 7th matched a cron restricted to the 13th OR Friday")
}
}