mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
Escalation was calling SendAlert() which always sends to all enabled channels, ignoring the per-level channel selection (email/webhook/all). Add SendAlertToChannels() that snapshots only the requested channel configs and uses a distinct "_escalation" queue type so the dequeue handler skips cooldown writes — preventing interference with the alert manager's own re-notify cadence.
888 lines
21 KiB
Go
888 lines
21 KiB
Go
package notifications
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
)
|
|
|
|
func TestAnnotateResolvedMetadata(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
alert *alerts.Alert
|
|
resolvedAt time.Time
|
|
checkFn func(*testing.T, *alerts.Alert)
|
|
}{
|
|
{
|
|
name: "nil alert",
|
|
alert: nil,
|
|
resolvedAt: time.Now(),
|
|
checkFn: func(t *testing.T, a *alerts.Alert) {
|
|
// Should not panic, nothing to check
|
|
},
|
|
},
|
|
{
|
|
name: "alert with nil metadata",
|
|
alert: &alerts.Alert{ID: "test-1"},
|
|
resolvedAt: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC),
|
|
checkFn: func(t *testing.T, a *alerts.Alert) {
|
|
if a.Metadata == nil {
|
|
t.Error("Metadata should be initialized")
|
|
return
|
|
}
|
|
raw, ok := a.Metadata[metadataResolvedAt]
|
|
if !ok {
|
|
t.Error("resolvedAt key should be set")
|
|
return
|
|
}
|
|
ts, ok := raw.(string)
|
|
if !ok {
|
|
t.Errorf("resolvedAt should be string, got %T", raw)
|
|
return
|
|
}
|
|
expected := "2025-01-15T10:30:00Z"
|
|
if ts != expected {
|
|
t.Errorf("resolvedAt = %q, want %q", ts, expected)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with existing metadata",
|
|
alert: &alerts.Alert{
|
|
ID: "test-2",
|
|
Metadata: map[string]interface{}{
|
|
"existingKey": "existingValue",
|
|
},
|
|
},
|
|
resolvedAt: time.Date(2025, 6, 20, 15, 45, 30, 0, time.UTC),
|
|
checkFn: func(t *testing.T, a *alerts.Alert) {
|
|
// Should preserve existing metadata
|
|
if v, ok := a.Metadata["existingKey"]; !ok || v != "existingValue" {
|
|
t.Error("existing metadata should be preserved")
|
|
}
|
|
// Should add resolvedAt
|
|
raw, ok := a.Metadata[metadataResolvedAt]
|
|
if !ok {
|
|
t.Error("resolvedAt key should be set")
|
|
return
|
|
}
|
|
ts, ok := raw.(string)
|
|
if !ok {
|
|
t.Errorf("resolvedAt should be string, got %T", raw)
|
|
return
|
|
}
|
|
expected := "2025-06-20T15:45:30Z"
|
|
if ts != expected {
|
|
t.Errorf("resolvedAt = %q, want %q", ts, expected)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "overwrites existing resolvedAt",
|
|
alert: &alerts.Alert{
|
|
ID: "test-3",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: "old-value",
|
|
},
|
|
},
|
|
resolvedAt: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC),
|
|
checkFn: func(t *testing.T, a *alerts.Alert) {
|
|
raw := a.Metadata[metadataResolvedAt]
|
|
ts, ok := raw.(string)
|
|
if !ok {
|
|
t.Errorf("resolvedAt should be string, got %T", raw)
|
|
return
|
|
}
|
|
expected := "2025-12-01T00:00:00Z"
|
|
if ts != expected {
|
|
t.Errorf("resolvedAt = %q, want %q (should overwrite old value)", ts, expected)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
annotateResolvedMetadata(tc.alert, tc.resolvedAt)
|
|
tc.checkFn(t, tc.alert)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveAppriseNotificationType(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
alerts []*alerts.Alert
|
|
expected string
|
|
}{
|
|
{
|
|
name: "nil slice",
|
|
alerts: nil,
|
|
expected: "info",
|
|
},
|
|
{
|
|
name: "empty slice",
|
|
alerts: []*alerts.Alert{},
|
|
expected: "info",
|
|
},
|
|
{
|
|
name: "slice with nil alert",
|
|
alerts: []*alerts.Alert{nil},
|
|
expected: "info",
|
|
},
|
|
{
|
|
name: "slice with multiple nil alerts",
|
|
alerts: []*alerts.Alert{nil, nil, nil},
|
|
expected: "info",
|
|
},
|
|
{
|
|
name: "single info-level alert (no level set)",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: ""},
|
|
},
|
|
expected: "info",
|
|
},
|
|
{
|
|
name: "single warning alert",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: alerts.AlertLevelWarning},
|
|
},
|
|
expected: "warning",
|
|
},
|
|
{
|
|
name: "single critical alert",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: alerts.AlertLevelCritical},
|
|
},
|
|
expected: "failure",
|
|
},
|
|
{
|
|
name: "multiple warnings",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: alerts.AlertLevelWarning},
|
|
{ID: "test-2", Level: alerts.AlertLevelWarning},
|
|
},
|
|
expected: "warning",
|
|
},
|
|
{
|
|
name: "warning and critical - returns failure (critical takes priority)",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: alerts.AlertLevelWarning},
|
|
{ID: "test-2", Level: alerts.AlertLevelCritical},
|
|
},
|
|
expected: "failure",
|
|
},
|
|
{
|
|
name: "critical first - returns failure immediately",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: alerts.AlertLevelCritical},
|
|
{ID: "test-2", Level: alerts.AlertLevelWarning},
|
|
},
|
|
expected: "failure",
|
|
},
|
|
{
|
|
name: "mixed with nil - critical takes priority",
|
|
alerts: []*alerts.Alert{
|
|
nil,
|
|
{ID: "test-1", Level: alerts.AlertLevelWarning},
|
|
nil,
|
|
{ID: "test-2", Level: alerts.AlertLevelCritical},
|
|
},
|
|
expected: "failure",
|
|
},
|
|
{
|
|
name: "info and warning - warning takes priority",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: ""},
|
|
{ID: "test-2", Level: alerts.AlertLevelWarning},
|
|
{ID: "test-3", Level: ""},
|
|
},
|
|
expected: "warning",
|
|
},
|
|
{
|
|
name: "unknown level treated as info",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Level: "unknown"},
|
|
},
|
|
expected: "info",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := resolveAppriseNotificationType(tc.alerts)
|
|
if result != tc.expected {
|
|
t.Errorf("resolveAppriseNotificationType() = %q, want %q", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeQueueType(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
notifType string
|
|
expectedType string
|
|
expectedEvent notificationEvent
|
|
}{
|
|
{
|
|
name: "email type",
|
|
notifType: "email",
|
|
expectedType: "email",
|
|
expectedEvent: eventAlert,
|
|
},
|
|
{
|
|
name: "webhook type",
|
|
notifType: "webhook",
|
|
expectedType: "webhook",
|
|
expectedEvent: eventAlert,
|
|
},
|
|
{
|
|
name: "apprise type",
|
|
notifType: "apprise",
|
|
expectedType: "apprise",
|
|
expectedEvent: eventAlert,
|
|
},
|
|
{
|
|
name: "email_resolved type",
|
|
notifType: "email_resolved",
|
|
expectedType: "email",
|
|
expectedEvent: eventResolved,
|
|
},
|
|
{
|
|
name: "webhook_resolved type",
|
|
notifType: "webhook_resolved",
|
|
expectedType: "webhook",
|
|
expectedEvent: eventResolved,
|
|
},
|
|
{
|
|
name: "apprise_resolved type",
|
|
notifType: "apprise_resolved",
|
|
expectedType: "apprise",
|
|
expectedEvent: eventResolved,
|
|
},
|
|
{
|
|
name: "empty type",
|
|
notifType: "",
|
|
expectedType: "",
|
|
expectedEvent: eventAlert,
|
|
},
|
|
{
|
|
name: "unknown type",
|
|
notifType: "unknown",
|
|
expectedType: "unknown",
|
|
expectedEvent: eventAlert,
|
|
},
|
|
{
|
|
name: "unknown_resolved type",
|
|
notifType: "unknown_resolved",
|
|
expectedType: "unknown",
|
|
expectedEvent: eventResolved,
|
|
},
|
|
{
|
|
name: "type with _resolved in middle - not stripped",
|
|
notifType: "_resolved_email",
|
|
expectedType: "_resolved_email",
|
|
expectedEvent: eventAlert,
|
|
},
|
|
{
|
|
name: "just _resolved suffix",
|
|
notifType: "_resolved",
|
|
expectedType: "",
|
|
expectedEvent: eventResolved,
|
|
},
|
|
{
|
|
name: "email_escalation type",
|
|
notifType: "email_escalation",
|
|
expectedType: "email",
|
|
expectedEvent: eventEscalation,
|
|
},
|
|
{
|
|
name: "webhook_escalation type",
|
|
notifType: "webhook_escalation",
|
|
expectedType: "webhook",
|
|
expectedEvent: eventEscalation,
|
|
},
|
|
{
|
|
name: "apprise_escalation type",
|
|
notifType: "apprise_escalation",
|
|
expectedType: "apprise",
|
|
expectedEvent: eventEscalation,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotType, gotEvent := normalizeQueueType(tc.notifType)
|
|
if gotType != tc.expectedType {
|
|
t.Errorf("normalizeQueueType() type = %q, want %q", gotType, tc.expectedType)
|
|
}
|
|
if gotEvent != tc.expectedEvent {
|
|
t.Errorf("normalizeQueueType() event = %q, want %q", gotEvent, tc.expectedEvent)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolvedTimeFromAlerts(t *testing.T) {
|
|
fixedTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
|
|
fixedTimeStr := fixedTime.Format(time.RFC3339)
|
|
|
|
tests := []struct {
|
|
name string
|
|
alerts []*alerts.Alert
|
|
checkFn func(*testing.T, time.Time)
|
|
}{
|
|
{
|
|
name: "nil slice - returns current time",
|
|
alerts: nil,
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
// Should return a time close to now
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for nil slice")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "empty slice - returns current time",
|
|
alerts: []*alerts.Alert{},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for empty slice")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "slice with nil alert - returns current time",
|
|
alerts: []*alerts.Alert{nil},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for nil alert")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with nil metadata - returns current time",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Metadata: nil},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for nil metadata")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert without resolvedAt key - returns current time",
|
|
alerts: []*alerts.Alert{
|
|
{ID: "test-1", Metadata: map[string]interface{}{"other": "value"}},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for missing resolvedAt")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with string resolvedAt (RFC3339)",
|
|
alerts: []*alerts.Alert{
|
|
{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: fixedTimeStr,
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if !result.Equal(fixedTime) {
|
|
t.Errorf("got %v, want %v", result, fixedTime)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with float64 resolvedAt (Unix timestamp)",
|
|
alerts: []*alerts.Alert{
|
|
{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: float64(fixedTime.Unix()),
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
// Unix timestamp loses nanoseconds
|
|
expected := time.Unix(fixedTime.Unix(), 0)
|
|
if !result.Equal(expected) {
|
|
t.Errorf("got %v, want %v", result, expected)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with zero float64 - returns current time",
|
|
alerts: []*alerts.Alert{
|
|
{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: float64(0),
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for zero timestamp")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with negative float64 - returns current time",
|
|
alerts: []*alerts.Alert{
|
|
{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: float64(-1000),
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for negative timestamp")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with invalid string format - returns current time",
|
|
alerts: []*alerts.Alert{
|
|
{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: "not-a-timestamp",
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for invalid string")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "alert with unsupported type - returns current time",
|
|
alerts: []*alerts.Alert{
|
|
{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: 12345, // int, not float64
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected time close to now for unsupported type")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "multiple alerts - returns first valid resolvedAt",
|
|
alerts: []*alerts.Alert{
|
|
nil,
|
|
{ID: "test-1", Metadata: nil},
|
|
{ID: "test-2", Metadata: map[string]interface{}{}},
|
|
{
|
|
ID: "test-3",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: fixedTimeStr,
|
|
},
|
|
},
|
|
{
|
|
ID: "test-4",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: "2024-01-01T00:00:00Z", // should not be reached
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if !result.Equal(fixedTime) {
|
|
t.Errorf("got %v, want %v (first valid)", result, fixedTime)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "first alert has valid resolvedAt",
|
|
alerts: []*alerts.Alert{
|
|
{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{
|
|
metadataResolvedAt: fixedTimeStr,
|
|
},
|
|
},
|
|
},
|
|
checkFn: func(t *testing.T, result time.Time) {
|
|
if !result.Equal(fixedTime) {
|
|
t.Errorf("got %v, want %v", result, fixedTime)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := resolvedTimeFromAlerts(tc.alerts)
|
|
tc.checkFn(t, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test the event constants
|
|
func TestNotificationEventConstants(t *testing.T) {
|
|
if eventAlert != "alert" {
|
|
t.Errorf("eventAlert = %q, want %q", eventAlert, "alert")
|
|
}
|
|
if eventResolved != "resolved" {
|
|
t.Errorf("eventResolved = %q, want %q", eventResolved, "resolved")
|
|
}
|
|
}
|
|
|
|
// Test the queue type suffix constant
|
|
func TestQueueTypeSuffixConstant(t *testing.T) {
|
|
if queueTypeSuffixResolved != "_resolved" {
|
|
t.Errorf("queueTypeSuffixResolved = %q, want %q", queueTypeSuffixResolved, "_resolved")
|
|
}
|
|
}
|
|
|
|
// Test the metadata key constant
|
|
func TestMetadataKeyConstant(t *testing.T) {
|
|
if metadataResolvedAt != "resolvedAt" {
|
|
t.Errorf("metadataResolvedAt = %q, want %q", metadataResolvedAt, "resolvedAt")
|
|
}
|
|
}
|
|
|
|
func TestCopyEmailConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg EmailConfig
|
|
}{
|
|
{
|
|
name: "empty config",
|
|
cfg: EmailConfig{},
|
|
},
|
|
{
|
|
name: "config with empty To slice",
|
|
cfg: EmailConfig{
|
|
Enabled: true,
|
|
SMTPHost: "smtp.example.com",
|
|
SMTPPort: 587,
|
|
From: "sender@example.com",
|
|
To: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "config with single recipient",
|
|
cfg: EmailConfig{
|
|
Enabled: true,
|
|
SMTPHost: "smtp.example.com",
|
|
SMTPPort: 465,
|
|
Username: "user",
|
|
Password: "pass",
|
|
From: "sender@example.com",
|
|
To: []string{"recipient@example.com"},
|
|
},
|
|
},
|
|
{
|
|
name: "config with multiple recipients",
|
|
cfg: EmailConfig{
|
|
Enabled: true,
|
|
SMTPHost: "mail.company.org",
|
|
SMTPPort: 25,
|
|
From: "alerts@company.org",
|
|
To: []string{"admin@company.org", "ops@company.org", "devops@company.org"},
|
|
TLS: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
copied := copyEmailConfig(tc.cfg)
|
|
|
|
// Verify fields are equal
|
|
if copied.Enabled != tc.cfg.Enabled {
|
|
t.Errorf("Enabled = %v, want %v", copied.Enabled, tc.cfg.Enabled)
|
|
}
|
|
if copied.SMTPHost != tc.cfg.SMTPHost {
|
|
t.Errorf("SMTPHost = %q, want %q", copied.SMTPHost, tc.cfg.SMTPHost)
|
|
}
|
|
if copied.SMTPPort != tc.cfg.SMTPPort {
|
|
t.Errorf("SMTPPort = %d, want %d", copied.SMTPPort, tc.cfg.SMTPPort)
|
|
}
|
|
if copied.From != tc.cfg.From {
|
|
t.Errorf("From = %q, want %q", copied.From, tc.cfg.From)
|
|
}
|
|
if len(copied.To) != len(tc.cfg.To) {
|
|
t.Errorf("To length = %d, want %d", len(copied.To), len(tc.cfg.To))
|
|
}
|
|
|
|
// Verify slice independence (if original has elements)
|
|
if len(tc.cfg.To) > 0 {
|
|
originalTo := tc.cfg.To[0]
|
|
copied.To[0] = "modified@example.com"
|
|
if tc.cfg.To[0] != originalTo {
|
|
t.Error("Modifying copied.To should not affect original")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyWebhookConfigs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
webhooks []WebhookConfig
|
|
wantNil bool
|
|
}{
|
|
{
|
|
name: "nil input",
|
|
webhooks: nil,
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty slice",
|
|
webhooks: []WebhookConfig{},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "single webhook without maps",
|
|
webhooks: []WebhookConfig{
|
|
{
|
|
Enabled: true,
|
|
URL: "https://hooks.example.com/webhook",
|
|
Method: "POST",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "webhook with headers",
|
|
webhooks: []WebhookConfig{
|
|
{
|
|
Enabled: true,
|
|
URL: "https://api.example.com/alerts",
|
|
Method: "POST",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer token123",
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "webhook with custom fields",
|
|
webhooks: []WebhookConfig{
|
|
{
|
|
Enabled: true,
|
|
URL: "https://pushover.net/api",
|
|
CustomFields: map[string]string{
|
|
"priority": "1",
|
|
"sound": "alarm",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple webhooks with all fields",
|
|
webhooks: []WebhookConfig{
|
|
{
|
|
Enabled: true,
|
|
URL: "https://discord.com/api/webhooks/123",
|
|
Headers: map[string]string{"X-Custom": "value"},
|
|
CustomFields: map[string]string{"key": "val"},
|
|
},
|
|
{
|
|
Enabled: false,
|
|
URL: "https://slack.com/api/post",
|
|
Method: "POST",
|
|
Headers: map[string]string{"Authorization": "xoxb-token"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
copied := copyWebhookConfigs(tc.webhooks)
|
|
|
|
if tc.wantNil {
|
|
if copied != nil {
|
|
t.Errorf("Expected nil, got %v", copied)
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(copied) != len(tc.webhooks) {
|
|
t.Errorf("Length = %d, want %d", len(copied), len(tc.webhooks))
|
|
return
|
|
}
|
|
|
|
// Verify each webhook
|
|
for i := range tc.webhooks {
|
|
if copied[i].URL != tc.webhooks[i].URL {
|
|
t.Errorf("[%d] URL = %q, want %q", i, copied[i].URL, tc.webhooks[i].URL)
|
|
}
|
|
if copied[i].Enabled != tc.webhooks[i].Enabled {
|
|
t.Errorf("[%d] Enabled = %v, want %v", i, copied[i].Enabled, tc.webhooks[i].Enabled)
|
|
}
|
|
|
|
// Verify headers independence
|
|
if len(tc.webhooks[i].Headers) > 0 {
|
|
for k := range tc.webhooks[i].Headers {
|
|
originalVal := tc.webhooks[i].Headers[k]
|
|
copied[i].Headers[k] = "modified"
|
|
if tc.webhooks[i].Headers[k] != originalVal {
|
|
t.Errorf("[%d] Modifying Headers should not affect original", i)
|
|
}
|
|
break // Test one key is enough
|
|
}
|
|
}
|
|
|
|
// Verify custom fields independence
|
|
if len(tc.webhooks[i].CustomFields) > 0 {
|
|
for k := range tc.webhooks[i].CustomFields {
|
|
originalVal := tc.webhooks[i].CustomFields[k]
|
|
copied[i].CustomFields[k] = "modified"
|
|
if tc.webhooks[i].CustomFields[k] != originalVal {
|
|
t.Errorf("[%d] Modifying CustomFields should not affect original", i)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyAppriseConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg AppriseConfig
|
|
}{
|
|
{
|
|
name: "empty config",
|
|
cfg: AppriseConfig{},
|
|
},
|
|
{
|
|
name: "config with empty targets",
|
|
cfg: AppriseConfig{
|
|
Enabled: true,
|
|
Targets: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "config with single target",
|
|
cfg: AppriseConfig{
|
|
Enabled: true,
|
|
Targets: []string{"discord://webhook/id/token"},
|
|
},
|
|
},
|
|
{
|
|
name: "config with multiple targets",
|
|
cfg: AppriseConfig{
|
|
Enabled: true,
|
|
Targets: []string{
|
|
"slack://token/channel",
|
|
"telegram://bot_token/chat_id",
|
|
"email://user:pass@smtp.example.com",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
copied := copyAppriseConfig(tc.cfg)
|
|
|
|
if copied.Enabled != tc.cfg.Enabled {
|
|
t.Errorf("Enabled = %v, want %v", copied.Enabled, tc.cfg.Enabled)
|
|
}
|
|
if len(copied.Targets) != len(tc.cfg.Targets) {
|
|
t.Errorf("Targets length = %d, want %d", len(copied.Targets), len(tc.cfg.Targets))
|
|
}
|
|
|
|
// Verify slice independence
|
|
if len(tc.cfg.Targets) > 0 {
|
|
originalTarget := tc.cfg.Targets[0]
|
|
copied.Targets[0] = "modified://target"
|
|
if tc.cfg.Targets[0] != originalTarget {
|
|
t.Error("Modifying copied.Targets should not affect original")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildNotificationTestAlert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
alert := buildNotificationTestAlert()
|
|
|
|
// Verify required fields are set
|
|
if alert.ID != "test-alert" {
|
|
t.Errorf("ID = %q, want %q", alert.ID, "test-alert")
|
|
}
|
|
if alert.Type != "cpu" {
|
|
t.Errorf("Type = %q, want %q", alert.Type, "cpu")
|
|
}
|
|
if alert.Level != "warning" {
|
|
t.Errorf("Level = %q, want %q", alert.Level, "warning")
|
|
}
|
|
if alert.ResourceID != "test-resource" {
|
|
t.Errorf("ResourceID = %q, want %q", alert.ResourceID, "test-resource")
|
|
}
|
|
if alert.ResourceName != "Test Resource" {
|
|
t.Errorf("ResourceName = %q, want %q", alert.ResourceName, "Test Resource")
|
|
}
|
|
if alert.Node == "" {
|
|
t.Error("Node should not be empty")
|
|
}
|
|
if alert.Instance == "" {
|
|
t.Error("Instance should not be empty")
|
|
}
|
|
if alert.Message == "" {
|
|
t.Error("Message should not be empty")
|
|
}
|
|
if alert.Value == 0 {
|
|
t.Error("Value should not be zero")
|
|
}
|
|
if alert.Threshold == 0 {
|
|
t.Error("Threshold should not be zero")
|
|
}
|
|
if alert.StartTime.IsZero() {
|
|
t.Error("StartTime should not be zero")
|
|
}
|
|
if alert.LastSeen.IsZero() {
|
|
t.Error("LastSeen should not be zero")
|
|
}
|
|
if alert.Metadata == nil {
|
|
t.Error("Metadata should not be nil")
|
|
}
|
|
|
|
// Verify StartTime is in the past (shows alert has been active)
|
|
if !alert.StartTime.Before(time.Now()) {
|
|
t.Error("StartTime should be in the past")
|
|
}
|
|
|
|
// Verify metadata contains resourceType
|
|
if rt, ok := alert.Metadata["resourceType"]; !ok || rt != "vm" {
|
|
t.Errorf("Metadata[resourceType] = %v, want %q", rt, "vm")
|
|
}
|
|
}
|