package maintenance import ( "context" "errors" "testing" "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // fakeBackend implements Backend with table-driven canned responses. type fakeBackend struct { rows []store.HostRepoMaintenance // jobs[hostID][kind] -> job (if present, returned). If absent, // fakeBackend returns ErrNotFound by default. jobs map[string]map[string]*store.Job // hardErr forces a non-ErrNotFound failure for a given (host, kind). hardErr map[string]map[string]error // listErr forces ListAllMaintenance to fail. listErr error } func (f *fakeBackend) ListAllMaintenance(_ context.Context) ([]store.HostRepoMaintenance, error) { if f.listErr != nil { return nil, f.listErr } return f.rows, nil } func (f *fakeBackend) LatestJobByKind(_ context.Context, hostID, kind string) (*store.Job, error) { if hostErrs, ok := f.hardErr[hostID]; ok { if err := hostErrs[kind]; err != nil { return nil, err } } if hostJobs, ok := f.jobs[hostID]; ok { if j := hostJobs[kind]; j != nil { return j, nil } } return nil, store.ErrNotFound } // mustTime parses an RFC3339 string, fatal on failure. func mustTime(t *testing.T, s string) time.Time { t.Helper() out, err := time.Parse(time.RFC3339, s) if err != nil { t.Fatalf("parse %q: %v", s, err) } return out } func TestTickerSkipsDisabled(t *testing.T) { t.Parallel() be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", ForgetCron: "0 3 * * *", ForgetEnabled: false, PruneCron: "0 4 * * *", PruneEnabled: false, CheckCron: "0 5 * * *", CheckEnabled: false, }}, } tk := New(be) now := mustTime(t, "2026-05-04T04:00:00Z") got, err := tk.Decide(context.Background(), now) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 0 { t.Errorf("expected no decisions, got %+v", got) } } func TestTickerSkipsEmptyCron(t *testing.T) { t.Parallel() be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", ForgetCron: "", ForgetEnabled: true, PruneCron: "", PruneEnabled: true, CheckCron: "", CheckEnabled: true, }}, } tk := New(be) got, err := tk.Decide(context.Background(), mustTime(t, "2026-05-04T04:00:00Z")) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 0 { t.Errorf("expected no decisions, got %+v", got) } } func TestTickerFiresWhenOverdue(t *testing.T) { t.Parallel() now := mustTime(t, "2026-05-04T04:00:00Z") // Latest forget job 25h ago. last := now.Add(-25 * time.Hour) be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", ForgetCron: "0 3 * * *", ForgetEnabled: true, }}, jobs: map[string]map[string]*store.Job{ "h1": {"forget": &store.Job{ID: "j1", HostID: "h1", Kind: "forget", CreatedAt: last}}, }, } tk := New(be) got, err := tk.Decide(context.Background(), now) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 1 || got[0].Kind != "forget" || got[0].HostID != "h1" { t.Errorf("expected one forget decision, got %+v", got) } } func TestTickerSuppressesWhenRecent(t *testing.T) { t.Parallel() now := mustTime(t, "2026-05-04T04:00:00Z") last := mustTime(t, "2026-05-04T03:30:00Z") be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", ForgetCron: "0 3 * * *", ForgetEnabled: true, }}, jobs: map[string]map[string]*store.Job{ "h1": {"forget": &store.Job{ID: "j1", HostID: "h1", Kind: "forget", CreatedAt: last}}, }, } tk := New(be) got, err := tk.Decide(context.Background(), now) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 0 { t.Errorf("expected no decisions, got %+v", got) } } func TestTickerFirstRunAnchorBoundedAt24h(t *testing.T) { t.Parallel() be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", ForgetCron: "0 3 * * *", ForgetEnabled: true, }}, } tk := New(be) // Case 1: now=04:00. Anchor=04:00 - 24h = previous-day 04:00. Next // fire after that is today 03:00 — within window → fire. now1 := mustTime(t, "2026-05-04T04:00:00Z") got, err := tk.Decide(context.Background(), now1) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 1 { t.Errorf("case1: expected 1 decision, got %+v", got) } // Case 2: a cron firing less often than once per 24h with a // no-prior-job anchor must not fire when the most recent fire is // outside the 24h lookback window. Use a weekly cron (Mondays at // 03:00) and `now` on a Tuesday: anchor=now-24h lands on Monday, // so cron.Next(Monday) = next-week Monday → after now → no fire. // 2026-05-04 is a Monday, 2026-05-05 a Tuesday. be2 := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h2", ForgetCron: "0 3 * * 1", ForgetEnabled: true, }}, } tk2 := New(be2) now2 := mustTime(t, "2026-05-05T03:00:00Z") got2, err := tk2.Decide(context.Background(), now2) if err != nil { t.Fatalf("Decide case2: %v", err) } if len(got2) != 0 { t.Errorf("case2: expected no decisions (cron fires < once/24h, prior fire was Monday 03:00 which is exactly 24h ago and anchor=now-24h means next-after is next Monday), got %+v", got2) } } func TestTickerCheckDecisionCarriesSubset(t *testing.T) { t.Parallel() now := mustTime(t, "2026-05-04T04:00:00Z") last := now.Add(-30 * 24 * time.Hour) be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", CheckCron: "0 3 * * *", CheckEnabled: true, CheckSubsetPct: 25, }}, jobs: map[string]map[string]*store.Job{ "h1": {"check": &store.Job{ID: "j1", HostID: "h1", Kind: "check", CreatedAt: last}}, }, } tk := New(be) got, err := tk.Decide(context.Background(), now) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 1 || got[0].Kind != "check" || got[0].SubsetPct != 25 { t.Errorf("expected check decision with SubsetPct=25, got %+v", got) } } func TestTickerHardJobErrorSkipsKind(t *testing.T) { t.Parallel() now := mustTime(t, "2026-05-04T04:00:00Z") last := now.Add(-25 * time.Hour) hardErr := errors.New("synthetic db error") be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", ForgetCron: "0 3 * * *", ForgetEnabled: true, CheckCron: "0 3 * * *", CheckEnabled: true, }}, jobs: map[string]map[string]*store.Job{ // check has a normal latest-job; should still fire. "h1": {"check": &store.Job{ID: "jc", HostID: "h1", Kind: "check", CreatedAt: last}}, }, hardErr: map[string]map[string]error{ "h1": {"forget": hardErr}, }, } tk := New(be) got, err := tk.Decide(context.Background(), now) if err != nil { t.Fatalf("Decide: %v", err) } // Only the check decision should land — forget is skipped. if len(got) != 1 || got[0].Kind != "check" { t.Errorf("expected only check decision, got %+v", got) } } func TestTickerHandlesMultipleHosts(t *testing.T) { t.Parallel() now := mustTime(t, "2026-05-04T04:00:00Z") last := now.Add(-25 * time.Hour) be := &fakeBackend{ rows: []store.HostRepoMaintenance{ { HostID: "ha", ForgetCron: "0 3 * * *", ForgetEnabled: true, }, { HostID: "hb", CheckCron: "0 3 * * *", CheckEnabled: true, PruneCron: "0 4 * * *", PruneEnabled: false, // disabled — should not fire }, }, jobs: map[string]map[string]*store.Job{ "ha": {"forget": &store.Job{ID: "j1", HostID: "ha", Kind: "forget", CreatedAt: last}}, "hb": {"check": &store.Job{ID: "j2", HostID: "hb", Kind: "check", CreatedAt: last}}, }, } tk := New(be) got, err := tk.Decide(context.Background(), now) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 decisions, got %d: %+v", len(got), got) } kinds := map[string]string{} for _, d := range got { kinds[d.HostID] = d.Kind } if kinds["ha"] != "forget" { t.Errorf("ha: expected forget, got %q", kinds["ha"]) } if kinds["hb"] != "check" { t.Errorf("hb: expected check, got %q", kinds["hb"]) } } func TestTickerInvalidCronSkipsSilently(t *testing.T) { t.Parallel() be := &fakeBackend{ rows: []store.HostRepoMaintenance{{ HostID: "h1", ForgetCron: "not a cron", ForgetEnabled: true, }}, } tk := New(be) got, err := tk.Decide(context.Background(), mustTime(t, "2026-05-04T04:00:00Z")) if err != nil { t.Fatalf("Decide: %v", err) } if len(got) != 0 { t.Errorf("expected no decisions for invalid cron, got %+v", got) } }