package notifications import ( "strings" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" ) func TestTitleCase(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "empty string", input: "", expected: "", }, { name: "single lowercase word", input: "hello", expected: "Hello", }, { name: "single uppercase word", input: "HELLO", expected: "Hello", }, { name: "single mixed case word", input: "hElLo", expected: "Hello", }, { name: "multiple words lowercase", input: "hello world", expected: "Hello World", }, { name: "multiple words uppercase", input: "HELLO WORLD", expected: "Hello World", }, { name: "multiple words mixed case", input: "hElLo WoRlD", expected: "Hello World", }, { name: "leading space", input: " hello", expected: " Hello", }, { name: "trailing space", input: "hello ", expected: "Hello ", }, { name: "multiple spaces between words", input: "hello world", expected: "Hello World", }, { name: "tab separator", input: "hello\tworld", expected: "Hello\tWorld", }, { name: "newline separator", input: "hello\nworld", expected: "Hello\nWorld", }, { name: "single character", input: "a", expected: "A", }, { name: "numbers and letters", input: "test123 abc456", expected: "Test123 Abc456", }, { name: "hyphenated word stays joined", input: "hello-world", expected: "Hello-world", }, { name: "underscore stays joined", input: "hello_world", expected: "Hello_world", }, { name: "already title case", input: "Hello World", expected: "Hello World", }, { name: "only spaces", input: " ", expected: " ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := titleCase(tt.input) if result != tt.expected { t.Errorf("titleCase(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } func TestFormatDuration(t *testing.T) { tests := []struct { name string duration time.Duration expected string }{ { name: "zero duration", duration: 0, expected: "0 seconds", }, { name: "one second", duration: time.Second, expected: "1 seconds", }, { name: "30 seconds", duration: 30 * time.Second, expected: "30 seconds", }, { name: "59 seconds", duration: 59 * time.Second, expected: "59 seconds", }, { name: "one minute exactly", duration: time.Minute, expected: "1 minutes", }, { name: "90 seconds", duration: 90 * time.Second, expected: "1 minutes", }, { name: "30 minutes", duration: 30 * time.Minute, expected: "30 minutes", }, { name: "59 minutes", duration: 59 * time.Minute, expected: "59 minutes", }, { name: "one hour exactly", duration: time.Hour, expected: "1.0 hours", }, { name: "1.5 hours", duration: 90 * time.Minute, expected: "1.5 hours", }, { name: "12 hours", duration: 12 * time.Hour, expected: "12.0 hours", }, { name: "23 hours", duration: 23 * time.Hour, expected: "23.0 hours", }, { name: "one day exactly", duration: 24 * time.Hour, expected: "1.0 days", }, { name: "1.5 days", duration: 36 * time.Hour, expected: "1.5 days", }, { name: "7 days", duration: 7 * 24 * time.Hour, expected: "7.0 days", }, { name: "30 days", duration: 30 * 24 * time.Hour, expected: "30.0 days", }, { name: "sub-second durations truncate to 0 seconds", duration: 500 * time.Millisecond, expected: "0 seconds", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatDuration(tt.duration) if result != tt.expected { t.Errorf("formatDuration(%v) = %q, want %q", tt.duration, result, tt.expected) } }) } } func TestPluralize(t *testing.T) { tests := []struct { name string count int expected string }{ { name: "count zero", count: 0, expected: "s", }, { name: "count one", count: 1, expected: "", }, { name: "count two", count: 2, expected: "s", }, { name: "count ten", count: 10, expected: "s", }, { name: "count negative", count: -1, expected: "s", }, { name: "large count", count: 1000000, expected: "s", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := pluralize(tt.count) if result != tt.expected { t.Errorf("pluralize(%d) = %q, want %q", tt.count, result, tt.expected) } }) } } func TestFormatMetricValue(t *testing.T) { tests := []struct { name string metricType string value float64 expected string }{ // CPU metrics { name: "cpu percentage", metricType: "cpu", value: 85.5, expected: "85.5%", }, { name: "CPU uppercase", metricType: "CPU", value: 95.3, expected: "95.3%", }, // Memory metrics { name: "memory percentage", metricType: "memory", value: 72.8, expected: "72.8%", }, { name: "Memory mixed case", metricType: "Memory", value: 50.0, expected: "50.0%", }, // Disk metrics { name: "disk percentage", metricType: "disk", value: 90.1, expected: "90.1%", }, { name: "usage percentage", metricType: "usage", value: 45.5, expected: "45.5%", }, // Disk I/O metrics { name: "diskread rate", metricType: "diskread", value: 125.7, expected: "125.7 MB/s", }, { name: "diskwrite rate", metricType: "diskwrite", value: 50.3, expected: "50.3 MB/s", }, { name: "DiskRead uppercase", metricType: "DiskRead", value: 100.0, expected: "100.0 MB/s", }, // Network metrics { name: "networkin rate", metricType: "networkin", value: 75.2, expected: "75.2 MB/s", }, { name: "networkout rate", metricType: "networkout", value: 30.8, expected: "30.8 MB/s", }, // Temperature metrics { name: "temperature celsius", metricType: "temperature", value: 65.5, expected: "65.5°C", }, { name: "Temperature uppercase", metricType: "Temperature", value: 80.0, expected: "80.0°C", }, // Unknown metrics { name: "unknown metric type", metricType: "custom", value: 123.456, expected: "123.5", }, { name: "empty metric type", metricType: "", value: 99.9, expected: "99.9", }, // Edge cases { name: "zero value", metricType: "cpu", value: 0.0, expected: "0.0%", }, { name: "negative value", metricType: "temperature", value: -10.5, expected: "-10.5°C", }, { name: "large value", metricType: "diskread", value: 1000.0, expected: "1000.0 MB/s", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatMetricValue(tt.metricType, tt.value) if result != tt.expected { t.Errorf("formatMetricValue(%q, %v) = %q, want %q", tt.metricType, tt.value, result, tt.expected) } }) } } func TestFormatMetricThreshold(t *testing.T) { tests := []struct { name string metricType string threshold float64 expected string }{ // CPU metrics - note: threshold uses %.0f (no decimal places) { name: "cpu threshold", metricType: "cpu", threshold: 80.0, expected: "80%", }, { name: "CPU uppercase", metricType: "CPU", threshold: 90.0, expected: "90%", }, // Memory metrics { name: "memory threshold", metricType: "memory", threshold: 75.0, expected: "75%", }, // Disk metrics { name: "disk threshold", metricType: "disk", threshold: 85.0, expected: "85%", }, { name: "usage threshold", metricType: "usage", threshold: 95.0, expected: "95%", }, // Disk I/O metrics { name: "diskread threshold", metricType: "diskread", threshold: 100.0, expected: "100 MB/s", }, { name: "diskwrite threshold", metricType: "diskwrite", threshold: 50.0, expected: "50 MB/s", }, // Network metrics { name: "networkin threshold", metricType: "networkin", threshold: 200.0, expected: "200 MB/s", }, { name: "networkout threshold", metricType: "networkout", threshold: 150.0, expected: "150 MB/s", }, // Temperature metrics { name: "temperature threshold", metricType: "temperature", threshold: 80.0, expected: "80°C", }, // Unknown metrics { name: "unknown metric type", metricType: "custom", threshold: 500.0, expected: "500", }, { name: "empty metric type", metricType: "", threshold: 100.0, expected: "100", }, // Edge cases { name: "zero threshold", metricType: "cpu", threshold: 0.0, expected: "0%", }, { name: "decimal threshold rounds", metricType: "cpu", threshold: 85.7, expected: "86%", }, { name: "negative threshold", metricType: "temperature", threshold: -20.0, expected: "-20°C", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatMetricThreshold(tt.metricType, tt.threshold) if result != tt.expected { t.Errorf("formatMetricThreshold(%q, %v) = %q, want %q", tt.metricType, tt.threshold, result, tt.expected) } }) } } func TestEmailTemplate(t *testing.T) { t.Parallel() tests := []struct { name string alerts []*alerts.Alert isSingle bool expectSingleSubject bool // subject contains single alert info vs "Multiple Alerts" subjectContains string }{ { name: "single alert with isSingle=true uses single template", alerts: []*alerts.Alert{ { ID: "alert-1", Level: "critical", Type: "cpu", ResourceName: "test-vm", Value: 95.5, Threshold: 90.0, StartTime: time.Now(), }, }, isSingle: true, expectSingleSubject: true, subjectContains: "test-vm", }, { name: "single alert with isSingle=false uses grouped template", alerts: []*alerts.Alert{ { ID: "alert-1", Level: "warning", Type: "memory", ResourceName: "test-vm", Value: 85.0, Threshold: 80.0, StartTime: time.Now(), }, }, isSingle: false, expectSingleSubject: false, subjectContains: "1 Warning alert", // Grouped template uses "N Level alert(s)" }, { name: "multiple alerts uses grouped template regardless of isSingle", alerts: []*alerts.Alert{ { ID: "alert-1", Level: "critical", Type: "cpu", ResourceName: "vm-1", Value: 95.5, Threshold: 90.0, StartTime: time.Now(), }, { ID: "alert-2", Level: "warning", Type: "memory", ResourceName: "vm-2", Value: 85.0, Threshold: 80.0, StartTime: time.Now(), }, }, isSingle: true, // Even with isSingle=true, multiple alerts use grouped expectSingleSubject: false, subjectContains: "Critical", // Subject shows level counts }, { name: "warning level alert", alerts: []*alerts.Alert{ { ID: "alert-1", Level: "warning", Type: "disk", ResourceName: "storage-1", Value: 88.0, Threshold: 85.0, StartTime: time.Now(), }, }, isSingle: true, expectSingleSubject: true, subjectContains: "Warning", }, { name: "multiple critical alerts only uses grouped template", alerts: []*alerts.Alert{ { ID: "alert-1", Level: "critical", Type: "cpu", ResourceName: "vm-1", Value: 95.5, Threshold: 90.0, StartTime: time.Now(), }, { ID: "alert-2", Level: "critical", Type: "memory", ResourceName: "vm-2", Value: 98.0, Threshold: 90.0, StartTime: time.Now(), }, }, isSingle: false, expectSingleSubject: false, subjectContains: "2 Critical alerts", }, { name: "io type alert formats as I/O", alerts: []*alerts.Alert{ { ID: "alert-io", Level: "warning", Type: "io", ResourceName: "storage-pool", Value: 150.0, Threshold: 100.0, StartTime: time.Now(), }, }, isSingle: true, expectSingleSubject: true, subjectContains: "I/O", }, { name: "custom type alert uses title case", alerts: []*alerts.Alert{ { ID: "alert-custom", Level: "critical", Type: "network_latency", ResourceName: "router-1", Value: 500.0, Threshold: 100.0, StartTime: time.Now(), }, }, isSingle: true, expectSingleSubject: true, subjectContains: "router-1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() subject, htmlBody, textBody := EmailTemplate(tt.alerts, tt.isSingle) // Check subject contains expected content if !strings.Contains(subject, tt.subjectContains) { t.Errorf("subject = %q, want to contain %q", subject, tt.subjectContains) } // Verify HTML body is not empty and contains basic structure if htmlBody == "" { t.Error("htmlBody is empty") } if !strings.Contains(htmlBody, "") { t.Error("htmlBody missing DOCTYPE") } if !strings.Contains(htmlBody, "") { t.Error("htmlBody missing closing html tag") } // Verify text body is not empty if textBody == "" { t.Error("textBody is empty") } // Check for single vs grouped template indicators if tt.expectSingleSubject { if strings.Contains(subject, "Multiple") || strings.Contains(subject, "Alerts]") { t.Errorf("expected single alert subject, got grouped: %q", subject) } } }) } }