package stacks import ( "io" "log" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // R-772 — a health probe that finds NO container to probe recorded `healthy: true` and then waited 5 minutes (measured // 2026-10-01 on 9202, Karakeep with its container stopped). A record of a check that did not run must never say healthy // (presence is not success), and the box must look again soon. func r772Manager(now time.Time) *Manager { m := &Manager{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{}} m.updateNowFn = func() time.Time { return now } return m } func r772Stack() *Stack { return &Stack{ Name: "karakeep", State: StateDegraded, Meta: Metadata{HealthCheck: &HealthCheckConfig{Interval: "5m", Checks: []HealthCheckItem{{Type: "http", Port: 3000, Path: "/api/health"}}}}, // the app container stopped; the others still run — nothing the probe may use Containers: []ContainerInfo{ {Name: "karakeep", State: StateExited}, {Name: "karakeep-meilisearch", State: StateRunning}, {Name: "karakeep-chrome", State: StateRunning}, }, } } func TestRunHealthProbes_NoContainerIsNotHealthy(t *testing.T) { m := r772Manager(time.Now()) m.stacks["karakeep"] = r772Stack() if err := m.RunHealthProbes(); err != nil { t.Fatal(err) } hp := m.stacks["karakeep"].HealthProbe if hp == nil { t.Fatal("no record written") } if hp.Healthy || !hp.NotChecked || len(hp.Details) != 1 || hp.Details[0].Healthy || hp.Details[0].MessageKey != KeyHealthNoProbeContainer { t.Fatalf("a check that did not run must read NOT healthy + not_checked, got %+v", hp) } if m.stacks["karakeep"].State != StateDegraded { t.Fatalf("the record must not change the state the containers gave, got %s", m.stacks["karakeep"].State) } } // The box looks again on the FAST cycle (10 s), not after the 5-minute interval: 20 s later a second run re-records. // Pre-fix (healthy: true) the second run skipped it for 5 minutes. func TestRunHealthProbes_NotCheckedIsLookedAtAgainSoon(t *testing.T) { first := time.Now().Add(-20 * time.Second) m := r772Manager(first) m.stacks["karakeep"] = r772Stack() _ = m.RunHealthProbes() m.updateNowFn = func() time.Time { return time.Now() } _ = m.RunHealthProbes() if got := m.stacks["karakeep"].HealthProbe.LastCheck; !got.After(first) { t.Fatalf("a not-checked app must be looked at again within the 10-second cycle; last check still %v", got) } } // R-630 stays true: a not-checked record never paints a RUNNING app unhealthy; a probe that ran and failed does. func TestProbeSaysUnhealthy_NotCheckedLeavesTheStateAlone(t *testing.T) { if probeSaysUnhealthy(&HealthProbeResult{Healthy: false, NotChecked: true}) { t.Fatal("not checked must not override the state") } if !probeSaysUnhealthy(&HealthProbeResult{Healthy: false}) { t.Fatal("a failed check must override running → unhealthy") } if probeSaysUnhealthy(nil) || probeSaysUnhealthy(&HealthProbeResult{Healthy: true}) { t.Fatal("nil / healthy must not override") } } // Measured live on 9202 (v0.286.0): with a HEALTHY record 2 minutes old, stopping the probe's container left the record // "healthy" until its 5-minute interval ran out. The missing container is now seen on the very next tick. func TestRunHealthProbes_AStoppedContainerIsSeenAtOnce(t *testing.T) { m := r772Manager(time.Now()) st := r772Stack() st.HealthProbe = &HealthProbeResult{Healthy: true, LastCheck: time.Now().Add(-2 * time.Minute)} m.stacks["karakeep"] = st _ = m.RunHealthProbes() if hp := m.stacks["karakeep"].HealthProbe; hp.Healthy || !hp.NotChecked { t.Fatalf("a stopped probe container must be recorded not checked on the next tick, got %+v", hp) } }