Files
felhom-controller/controller/internal/holdreport/holdreport_test.go
T
admin 5bd03ad152
gates / gates (push) Successful in 54s
feat(D53): the hub learns which apps are held — app_held / app_hold_cleared, daily repeat
internal/holdreport: one observer of the hold store, ticked every minute; app_held for a new or changed
hold (stack_name, reason, since, why), app_hold_cleared when gone (also while the controller was down:
settings.reported_holds), every hold again daily and at start. Severity info. RestoreHold.Why/WireReason
(holdWhy calls it). 8 tests, red-proved (inert Tick; unwired scheduler line). Commit only (felhom.eu 08 §10).
2026-10-26 18:23:54 +01:00

183 lines
6.5 KiB
Go

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)
}
}