package notifications import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" ) func flushPending(n *NotificationManager) { n.mu.Lock() if n.groupTimer != nil { n.groupTimer.Stop() n.groupTimer = nil } n.mu.Unlock() n.sendGroupedAlerts() } func TestNormalizeAppriseConfig(t *testing.T) { original := AppriseConfig{ Enabled: true, Targets: []string{" discord://token ", "", "DISCORD://TOKEN"}, CLIPath: " ", TimeoutSeconds: -5, APIKeyHeader: "", } normalized := NormalizeAppriseConfig(original) if normalized.Mode != AppriseModeCLI { t.Fatalf("expected default mode cli, got %q", normalized.Mode) } if normalized.CLIPath != "apprise" { t.Fatalf("expected default CLI path 'apprise', got %q", normalized.CLIPath) } if normalized.TimeoutSeconds != 15 { t.Fatalf("expected timeout of 15 seconds, got %d", normalized.TimeoutSeconds) } if !normalized.Enabled { t.Fatalf("expected config to remain enabled when targets exist") } if len(normalized.Targets) != 1 || normalized.Targets[0] != "discord://token" { t.Fatalf("unexpected targets normalization result: %#v", normalized.Targets) } if normalized.APIKeyHeader != "X-API-KEY" { t.Fatalf("expected default API key header, got %q", normalized.APIKeyHeader) } // When all targets removed, enabled should reset to false empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true}) if empty.Enabled { t.Fatalf("expected enabled to be false when no targets configured") } httpConfig := NormalizeAppriseConfig(AppriseConfig{ Enabled: true, Mode: AppriseModeHTTP, ServerURL: "https://apprise.example.com/api/", APIKey: " secret ", APIKeyHeader: " X-Token ", TimeoutSeconds: 200, }) if httpConfig.Mode != AppriseModeHTTP { t.Fatalf("expected HTTP mode, got %q", httpConfig.Mode) } if httpConfig.ServerURL != "https://apprise.example.com/api" { t.Fatalf("expected server URL to be trimmed, got %q", httpConfig.ServerURL) } if httpConfig.APIKey != "secret" { t.Fatalf("expected API key to be trimmed, got %q", httpConfig.APIKey) } if httpConfig.APIKeyHeader != "X-Token" { t.Fatalf("expected API key header to be trimmed, got %q", httpConfig.APIKeyHeader) } if httpConfig.TimeoutSeconds != 120 { t.Fatalf("expected timeout to clamp to 120, got %d", httpConfig.TimeoutSeconds) } if !httpConfig.Enabled { t.Fatalf("expected HTTP config with server URL to remain enabled") } disabledHTTP := NormalizeAppriseConfig(AppriseConfig{ Enabled: true, Mode: AppriseModeHTTP, }) if disabledHTTP.Enabled { t.Fatalf("expected HTTP config without server URL to disable notifications") } } func TestSetCooldownClampsNegativeValues(t *testing.T) { nm := NewNotificationManager("") nm.SetCooldown(-10) nm.mu.RLock() if nm.cooldown != 0 { nm.mu.RUnlock() t.Fatalf("expected cooldown to clamp to zero, got %s", nm.cooldown) } nm.mu.RUnlock() nm.SetCooldown(5) nm.mu.RLock() if nm.cooldown != 5*time.Minute { nm.mu.RUnlock() t.Fatalf("expected cooldown of five minutes, got %s", nm.cooldown) } nm.mu.RUnlock() } func TestSetGroupingWindowClampsNegativeValues(t *testing.T) { nm := NewNotificationManager("") nm.SetGroupingWindow(-60) nm.mu.RLock() if nm.groupWindow != 0 { nm.mu.RUnlock() t.Fatalf("expected grouping window to clamp to zero, got %s", nm.groupWindow) } nm.mu.RUnlock() nm.SetGroupingWindow(120) nm.mu.RLock() if nm.groupWindow != 120*time.Second { nm.mu.RUnlock() t.Fatalf("expected grouping window of 120 seconds, got %s", nm.groupWindow) } nm.mu.RUnlock() } func TestSendGroupedAppriseInvokesExecutor(t *testing.T) { nm := NewNotificationManager("") nm.SetGroupingWindow(0) nm.SetEmailConfig(EmailConfig{Enabled: false}) done := make(chan struct{}) var capturedArgs []string nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) { if path != "apprise" { t.Fatalf("expected CLI path 'apprise', got %q", path) } capturedArgs = append([]string(nil), args...) close(done) return []byte("success"), nil } nm.SetAppriseConfig(AppriseConfig{ Enabled: true, Targets: []string{"discord://token"}, TimeoutSeconds: 10, }) alert := &alerts.Alert{ ID: "test", Type: "cpu", Level: alerts.AlertLevelCritical, ResourceID: "vm-100", ResourceName: "vm-100", Message: "CPU usage high", Value: 95, Threshold: 90, StartTime: time.Now().Add(-time.Minute), LastSeen: time.Now(), } nm.mu.Lock() nm.pendingAlerts = append(nm.pendingAlerts, alert) nm.mu.Unlock() nm.sendGroupedAlerts() select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("timed out waiting for Apprise executor to run") } if len(capturedArgs) == 0 { t.Fatalf("expected Apprise executor to receive arguments") } if capturedArgs[len(capturedArgs)-1] != "discord://token" { t.Fatalf("expected target URL as last argument, got %v", capturedArgs) } } func TestSendGroupedAppriseHTTP(t *testing.T) { nm := NewNotificationManager("https://pulse.local") nm.SetGroupingWindow(0) nm.SetEmailConfig(EmailConfig{Enabled: false}) type apprisePayload struct { Body string `json:"body"` Title string `json:"title"` Type string `json:"type"` URLs []string `json:"urls"` } type capturedRequest struct { Method string Path string ContentType string APIKey string Payload apprisePayload } requests := make(chan capturedRequest, 1) errs := make(chan error, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil { errs <- err w.WriteHeader(http.StatusInternalServerError) return } var payload apprisePayload if err := json.Unmarshal(body, &payload); err != nil { errs <- err w.WriteHeader(http.StatusBadRequest) return } requests <- capturedRequest{ Method: r.Method, Path: r.URL.Path, ContentType: r.Header.Get("Content-Type"), APIKey: r.Header.Get("X-Test-Key"), Payload: payload, } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer server.Close() nm.SetAppriseConfig(AppriseConfig{ Enabled: true, Mode: AppriseModeHTTP, ServerURL: server.URL, ConfigKey: "primary", APIKey: "secret", APIKeyHeader: "X-Test-Key", Targets: []string{"discord://token"}, TimeoutSeconds: 10, }) alert := &alerts.Alert{ ID: "test", Type: "cpu", Level: alerts.AlertLevelCritical, ResourceID: "vm-100", ResourceName: "vm-100", Message: "CPU usage high", Value: 95, Threshold: 90, StartTime: time.Now().Add(-time.Minute), LastSeen: time.Now(), } nm.mu.Lock() nm.pendingAlerts = append(nm.pendingAlerts, alert) nm.mu.Unlock() nm.sendGroupedAlerts() var req capturedRequest select { case req = <-requests: case err := <-errs: t.Fatalf("server error: %v", err) case <-time.After(2 * time.Second): t.Fatalf("timed out waiting for Apprise API request") } if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } if req.Path != "/notify/primary" { t.Fatalf("expected notify path with config key, got %s", req.Path) } if req.ContentType != "application/json" { t.Fatalf("expected JSON content type, got %s", req.ContentType) } if req.APIKey != "secret" { t.Fatalf("expected API key header to be set, got %q", req.APIKey) } if req.Payload.Title != "Pulse alert: vm-100" { t.Fatalf("unexpected title: %s", req.Payload.Title) } if req.Payload.Type != "failure" { t.Fatalf("expected failure notification type, got %s", req.Payload.Type) } if len(req.Payload.URLs) != 1 || req.Payload.URLs[0] != "discord://token" { t.Fatalf("unexpected URLs in payload: %#v", req.Payload.URLs) } if !strings.Contains(req.Payload.Body, "CPU usage high") { t.Fatalf("expected alert message in payload body, got %s", req.Payload.Body) } if !strings.Contains(req.Payload.Body, "Dashboard: https://pulse.local") { t.Fatalf("expected dashboard link in payload body, got %s", req.Payload.Body) } } func TestNotificationCooldownAllowsNewAlertInstance(t *testing.T) { nm := NewNotificationManager("") nm.SetCooldown(1) // 1 minute cooldown nm.SetGroupingWindow(3600) // keep timer from firing immediately alertStart := time.Now().Add(-time.Minute) alertA := &alerts.Alert{ ID: "vm-100-memory", Type: "memory", Level: alerts.AlertLevelWarning, StartTime: alertStart, } nm.SendAlert(alertA) flushPending(nm) nm.mu.RLock() firstRecord, ok := nm.lastNotified[alertA.ID] nm.mu.RUnlock() if !ok { t.Fatalf("first notification not recorded") } nm.SendAlert(alertA) nm.mu.RLock() pendingAfter := len(nm.pendingAlerts) nm.mu.RUnlock() if pendingAfter != 0 { t.Fatalf("cooldown alert should not be queued, found %d pending", pendingAfter) } alertRestart := &alerts.Alert{ ID: "vm-100-memory", Type: "memory", Level: alerts.AlertLevelWarning, StartTime: alertStart.Add(time.Minute), } nm.SendAlert(alertRestart) flushPending(nm) nm.mu.RLock() recordAfter := nm.lastNotified[alertRestart.ID] nm.mu.RUnlock() if !recordAfter.alertStart.Equal(alertRestart.StartTime) { t.Fatalf("expected alertStart %v, got %v", alertRestart.StartTime, recordAfter.alertStart) } if !recordAfter.lastSent.After(firstRecord.lastSent) { t.Fatalf("lastSent was not updated for new alert instance") } } func TestCancelAlertRemovesPending(t *testing.T) { nm := NewNotificationManager("") nm.SetGroupingWindow(120) alertA := &alerts.Alert{ ID: "vm-100-disk", Type: "disk", Level: alerts.AlertLevelWarning, StartTime: time.Now(), } alertB := &alerts.Alert{ ID: "vm-101-disk", Type: "disk", Level: alerts.AlertLevelWarning, StartTime: time.Now(), } nm.SendAlert(alertA) nm.SendAlert(alertB) nm.CancelAlert(alertA.ID) nm.mu.RLock() remaining := make([]string, 0, len(nm.pendingAlerts)) for _, pending := range nm.pendingAlerts { if pending != nil { remaining = append(remaining, pending.ID) } } groupTimerActive := nm.groupTimer != nil nm.mu.RUnlock() if len(remaining) != 1 || remaining[0] != alertB.ID { t.Fatalf("expected only %s to remain pending, got %v", alertB.ID, remaining) } if !groupTimerActive { t.Fatalf("expected grouping timer to remain active while other alerts pending") } nm.CancelAlert(alertB.ID) nm.mu.RLock() if len(nm.pendingAlerts) != 0 { nm.mu.RUnlock() t.Fatalf("expected no pending alerts after cancelling all, found %d", len(nm.pendingAlerts)) } timerStopped := nm.groupTimer == nil nm.mu.RUnlock() if !timerStopped { t.Fatalf("expected grouping timer to be cleared when no alerts remain") } } func TestConvertWebhookCustomFields(t *testing.T) { if result := convertWebhookCustomFields(nil); result != nil { t.Fatalf("expected nil for empty input, got %#v", result) } original := map[string]string{ "app_token": "abc123", "user_token": "user456", } converted := convertWebhookCustomFields(original) if len(converted) != len(original) { t.Fatalf("expected %d keys, got %d", len(original), len(converted)) } for key, value := range original { if got, ok := converted[key]; !ok || got != value { t.Fatalf("expected %s=%s, got %v (present=%v)", key, value, got, ok) } } // Mutate original map and ensure converted copy remains unchanged original["extra"] = "new-value" if _, ok := converted["extra"]; ok { t.Fatalf("expected converted map to be independent of original mutations") } } func TestRenderWebhookURL_PathEncoding(t *testing.T) { data := WebhookPayloadData{ Message: "CPU spike detected", } result, err := renderWebhookURL("https://example.com/alerts/{{.Message}}", data) if err != nil { t.Fatalf("expected no error rendering URL template, got %v", err) } expected := "https://example.com/alerts/CPU%20spike%20detected" if result != expected { t.Fatalf("expected %s, got %s", expected, result) } } func TestRenderWebhookURL_QueryEncoding(t *testing.T) { data := WebhookPayloadData{ Message: "CPU & Memory > 90%", } result, err := renderWebhookURL("https://hooks.example.com?msg={{urlquery .Message}}", data) if err != nil { t.Fatalf("expected no error rendering URL template, got %v", err) } expected := "https://hooks.example.com?msg=CPU+%26+Memory+%3E+90%25" if result != expected { t.Fatalf("expected %s, got %s", expected, result) } } func TestRenderWebhookURL_InvalidTemplate(t *testing.T) { _, err := renderWebhookURL("https://example.com/{{.Missing", WebhookPayloadData{}) if err == nil { t.Fatalf("expected error for invalid URL template, got nil") } } func TestSendTestNotificationApprise(t *testing.T) { nm := NewNotificationManager("") nm.SetEmailConfig(EmailConfig{Enabled: false}) // Test 1: Apprise not enabled should return error nm.SetAppriseConfig(AppriseConfig{ Enabled: false, Targets: []string{"discord://token"}, }) err := nm.SendTestNotification("apprise") if err == nil { t.Fatalf("expected error when Apprise is disabled, got nil") } if !strings.Contains(err.Error(), "not enabled") { t.Fatalf("expected 'not enabled' error, got: %v", err) } // Test 2: Apprise enabled with CLI mode should invoke executor done := make(chan struct{}) var capturedArgs []string nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) { if path != "apprise" { t.Fatalf("expected CLI path 'apprise', got %q", path) } capturedArgs = append([]string(nil), args...) close(done) return []byte("success"), nil } nm.SetAppriseConfig(AppriseConfig{ Enabled: true, Targets: []string{"discord://token"}, TimeoutSeconds: 10, }) err = nm.SendTestNotification("apprise") if err != nil { t.Fatalf("expected no error when testing Apprise, got: %v", err) } // Wait for the executor to be called select { case <-done: // Success - executor was called case <-time.After(2 * time.Second): t.Fatalf("timeout waiting for Apprise executor to be called") } // Verify the arguments contain the target foundTarget := false for _, arg := range capturedArgs { if arg == "discord://token" { foundTarget = true break } } if !foundTarget { t.Fatalf("expected target 'discord://token' in args, got: %v", capturedArgs) } } func TestSendTestNotificationAppriseHTTP(t *testing.T) { nm := NewNotificationManager("") nm.SetEmailConfig(EmailConfig{Enabled: false}) type apprisePayload struct { Body string `json:"body"` Title string `json:"title"` Type string `json:"type"` URLs []string `json:"urls"` } requests := make(chan apprisePayload, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } var payload apprisePayload if err := json.Unmarshal(body, &payload); err != nil { w.WriteHeader(http.StatusBadRequest) return } requests <- payload w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok": true}`)) })) defer server.Close() nm.SetAppriseConfig(AppriseConfig{ Enabled: true, Mode: AppriseModeHTTP, ServerURL: server.URL, ConfigKey: "test-key", TimeoutSeconds: 10, }) err := nm.SendTestNotification("apprise") if err != nil { t.Fatalf("expected no error when testing Apprise HTTP, got: %v", err) } // Wait for the HTTP request select { case payload := <-requests: // Verify the payload contains test alert information if payload.Title == "" { t.Fatalf("expected non-empty title in Apprise payload") } if payload.Body == "" { t.Fatalf("expected non-empty body in Apprise payload") } if !strings.Contains(payload.Body, "test alert") && !strings.Contains(payload.Body, "Test Resource") { t.Fatalf("expected test alert content in body, got: %s", payload.Body) } case <-time.After(2 * time.Second): t.Fatalf("timeout waiting for Apprise HTTP request") } }