package holdreport import ( "io" "log" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) type fakeStore struct { holds map[string]settings.RestoreHold reported map[string]string } func (f *fakeStore) ListRestoreHolds() []settings.RestoreHold { out := []settings.RestoreHold{} for _, h := range f.holds { out = append(out, h) } return out } func (f *fakeStore) GetReportedHolds() map[string]string { out := map[string]string{} for k, v := range f.reported { out[k] = v } return out } func (f *fakeStore) SetReportedHolds(m map[string]string) error { f.reported = m; return nil } type sent struct { typ, sev string d Details } func rec(out *[]sent) Push { return func(t, sev, msg string, d interface{}) { *out = append(*out, sent{t, sev, d.(Details)}) } } var t0 = time.Date(2026, 10, 26, 18, 0, 0, 0, time.UTC) // The report field (D53): a new hold is sent once as app_held with its stack, reason and since, severity info (the hub // mails nothing on receipt; its reminder after the grace is the mail). func TestD53_ANewHoldIsReportedWithStackReasonAndSince(t *testing.T) { st := &fakeStore{holds: map[string]settings.RestoreHold{ "kimai": {Stack: "kimai", At: "2026-10-14T08:00:00Z", Reason: settings.HoldReasonUnhealthyStop, UnhealthyKind: "crash_loop"}, }} var out []sent r := New(st, rec(&out), nil) r.Tick(t0) if len(out) != 1 || out[0].typ != EventHeld || out[0].sev != "info" { t.Fatalf("want one info app_held, got %+v", out) } d := out[0].d if d.StackName != "kimai" || d.Reason != "unhealthy_stop" || d.Since != "2026-10-14T08:00:00Z" || d.Why == "" { t.Fatalf("payload: %+v", d) } if st.reported["kimai"] != "unhealthy_stop|2026-10-14T08:00:00Z" { t.Fatalf("reported record: %v", st.reported) } // The next tick inside a day sends nothing. out = nil r.Tick(t0.Add(time.Minute)) if len(out) != 0 { t.Fatalf("an unchanged hold was sent again inside a day: %+v", out) } } // An event can be lost: every hold still in place is sent again after a day, marked as a repeat. func TestD53_AHoldIsSentAgainEveryDay(t *testing.T) { st := &fakeStore{holds: map[string]settings.RestoreHold{"a": {Stack: "a", At: "2026-10-20T00:00:00Z", Reason: settings.HoldReasonUpdateFailed}}} var out []sent r := New(st, rec(&out), nil) r.Tick(t0) out = nil r.Tick(t0.Add(ResendEvery)) if len(out) != 1 || out[0].typ != EventHeld || !out[0].d.Repeat { t.Fatalf("want one repeat app_held after a day, got %+v", out) } } // A hold cleared by a person ends it: one app_hold_cleared, then nothing. func TestD53_AClearedHoldSendsClearedOnce(t *testing.T) { st := &fakeStore{holds: map[string]settings.RestoreHold{"a": {Stack: "a", At: "2026-10-20T00:00:00Z", Reason: settings.HoldReasonRestoreMixed}}} var out []sent r := New(st, rec(&out), nil) r.Tick(t0) delete(st.holds, "a") out = nil r.Tick(t0.Add(time.Minute)) if len(out) != 1 || out[0].typ != EventCleared || out[0].d.StackName != "a" || out[0].sev != "info" { t.Fatalf("want one info app_hold_cleared for a, got %+v", out) } if len(st.reported) != 0 { t.Fatalf("the reported record still names the cleared hold: %v", st.reported) } out = nil r.Tick(t0.Add(2 * time.Minute)) r.Tick(t0.Add(ResendEvery + time.Hour)) if len(out) != 0 { t.Fatalf("a cleared hold was reported again: %+v", out) } } // A hold cleared while the controller was down is still reported cleared: the record lives in settings. func TestD53_AHoldClearedWhileDownIsReportedAtStart(t *testing.T) { st := &fakeStore{holds: map[string]settings.RestoreHold{}, reported: map[string]string{"gone": "update_failed|2026-10-01T00:00:00Z"}} var out []sent New(st, rec(&out), nil).Tick(t0) if len(out) != 1 || out[0].typ != EventCleared || out[0].d.StackName != "gone" { t.Fatalf("want app_hold_cleared for gone at start, got %+v", out) } } // A hold whose reason changes (a put-back that failed becomes a mixed restore) is a new hold for the hub. func TestD53_AChangedReasonIsReportedAgain(t *testing.T) { st := &fakeStore{holds: map[string]settings.RestoreHold{"a": {Stack: "a", At: "2026-10-20T00:00:00Z", Reason: settings.HoldReasonPutBackPending}}} var out []sent r := New(st, rec(&out), nil) r.Tick(t0) st.holds["a"] = settings.RestoreHold{Stack: "a", At: "2026-10-20T01:00:00Z", Reason: settings.HoldReasonRestoreMixed} out = nil r.Tick(t0.Add(time.Minute)) if len(out) != 1 || out[0].typ != EventHeld || out[0].d.Reason != "restore_mixed" || out[0].d.Repeat { t.Fatalf("want a new (not repeat) app_held restore_mixed, got %+v", out) } } // The legacy empty reason is a restore that failed with its rollback: the hub reads it as restore_failed. func TestD53_TheEmptyReasonIsRestoreFailedOnTheWire(t *testing.T) { st := &fakeStore{holds: map[string]settings.RestoreHold{"db": {Stack: "db", At: "2026-10-20T00:00:00Z"}}} var out []sent New(st, rec(&out), nil).Tick(t0) if len(out) != 1 || out[0].d.Reason != "restore_failed" { t.Fatalf("want reason restore_failed, got %+v", out) } } // The seam is wired: main builds the reporter on the real settings and notifier and ticks it from the scheduler. A // reporter nobody ticks is the inert-seam shape (four instances in this project). A commented-out line does not count. func TestD53_TheReporterIsWiredInMain(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "cmd", "controller", "main.go")) if err != nil { t.Fatalf("cannot read main.go: %v", err) } live := []string{} for _, l := range strings.Split(string(raw), "\n") { if !strings.HasPrefix(strings.TrimSpace(l), "//") { live = append(live, l) } } src := strings.Join(live, "\n") for _, want := range []string{"holdreport.New(sett, notifier.PushEvent, logger)", `sched.Every("hold-report"`, "holdReporter.Tick("} { if !strings.Contains(src, want) { t.Errorf("main.go lacks %q — the hold report is not wired", want) } } } // The reported record survives a restart: it is what lets a hold cleared while the controller was down be reported. func TestD53_TheReportedRecordSurvivesAReload(t *testing.T) { p := filepath.Join(t.TempDir(), "settings.json") s, err := settings.Load(p, log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } if err := s.SetReportedHolds(map[string]string{"kimai": "unhealthy_stop|2026-10-14T08:00:00Z"}); err != nil { t.Fatal(err) } s2, err := settings.Load(p, log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } if got := s2.GetReportedHolds()["kimai"]; got != "unhealthy_stop|2026-10-14T08:00:00Z" { t.Fatalf("after a reload the record reads %q", got) } }