package notify import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // capturedEvent is one POST /api/v1/event body the hub would have received. type capturedEvent struct { EventType string `json:"event_type"` Severity string `json:"severity"` Message string `json:"message"` } // notifierAgainstHub wires a REAL Notifier at an httptest hub and returns a channel of what actually // went over the wire. The whole failure class E-2b addresses is "the method exists and nobody calls // it / nothing arrives", so the test has to observe the WIRE, not a mock's call count. func notifierAgainstHub(t *testing.T) (*Notifier, <-chan capturedEvent) { t.Helper() got := make(chan capturedEvent, 8) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var ev capturedEvent _ = json.Unmarshal(body, &ev) got <- ev w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("settings: %v", err) } n := New(srv.URL, "test-key", "demo-hp", sett, log.New(os.Stderr, "", 0), false) return n, got } func awaitEvent(t *testing.T, ch <-chan capturedEvent) capturedEvent { t.Helper() select { case ev := <-ch: return ev case <-time.After(3 * time.Second): t.Fatal("no event reached the hub within 3s — the notification never went out") return capturedEvent{} } } // E-2 Part 5 — the absent backup target must emit its OWN event type, at error severity. // // It has to be `backup_target_absent` specifically: the hub allowlists that string, and an event type // the hub does not allowlist is answered 400 and VANISHES (R-97a). A typo here is not a cosmetic bug, // it is total silence — the exact condition E-2 exists to end. func TestBackupTargetAbsentEmitsItsOwnEventType(t *testing.T) { n, got := notifierAgainstHub(t) n.NotifyBackupTargetAbsent("Külső HDD 1TB", "/mnt/hdd_1") ev := awaitEvent(t, got) if ev.EventType != "backup_target_absent" { t.Errorf("event_type = %q, want backup_target_absent (any other string is 400'd by the hub and lost)", ev.EventType) } if ev.Severity != "error" { t.Errorf("severity = %q, want error — a lost whole-system backup is not a warning", ev.Severity) } if ev.Message == "" { t.Error("message is empty — the operator mail would name no drive") } } // The recovery half. `info` severity is deliberate and load-bearing: the recovery-mail pairing is // gated on it, and widening severityNotifies to carry recoveries at warning/error would page the // operator for good news across every event family, not just this one. func TestBackupTargetRestoredIsPairedAndInfo(t *testing.T) { n, got := notifierAgainstHub(t) n.NotifyBackupTargetRestored("Külső HDD 1TB", "/mnt/hdd_1") ev := awaitEvent(t, got) if ev.EventType != "backup_target_restored" { t.Errorf("event_type = %q, want backup_target_restored", ev.EventType) } if ev.Severity != "info" { t.Errorf("severity = %q, want info — recovery mails are pairing-gated on info", ev.Severity) } } // E-2b — the seam that was defined and never called. This pins that NotifyStorageDisconnected // actually reaches the hub, which it never did before E-2: it was registered in allowedEventTypes, // DefaultEnabledEvents and the hub's Hungarian customerMessages, and called from nowhere in the repo. func TestStorageDisconnectedActuallyReachesTheHub(t *testing.T) { n, got := notifierAgainstHub(t) n.NotifyStorageDisconnected("Külső HDD 1TB", []string{"paperless"}) ev := awaitEvent(t, got) if ev.EventType != "storage_disconnected" { t.Errorf("event_type = %q, want storage_disconnected", ev.EventType) } }