package lifecycle import ( "fmt" "strings" "testing" "time" ) type stubHealthNotifier struct { failUntil int attempts int txHash string } func (s *stubHealthNotifier) NotifyHealthChange(moduleID string, oldHealth, newHealth ModuleHealth) error { s.attempts++ if s.attempts <= s.failUntil { return fmt.Errorf("notify failure %d for tx %s", s.attempts, s.txHash) } return nil } func (s *stubHealthNotifier) NotifySystemHealth(health OverallHealth) error { return nil } func (s *stubHealthNotifier) NotifyAlert(alert HealthAlert) error { return nil } func TestHealthMonitorNotifyWithRetrySuccess(t *testing.T) { config := HealthMonitorConfig{ NotificationRetries: 3, NotificationRetryDelay: time.Nanosecond, } hm := NewHealthMonitor(config) hm.logger = nil notifier := &stubHealthNotifier{failUntil: 2, txHash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} hm.notifier = notifier err := hm.notifyWithRetry(func() error { return notifier.NotifyHealthChange("module", ModuleHealth{}, ModuleHealth{}) }, "notify failure", "module_id", "module") if err != nil { t.Fatalf("expected notification to eventually succeed, got %v", err) } if notifier.attempts != 3 { t.Fatalf("expected 3 attempts, got %d", notifier.attempts) } if errs := hm.NotificationErrors(); len(errs) != 0 { t.Fatalf("expected no recorded notification errors, got %d", len(errs)) } if hm.aggregatedNotificationError() != nil { t.Fatal("expected aggregated notification error to be nil") } } func TestHealthMonitorNotifyWithRetryFailure(t *testing.T) { config := HealthMonitorConfig{ NotificationRetries: 2, NotificationRetryDelay: time.Nanosecond, } hm := NewHealthMonitor(config) hm.logger = nil txHash := "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" notifier := &stubHealthNotifier{failUntil: 3, txHash: txHash} hm.notifier = notifier err := hm.notifyWithRetry(func() error { return notifier.NotifyHealthChange("module", ModuleHealth{}, ModuleHealth{}) }, "notify failure", "module_id", "module") if err == nil { t.Fatal("expected notification to fail after retries") } if notifier.attempts != 2 { t.Fatalf("expected 2 attempts, got %d", notifier.attempts) } exported := hm.NotificationErrors() if len(exported) != 1 { t.Fatalf("expected 1 recorded notification error, got %d", len(exported)) } copyErrs := hm.NotificationErrors() if copyErrs[0] == nil { t.Fatal("expected copy of notification errors to retain value") } if got := exported[0].Error(); !strings.Contains(got, txHash) { t.Fatalf("recorded notification error should include tx hash, got %q", got) } details := hm.NotificationErrorDetails() if len(details) != 1 { t.Fatalf("expected notification error details entry, got %d", len(details)) } if details[0].TxHash != txHash { t.Fatalf("expected notification error detail to track tx hash %s, got %s", txHash, details[0].TxHash) } agg := hm.aggregatedNotificationError() if agg == nil { t.Fatal("expected aggregated notification error to be returned") } if got := agg.Error(); !strings.Contains(got, "notify failure") || !strings.Contains(got, "notify failure 1") || !strings.Contains(got, txHash) { t.Fatalf("aggregated notification error should include failure details and tx hash, got %q", got) } }