package notify import ( "io" "log" "testing" ) // fix-3: app_start_failed fires ONCE per running→down transition. down→down cycles are silent (the // anti-spam guarantee); down→running clears the tracker so a later re-failure re-notifies. func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) { n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) var events []string n.pushFn = func(eventType, _, msg string, _ interface{}) { if eventType == "app_start_failed" { events = append(events, msg) } } up := []AppRunState{{Name: "radarr", DisplayName: "Radarr", Down: false}} down := []AppRunState{{Name: "radarr", DisplayName: "Radarr", Down: true}} // running → down: exactly one event. n.NotifyAppStartFailures(up) n.NotifyAppStartFailures(down) if len(events) != 1 { t.Fatalf("running→down must fire exactly one event, got %d: %v", len(events), events) } // down → down (two more cycles): SILENT (companion: drop the n.appDown tracking → fires each cycle → fail). n.NotifyAppStartFailures(down) n.NotifyAppStartFailures(down) if len(events) != 1 { t.Fatalf("down→down must be silent, got %d events: %v", len(events), events) } // down → running → down: a fresh transition re-notifies. n.NotifyAppStartFailures(up) n.NotifyAppStartFailures(down) if len(events) != 2 { t.Fatalf("a fresh down transition must re-notify, got %d: %v", len(events), events) } } // An app that is down on the FIRST evaluation (the F11 dead-at-boot case, after the boot grace) still // fires — it is a running→down transition from the tracker's empty initial state. func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) { n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) var count int n.pushFn = func(eventType, _, _ string, _ interface{}) { if eventType == "app_start_failed" { count++ } } n.NotifyAppStartFailures([]AppRunState{{Name: "jellyfin", DisplayName: "Jellyfin", Down: true}}) if count != 1 { t.Fatalf("a first-seen dead app (dead-at-boot) must fire once, got %d", count) } } // Group C (Scenario C, v0.164.0) — stop → start → crash stays correct. The stop is reported as // Down=false (classifyRunStates suppresses StateStopped — proven in package main's // TestClassifyRunStates_StoppedIsSuppressed), so it fires NOTHING and leaves the tracker clean; the // later crash (StateExited → Down=true) is then a clean false→true transition → exactly ONE event. // // COMPANION red-proof: flip the stop cycle to Down=true (as bare IsDownState would report before the // suppression) → the stop becomes a running→down transition and fires here, so the "zero after the // stop" assertion below fails. (Verified by hand-editing the fixture.) func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) { n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) var count int n.pushFn = func(eventType, _, _ string, _ interface{}) { if eventType == "app_start_failed" { count++ } } app := "immich" stopped := []AppRunState{{Name: app, DisplayName: "Immich", Down: false}} // user stop: StateStopped ⇒ Down=false running := []AppRunState{{Name: app, DisplayName: "Immich", Down: false}} // started again exited := []AppRunState{{Name: app, DisplayName: "Immich", Down: true}} // then it crashes: StateExited // The deliberate stop must be SILENT — the whole point of the suppression. n.NotifyAppStartFailures(stopped) if count != 0 { t.Fatalf("a deliberate stop must fire no event, got %d", count) } // Start, then crash → exactly ONE app_start_failed for the final false→true transition. n.NotifyAppStartFailures(running) n.NotifyAppStartFailures(exited) if count != 1 { t.Fatalf("stop→start→crash must fire exactly one event for the crash, got %d", count) } } // A deployed app that is up never fires. func TestNotifyAppStartFailures_HealthyNeverFires(t *testing.T) { n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) var count int n.pushFn = func(string, string, string, interface{}) { count++ } n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}}) n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}}) if count != 0 { t.Fatalf("a healthy app must never fire, got %d", count) } }