package storage import ( "strings" "testing" "time" "hamlab/internal/cron" ) func TestSnapTimeReadsZfSnapNames(t *testing.T) { // the real thing off grid taken, exp, ok := SnapTime("tank@2026-09-24_02.30.01--6w") if !ok { t.Fatal("zfSnap's own name must parse") } if taken.Format("2006-01-02 15:04:05") != "2026-09-24 02:30:01" { t.Fatalf("taken = %v", taken) } if got := exp.Sub(taken); got != 6*7*24*time.Hour { t.Fatalf("ttl = %v, want 6w", got) } // a labelled one from a policy with a prefix if _, e, ok := SnapTime("tank/apps@nightly-2026-09-24_02.30.01--30d"); !ok || e.IsZero() { t.Fatal("a prefixed name must parse and expire") } // no TTL: never expires if _, e, ok := SnapTime("tank@2026-09-24_02.30.01"); !ok || !e.IsZero() { t.Fatal("a name without a TTL must parse and never expire") } // things that are not ours for _, n := range []string{"tank@before-upgrade", "tank@manual", "tank", "tank@2026-13-45_99.99.99--6w"} { if _, _, ok := SnapTime(n); ok { t.Errorf("%q must not parse as a scheduled snapshot", n) } } } func TestCronExprAndNextRun(t *testing.T) { now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local) for _, c := range []struct { p Policy want string }{ {Policy{Every: "daily", At: "02:30"}, "30 2 * * *"}, {Policy{Every: "hourly", At: "00:30"}, "30 * * * *"}, {Policy{Every: "weekly", At: "04:00", Weekday: 0}, "0 4 * * 0"}, {Policy{Every: "monthly", At: "05:15", Day: 12}, "15 5 12 * *"}, {Policy{Every: "custom", Cron: "*/15 9-17 * * 1-5"}, "*/15 9-17 * * 1-5"}, } { got, err := c.p.CronExpr() if err != nil || got != c.want { t.Errorf("CronExpr(%+v) = %q, %v; want %q", c.p.Every, got, err, c.want) } } // the next run is read with the same evaluator the Jobs tab uses daily := Policy{Every: "daily", At: "02:30"} if n := daily.NextRun(now); n.Hour() != 2 || n.Minute() != 30 || n.Day() != 25 { t.Fatalf("NextRun = %v, want 25th 02:30", n) } // a custom expression that does not parse is refused, not silently ignored if _, err := (&Policy{Every: "custom", Cron: "not a schedule"}).CronExpr(); err == nil { t.Fatal("a bad cron expression must be refused") } if _, err := (&Policy{Every: "custom", Cron: "@reboot"}).CronExpr(); err == nil { t.Fatal("@reboot is not a snapshot schedule") } } func TestCronFileText(t *testing.T) { ps := []Policy{ {ID: "abc", Name: "tank nightly", Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Enabled: true}, {ID: "def", Name: "paused one", Dataset: "tank/apps", Every: "hourly", At: "00:05", KeepFor: "2d"}, } text, err := CronFileText(ps, "core", "/usr/local/bin/hamlab", "/home/core/apps/hamlab/config.json") if err != nil { t.Fatal(err) } if !strings.Contains(text, "30 2 * * * core /usr/local/bin/hamlab -config /home/core/apps/hamlab/config.json zfs-snapshot abc") { t.Fatalf("the enabled schedule is missing or malformed:\n%s", text) } if strings.Contains(text, "zfs-snapshot def") { t.Fatalf("a paused schedule must not be scheduled:\n%s", text) } // every line cron will read must parse as a schedule for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") || strings.Contains(line, "=") { continue } f := strings.Fields(line) if err := cron.ValidSchedule(strings.Join(f[:5], " ")); err != nil { t.Errorf("line %q: %v", line, err) } } } func TestValidateRefusesNonsense(t *testing.T) { ok := Policy{Dataset: "tank/apps", Every: "daily", At: "02:30", KeepFor: "6w"} if err := ok.Validate(); err != nil { t.Fatalf("a sane policy: %v", err) } bad := []Policy{ {Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"}, {Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"}, {Dataset: "tank", Every: "custom", KeepFor: "6w"}, {Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"}, {Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"}, {Dataset: "tank", Every: "daily", At: "02:30"}, // no retention {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable {Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"}, } for i, p := range bad { if err := p.Validate(); err == nil { t.Errorf("bad policy %d was accepted: %+v", i, p) } } } func TestParseAge(t *testing.T) { for s, want := range map[string]time.Duration{ "6w": 6 * 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, "12h": 12 * time.Hour, "90m": 90 * time.Minute, } { if got, err := ParseAge(s); err != nil || got != want { t.Errorf("ParseAge(%q) = %v, %v; want %v", s, got, err, want) } } for _, s := range []string{"", "6", "w", "-1d", "0w", "6y", "six weeks"} { if _, err := ParseAge(s); err == nil { t.Errorf("ParseAge(%q) should fail", s) } } }