package monitor import ( "io" "log" "sync" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-523 — the checker that turns the agent's controller_supervisor stanza into events. // The JSON below is the exact wire shape the agent's TestControllerSupervisorStanza_WireShape pins. func supReport(guests string) []byte { return []byte(`{"host_id":"h1","controller_supervisor":{"guests":` + guests + `}}`) } type evRec struct { mu sync.Mutex evs []string } func (r *evRec) fn(_, et, sev, _, _, _ string) { r.mu.Lock() defer r.mu.Unlock() r.evs = append(r.evs, et+"/"+sev) } func (r *evRec) take() []string { r.mu.Lock() defer r.mu.Unlock() out := r.evs r.evs = nil return out } // The consequence: an agent restart of a controller reaches the dispatcher as // controller_restarted_by_agent, once; a crash-loop reaches it as controller_crashloop (error). // // RED-PROOF: make observe() skip the `LastRestartAt != prev.lastRestartAt` branch → the second // Check emits nothing → "restart did not produce controller_restarted_by_agent". func TestControllerSupervisorChecker_EmitsOnMovement(t *testing.T) { st := newCapStore(t) rec := &evRec{} c := NewControllerSupervisorChecker(st, rec.fn, log.New(io.Discard, "", 0)) // Seed: an old restart already recorded at first sight → silent. st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":1,"last_restart_at":"2026-09-15T08:00:00Z","last_reason":"exited","crashloop":false,"parked":false}]`), store.HostReportDenorm{}) c.Check() if evs := rec.take(); len(evs) != 0 { t.Fatalf("first observation must seed silently, got %v", evs) } // Same record again → silent. c.Check() if evs := rec.take(); len(evs) != 0 { t.Fatalf("unchanged record emitted %v", evs) } // A new restart. st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":2,"last_restart_at":"2026-09-15T09:00:00Z","last_reason":"exited","crashloop":false,"parked":false}]`), store.HostReportDenorm{}) c.Check() if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_restarted_by_agent/info" { t.Fatalf("restart did not produce controller_restarted_by_agent (got %v)", evs) } // Agent restarted (counter back to 1) with a NEW timestamp → still an event. st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":1,"last_restart_at":"2026-09-15T10:00:00Z","last_reason":"absent","crashloop":false,"parked":false}]`), store.HostReportDenorm{}) c.Check() if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_restarted_by_agent/info" { t.Fatalf("a restart after an agent restart (counter reset) was lost: %v", evs) } // Crash-loop. st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":4,"last_restart_at":"2026-09-15T10:00:00Z","last_reason":"exited","crashloop":true,"crashloop_since":"2026-09-15T10:05:00Z","parked":false}]`), store.HostReportDenorm{}) c.Check() if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_crashloop/error" { t.Fatalf("crash-loop did not produce controller_crashloop/error (got %v)", evs) } // A pre-v0.131.0 report (no stanza) → nothing. st.SaveHostReport("h1", "c1", []byte(`{"host_id":"h1"}`), store.HostReportDenorm{}) c.Check() if evs := rec.take(); len(evs) != 0 { t.Fatalf("a report without the stanza emitted %v", evs) } } // A hub restarted DURING a crash-loop must say so once, not seed it away. func TestControllerSupervisorChecker_CrashloopAtFirstSightEmits(t *testing.T) { st := newCapStore(t) rec := &evRec{} c := NewControllerSupervisorChecker(st, rec.fn, log.New(io.Discard, "", 0)) st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":3,"last_restart_at":"2026-09-15T10:00:00Z","crashloop":true,"crashloop_since":"2026-09-15T10:05:00Z"}]`), store.HostReportDenorm{}) c.Check() c.Check() if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_crashloop/error" { t.Fatalf("crash-loop at first sight: want exactly one controller_crashloop, got %v", evs) } }