diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 139375a..80dbdfc 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -1375,3 +1375,445 @@ func TestGetEmailConfig(t *testing.T) { t.Fatalf("expected startTLS to be true") } } + +func TestBuildResolvedNotificationContent(t *testing.T) { + t.Run("nil alert list returns empty strings", func(t *testing.T) { + title, htmlBody, textBody := buildResolvedNotificationContent(nil, time.Now(), "") + if title != "" || htmlBody != "" || textBody != "" { + t.Fatalf("expected empty strings for nil list, got title=%q, htmlBody=%q, textBody=%q", title, htmlBody, textBody) + } + }) + + t.Run("empty alert list returns empty strings", func(t *testing.T) { + title, htmlBody, textBody := buildResolvedNotificationContent([]*alerts.Alert{}, time.Now(), "") + if title != "" || htmlBody != "" || textBody != "" { + t.Fatalf("expected empty strings for empty list, got title=%q, htmlBody=%q, textBody=%q", title, htmlBody, textBody) + } + }) + + t.Run("list with only nil alerts returns empty strings", func(t *testing.T) { + title, htmlBody, textBody := buildResolvedNotificationContent([]*alerts.Alert{nil, nil, nil}, time.Now(), "") + if title != "" || htmlBody != "" || textBody != "" { + t.Fatalf("expected empty strings for nil-only list, got title=%q, htmlBody=%q, textBody=%q", title, htmlBody, textBody) + } + }) + + t.Run("single alert generates correct title and body", func(t *testing.T) { + startTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + resolvedAt := time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC) + + alert := &alerts.Alert{ + ID: "test-alert-1", + Type: "cpu", + Level: alerts.AlertLevelWarning, + ResourceName: "vm-100", + Message: "CPU usage exceeded threshold", + StartTime: startTime, + Node: "pve1", + Instance: "vm-100", + Threshold: 80, + Value: 95.5, + } + + title, htmlBody, textBody := buildResolvedNotificationContent([]*alerts.Alert{alert}, resolvedAt, "") + + expectedTitle := "Pulse alert resolved: vm-100" + if title != expectedTitle { + t.Fatalf("expected title %q, got %q", expectedTitle, title) + } + + // Check text body contains expected elements + if !strings.Contains(textBody, "Resolved at 2024-01-15T11:00:00Z") { + t.Fatalf("expected resolved timestamp in body, got: %s", textBody) + } + if !strings.Contains(textBody, "[WARNING] vm-100") { + t.Fatalf("expected alert level and resource name in body, got: %s", textBody) + } + if !strings.Contains(textBody, "CPU usage exceeded threshold") { + t.Fatalf("expected message in body, got: %s", textBody) + } + if !strings.Contains(textBody, "Started: 2024-01-15T10:30:00Z") { + t.Fatalf("expected start time in body, got: %s", textBody) + } + if !strings.Contains(textBody, "Cleared: 2024-01-15T11:00:00Z") { + t.Fatalf("expected cleared time in body, got: %s", textBody) + } + if !strings.Contains(textBody, "Node: pve1") { + t.Fatalf("expected node in body, got: %s", textBody) + } + if !strings.Contains(textBody, "Last value 95.50 (threshold 80.00)") { + t.Fatalf("expected threshold/value in body, got: %s", textBody) + } + + // Check HTML body wraps in pre tag + if !strings.Contains(htmlBody, "
tag, got: %s", htmlBody) + } + if !strings.Contains(htmlBody, "") { + t.Fatalf("expected HTML body to end with tag, got: %s", htmlBody) + } + }) + + t.Run("multiple alerts generate plural title", func(t *testing.T) { + alert1 := &alerts.Alert{ + ID: "alert-1", + Level: alerts.AlertLevelWarning, + ResourceName: "vm-100", + } + alert2 := &alerts.Alert{ + ID: "alert-2", + Level: alerts.AlertLevelCritical, + ResourceName: "vm-101", + } + alert3 := &alerts.Alert{ + ID: "alert-3", + Level: alerts.AlertLevelWarning, + ResourceName: "vm-102", + } + + title, _, textBody := buildResolvedNotificationContent([]*alerts.Alert{alert1, alert2, alert3}, time.Now(), "") + + expectedTitle := "Pulse alerts resolved (3)" + if title != expectedTitle { + t.Fatalf("expected title %q, got %q", expectedTitle, title) + } + + // Verify all alerts are in the body + if !strings.Contains(textBody, "[WARNING] vm-100") { + t.Fatalf("expected alert1 in body, got: %s", textBody) + } + if !strings.Contains(textBody, "[CRITICAL] vm-101") { + t.Fatalf("expected alert2 in body, got: %s", textBody) + } + if !strings.Contains(textBody, "[WARNING] vm-102") { + t.Fatalf("expected alert3 in body, got: %s", textBody) + } + }) + + t.Run("zero resolvedAt uses current time", func(t *testing.T) { + alert := &alerts.Alert{ + ID: "test-alert", + Level: alerts.AlertLevelWarning, + ResourceName: "vm-100", + } + + beforeCall := time.Now() + _, _, textBody := buildResolvedNotificationContent([]*alerts.Alert{alert}, time.Time{}, "") + afterCall := time.Now() + + // The resolved timestamp should be between beforeCall and afterCall + if !strings.Contains(textBody, "Resolved at") { + t.Fatalf("expected 'Resolved at' in body, got: %s", textBody) + } + + // Extract the timestamp from the body and verify it's reasonable + // The format is "Resolved at 2024-01-15T11:00:00Z" or similar + lines := strings.Split(textBody, "\n") + if len(lines) == 0 { + t.Fatalf("expected at least one line in body") + } + firstLine := lines[0] + if !strings.HasPrefix(firstLine, "Resolved at ") { + t.Fatalf("expected first line to start with 'Resolved at ', got: %s", firstLine) + } + timestampStr := strings.TrimPrefix(firstLine, "Resolved at ") + parsedTime, err := time.Parse(time.RFC3339, timestampStr) + if err != nil { + t.Fatalf("failed to parse timestamp %q: %v", timestampStr, err) + } + if parsedTime.Before(beforeCall.Add(-time.Second)) || parsedTime.After(afterCall.Add(time.Second)) { + t.Fatalf("expected timestamp between %v and %v, got %v", beforeCall, afterCall, parsedTime) + } + }) + + t.Run("public URL is appended when provided", func(t *testing.T) { + alert := &alerts.Alert{ + ID: "test-alert", + Level: alerts.AlertLevelWarning, + ResourceName: "vm-100", + } + + _, _, textBody := buildResolvedNotificationContent([]*alerts.Alert{alert}, time.Now(), "https://pulse.example.com") + + if !strings.Contains(textBody, "Dashboard: https://pulse.example.com") { + t.Fatalf("expected dashboard URL in body, got: %s", textBody) + } + }) + + t.Run("public URL is not appended when empty", func(t *testing.T) { + alert := &alerts.Alert{ + ID: "test-alert", + Level: alerts.AlertLevelWarning, + ResourceName: "vm-100", + } + + _, _, textBody := buildResolvedNotificationContent([]*alerts.Alert{alert}, time.Now(), "") + + if strings.Contains(textBody, "Dashboard:") { + t.Fatalf("expected no dashboard URL in body, got: %s", textBody) + } + }) + + t.Run("HTML body properly escapes content", func(t *testing.T) { + alert := &alerts.Alert{ + ID: "test-alert", + Level: alerts.AlertLevelWarning, + ResourceName: "", + Message: "Value > threshold & alert triggered", + } + + _, htmlBody, _ := buildResolvedNotificationContent([]*alerts.Alert{alert}, time.Now(), "") + + // Check that HTML special characters are escaped + if strings.Contains(htmlBody, "