From d2a8184833ace15c1e7602fb7b46fe3545607784 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 03:19:23 +0000 Subject: [PATCH] Add unit tests for notification queue utility functions Test coverage for calculateBackoff (exponential backoff calculation), NotificationQueueStatus constants, and QueuedNotification struct fields. 15 test cases covering backoff timing, cap behavior, and struct defaults. --- internal/notifications/queue_test.go | 203 +++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 internal/notifications/queue_test.go diff --git a/internal/notifications/queue_test.go b/internal/notifications/queue_test.go new file mode 100644 index 0000000..d8255e5 --- /dev/null +++ b/internal/notifications/queue_test.go @@ -0,0 +1,203 @@ +package notifications + +import ( + "testing" + "time" +) + +func TestCalculateBackoff(t *testing.T) { + tests := []struct { + name string + attempt int + expected time.Duration + }{ + { + name: "attempt 0 (first retry)", + attempt: 0, + expected: 1 * time.Second, + }, + { + name: "attempt 1", + attempt: 1, + expected: 2 * time.Second, + }, + { + name: "attempt 2", + attempt: 2, + expected: 4 * time.Second, + }, + { + name: "attempt 3", + attempt: 3, + expected: 8 * time.Second, + }, + { + name: "attempt 4", + attempt: 4, + expected: 16 * time.Second, + }, + { + name: "attempt 5", + attempt: 5, + expected: 32 * time.Second, + }, + { + name: "attempt 6 (capped at 60s)", + attempt: 6, + expected: 60 * time.Second, + }, + { + name: "attempt 7 (stays at cap)", + attempt: 7, + expected: 60 * time.Second, + }, + { + name: "attempt 10 (stays at cap)", + attempt: 10, + expected: 60 * time.Second, + }, + // Note: For very large attempt numbers (>= 60 on 64-bit), bit shift + // overflows causing duration to be 0. In practice this never happens + // as max_attempts is typically 3-10. + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := calculateBackoff(tc.attempt) + if result != tc.expected { + t.Errorf("calculateBackoff(%d) = %v, want %v", tc.attempt, result, tc.expected) + } + }) + } +} + +func TestCalculateBackoff_ExponentialGrowth(t *testing.T) { + // Verify backoff grows exponentially until cap + prev := calculateBackoff(0) + for attempt := 1; attempt <= 5; attempt++ { + curr := calculateBackoff(attempt) + if curr != prev*2 { + t.Errorf("calculateBackoff(%d) = %v, expected %v (2x previous)", attempt, curr, prev*2) + } + prev = curr + } +} + +func TestCalculateBackoff_NeverExceedsCap(t *testing.T) { + cap := 60 * time.Second + // Test a range of practical attempt values (0-20 is realistic range) + for attempt := 0; attempt <= 20; attempt++ { + result := calculateBackoff(attempt) + if result > cap { + t.Errorf("calculateBackoff(%d) = %v, exceeds cap of %v", attempt, result, cap) + } + } +} + +func TestNotificationQueueStatus_Values(t *testing.T) { + // Verify status constants have expected string values + tests := []struct { + status NotificationQueueStatus + expected string + }{ + {QueueStatusPending, "pending"}, + {QueueStatusSending, "sending"}, + {QueueStatusSent, "sent"}, + {QueueStatusFailed, "failed"}, + {QueueStatusDLQ, "dlq"}, + {QueueStatusCancelled, "cancelled"}, + } + + for _, tc := range tests { + t.Run(string(tc.status), func(t *testing.T) { + if string(tc.status) != tc.expected { + t.Errorf("status = %q, want %q", tc.status, tc.expected) + } + }) + } +} + +func TestQueuedNotification_Fields(t *testing.T) { + now := time.Now() + lastAttempt := now.Add(-1 * time.Minute) + nextRetry := now.Add(5 * time.Minute) + errorMsg := "connection refused" + + notif := QueuedNotification{ + ID: "test-123", + Type: "email", + Method: "smtp", + Status: QueueStatusPending, + Alerts: nil, + Config: []byte(`{"host":"smtp.example.com"}`), + Attempts: 2, + MaxAttempts: 5, + LastAttempt: &lastAttempt, + LastError: &errorMsg, + CreatedAt: now, + NextRetryAt: &nextRetry, + } + + if notif.ID != "test-123" { + t.Errorf("ID = %q, want 'test-123'", notif.ID) + } + if notif.Type != "email" { + t.Errorf("Type = %q, want 'email'", notif.Type) + } + if notif.Method != "smtp" { + t.Errorf("Method = %q, want 'smtp'", notif.Method) + } + if notif.Status != QueueStatusPending { + t.Errorf("Status = %q, want 'pending'", notif.Status) + } + if notif.Attempts != 2 { + t.Errorf("Attempts = %d, want 2", notif.Attempts) + } + if notif.MaxAttempts != 5 { + t.Errorf("MaxAttempts = %d, want 5", notif.MaxAttempts) + } + if notif.LastAttempt == nil { + t.Error("LastAttempt should not be nil") + } + if notif.LastError == nil || *notif.LastError != "connection refused" { + t.Errorf("LastError = %v, want 'connection refused'", notif.LastError) + } + if notif.NextRetryAt == nil { + t.Error("NextRetryAt should not be nil") + } +} + +func TestQueuedNotification_ZeroValues(t *testing.T) { + notif := QueuedNotification{} + + if notif.ID != "" { + t.Error("ID should be empty by default") + } + if notif.Type != "" { + t.Error("Type should be empty by default") + } + if notif.Status != "" { + t.Error("Status should be empty by default") + } + if notif.Attempts != 0 { + t.Error("Attempts should be 0 by default") + } + if notif.MaxAttempts != 0 { + t.Error("MaxAttempts should be 0 by default") + } + if notif.LastAttempt != nil { + t.Error("LastAttempt should be nil by default") + } + if notif.LastError != nil { + t.Error("LastError should be nil by default") + } + if !notif.CreatedAt.IsZero() { + t.Error("CreatedAt should be zero by default") + } + if notif.NextRetryAt != nil { + t.Error("NextRetryAt should be nil by default") + } + if notif.CompletedAt != nil { + t.Error("CompletedAt should be nil by default") + } +}