Pulse/internal/notifications/queue_test.go
rcourtman 76dc690840 Suppress recovery notifications when the firing never left the queue
An alert that resolved while its firing notification was still in the
grouping window or waiting in the persistent queue (alert delay pushes
activation close to resolution; quiet-hours replay defers delivery)
produced a recovery-only notification: CancelAlert dropped the queued
firing, but LastNotified had been set optimistically at dispatch, so
the resolved-notification gate believed the firing had been sent.

CancelAlert now reports whether it cancelled a firing notification that
had not been delivered (grouping window entries and pending queue rows;
mid-send rows are excluded because their delivery may still complete),
and handleAlertResolved suppresses the recovery in that case. This also
covers the quiet-hours replay bypass: a recovery only follows a deferred
firing if the replay was actually delivered.

Addresses #1553
2026-07-11 18:28:28 +01:00

1579 lines
45 KiB
Go

package notifications
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
)
func TestCalculateBackoff(t *testing.T) {
tests := []struct {
name string
attempt int
expected time.Duration
}{
{
name: "negative attempt defaults to first backoff",
attempt: -1,
expected: 1 * time.Second,
},
{
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,
},
{
name: "very large attempts stay capped",
attempt: 60,
expected: 60 * time.Second,
},
}
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 TestNewNotificationQueue_WhitespaceDataDirUsesDefault(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
nq, err := NewNotificationQueue(" \t ")
if err != nil {
t.Fatalf("Failed to create notification queue with whitespace data dir: %v", err)
}
defer func() { _ = nq.Stop() }()
expectedDBPath := filepath.Join(utils.GetDataDir(), notificationQueueDirName, notificationQueueFileName)
if nq.dbPath != expectedDBPath {
t.Fatalf("expected db path %q, got %q", expectedDBPath, nq.dbPath)
}
}
func TestResolveNotificationQueuePathCanonicalizesExplicitDataDir(t *testing.T) {
rawDataDir := filepath.Join(t.TempDir(), "queue", "..", "queue", ".")
resolvedDir, dbPath, err := resolveNotificationQueuePath(rawDataDir)
if err != nil {
t.Fatalf("resolveNotificationQueuePath() error = %v", err)
}
expectedDir := filepath.Clean(rawDataDir)
if resolvedDir != expectedDir {
t.Fatalf("resolved dir = %q, want %q", resolvedDir, expectedDir)
}
expectedDBPath := filepath.Join(expectedDir, notificationQueueFileName)
if dbPath != expectedDBPath {
t.Fatalf("db path = %q, want %q", dbPath, expectedDBPath)
}
}
func TestResolveNotificationQueuePathWhitespaceUsesDefaultNotificationsDir(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
resolvedDir, dbPath, err := resolveNotificationQueuePath(" \t ")
if err != nil {
t.Fatalf("resolveNotificationQueuePath() error = %v", err)
}
expectedDir := filepath.Join(utils.GetDataDir(), notificationQueueDirName)
if resolvedDir != expectedDir {
t.Fatalf("resolved dir = %q, want %q", resolvedDir, expectedDir)
}
expectedDBPath := filepath.Join(expectedDir, notificationQueueFileName)
if dbPath != expectedDBPath {
t.Fatalf("db path = %q, want %q", dbPath, expectedDBPath)
}
}
func TestEnqueue_ValidatesAndNormalizesInput(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
t.Run("rejects nil notification", func(t *testing.T) {
err := nq.Enqueue(nil)
if err == nil {
t.Fatalf("expected error for nil notification")
}
})
t.Run("rejects empty type", func(t *testing.T) {
err := nq.Enqueue(&QueuedNotification{
Config: []byte(`{}`),
})
if err == nil {
t.Fatalf("expected error for empty notification type")
}
})
t.Run("rejects empty config", func(t *testing.T) {
err := nq.Enqueue(&QueuedNotification{
Type: "email",
})
if err == nil {
t.Fatalf("expected error for empty notification config")
}
})
t.Run("normalizes attempts and type", func(t *testing.T) {
futureRetry := time.Now().Add(1 * time.Hour)
notif := &QueuedNotification{
ID: "normalize-test",
Type: " email ",
Status: QueueStatusPending,
Attempts: -10,
MaxAttempts: -2,
Config: []byte(`{}`),
NextRetryAt: &futureRetry, // keep background worker from picking it up
Alerts: []*alerts.Alert{{ID: "a-1"}},
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("enqueue failed: %v", err)
}
var dbType string
var attempts int
var maxAttempts int
err := nq.db.QueryRow(`SELECT type, attempts, max_attempts FROM notification_queue WHERE id = ?`, notif.ID).Scan(&dbType, &attempts, &maxAttempts)
if err != nil {
t.Fatalf("failed to query normalized notification: %v", err)
}
if dbType != "email" {
t.Fatalf("expected trimmed type 'email', got %q", dbType)
}
if attempts != 0 {
t.Fatalf("expected attempts to normalize to 0, got %d", attempts)
}
if maxAttempts != defaultQueueMaxAttempts {
t.Fatalf("expected max attempts to normalize to %d, got %d", defaultQueueMaxAttempts, maxAttempts)
}
})
}
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 TestCalculateBackoff_LargeAttemptStillCapped(t *testing.T) {
for _, attempt := range []int{60, 1000} {
if got := calculateBackoff(attempt); got != 60*time.Second {
t.Errorf("calculateBackoff(%d) = %v, want %v", attempt, got, 60*time.Second)
}
}
}
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")
}
}
func TestCancelByAlertIdentifiers_EmptyInput(t *testing.T) {
// Create a temporary queue for testing
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
// Empty slice should return nil without error
count, err := nq.CancelByAlertIdentifiers([]string{})
if err != nil {
t.Errorf("CancelByAlertIdentifiers with empty slice returned error: %v", err)
}
if count != 0 {
t.Errorf("CancelByAlertIdentifiers with empty slice returned count %d, want 0", count)
}
// Nil slice should also return nil without error
count, err = nq.CancelByAlertIdentifiers(nil)
if err != nil {
t.Errorf("CancelByAlertIdentifiers with nil slice returned error: %v", err)
}
if count != 0 {
t.Errorf("CancelByAlertIdentifiers with nil slice returned count %d, want 0", count)
}
}
func TestCancelByAlertIdentifiers_NoMatchingNotifications(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Enqueue a notification with alert-1 (far future NextRetryAt so background processor doesn't pick it up)
futureRetry := time.Now().Add(1 * time.Hour)
notif := &QueuedNotification{
ID: "notif-1",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-1"}},
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
// Cancel with non-matching alert ID
count, err := nq.CancelByAlertIdentifiers([]string{"alert-2"})
if err != nil {
t.Errorf("CancelByAlertIdentifiers returned error: %v", err)
}
if count != 0 {
t.Errorf("CancelByAlertIdentifiers returned count %d, want 0", count)
}
// Verify the notification is still pending using GetQueueStats
stats, err := nq.GetQueueStats()
if err != nil {
t.Fatalf("GetQueueStats failed: %v", err)
}
if stats["pending"] != 1 {
t.Errorf("Expected 1 pending notification, got %d (stats: %v)", stats["pending"], stats)
}
if stats["cancelled"] != 0 {
t.Errorf("Expected 0 cancelled notifications, got %d", stats["cancelled"])
}
}
func TestNewNotificationQueueCreatesSecureDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("directory permission bits are not reliably enforced on windows")
}
baseDir := t.TempDir()
queueDir := baseDir + "/notification-queue"
nq, err := NewNotificationQueue(queueDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
info, err := os.Stat(queueDir)
if err != nil {
t.Fatalf("failed to stat queue directory: %v", err)
}
if perms := info.Mode().Perm(); perms != 0700 {
t.Fatalf("expected queue directory permissions 0700, got %#o", perms)
}
}
func TestNewNotificationQueue_MigratesAuditAlertIdentifiersColumn(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "notification_queue.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
legacySchema := `
CREATE TABLE notification_queue (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
method TEXT,
status TEXT NOT NULL,
alerts TEXT NOT NULL,
config TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
last_attempt INTEGER,
last_error TEXT,
created_at INTEGER NOT NULL,
next_retry_at INTEGER,
completed_at INTEGER,
payload_bytes INTEGER
);
CREATE TABLE notification_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notification_id TEXT NOT NULL,
type TEXT NOT NULL,
method TEXT,
status TEXT NOT NULL,
alert_ids TEXT,
alert_count INTEGER,
attempts INTEGER,
success BOOLEAN,
error_message TEXT,
payload_size INTEGER,
timestamp INTEGER NOT NULL
);
`
if _, err := db.Exec(legacySchema); err != nil {
_ = db.Close()
t.Fatalf("create legacy schema: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("NewNotificationQueue failed: %v", err)
}
defer func() { _ = nq.Stop() }()
columns, err := nq.tableColumns("notification_audit")
if err != nil {
t.Fatalf("tableColumns(notification_audit) failed: %v", err)
}
if !columns[notificationAuditAlertIdentifiersColumn] {
t.Fatalf("expected migrated alert_identifiers column, got %#v", columns)
}
if columns[legacyNotificationAuditAlertIdentifiersColumn] {
t.Fatalf(
"did not expect legacy %s column after migration, got %#v",
legacyNotificationAuditAlertIdentifiersColumn,
columns,
)
}
}
func TestCancelByAlertIdentifiers_MatchingNotificationCancelled(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Enqueue a notification with alert-1 (far future NextRetryAt so background processor doesn't pick it up)
futureRetry := time.Now().Add(1 * time.Hour)
notif := &QueuedNotification{
ID: "notif-1",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-1"}},
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
// Cancel with matching alert ID
count, err := nq.CancelByAlertIdentifiers([]string{"alert-1"})
if err != nil {
t.Errorf("CancelByAlertIdentifiers returned error: %v", err)
}
if count != 1 {
t.Errorf("CancelByAlertIdentifiers returned count %d, want 1", count)
}
// Verify the notification is now cancelled using GetQueueStats
stats, err := nq.GetQueueStats()
if err != nil {
t.Fatalf("GetQueueStats failed: %v", err)
}
if stats["pending"] != 0 {
t.Errorf("Expected 0 pending notifications, got %d", stats["pending"])
}
if stats["cancelled"] != 1 {
t.Errorf("Expected 1 cancelled notification, got %d (stats: %v)", stats["cancelled"], stats)
}
var completedAt sql.NullInt64
err = nq.db.QueryRow(`SELECT completed_at FROM notification_queue WHERE id = ?`, "notif-1").Scan(&completedAt)
if err != nil {
t.Fatalf("Failed to query completed_at: %v", err)
}
if !completedAt.Valid || completedAt.Int64 <= 0 {
t.Errorf("Expected completed_at to be set for cancelled notification, got %+v", completedAt)
}
}
func TestCancelByAlertIdentifiers_PreservesResolvedNotifications(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
firing := &QueuedNotification{
ID: "notif-firing",
Type: "webhook",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-1"}},
}
resolved := &QueuedNotification{
ID: "notif-resolved",
Type: "webhook" + queueTypeSuffixResolved,
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-1"}},
}
for _, notif := range []*QueuedNotification{firing, resolved} {
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue %s: %v", notif.ID, err)
}
}
count, err := nq.CancelByAlertIdentifiers([]string{"alert-1"})
if err != nil {
t.Fatalf("CancelByAlertIdentifiers returned error: %v", err)
}
if count != 1 {
t.Fatalf("CancelByAlertIdentifiers returned count %d, want 1 (only the firing entry counts)", count)
}
var statusFiring NotificationQueueStatus
if err := nq.db.QueryRow(`SELECT status FROM notification_queue WHERE id = ?`, firing.ID).Scan(&statusFiring); err != nil {
t.Fatalf("failed to read firing notification status: %v", err)
}
if statusFiring != QueueStatusCancelled {
t.Fatalf("expected firing notification to be cancelled, got %s", statusFiring)
}
var statusResolved NotificationQueueStatus
if err := nq.db.QueryRow(`SELECT status FROM notification_queue WHERE id = ?`, resolved.ID).Scan(&statusResolved); err != nil {
t.Fatalf("failed to read resolved notification status: %v", err)
}
if statusResolved != QueueStatusPending {
t.Fatalf("expected resolved notification to remain pending, got %s", statusResolved)
}
}
func TestCancelByAlertIdentifiers_MultipleAlertsPartialMatch(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Enqueue a notification with multiple alerts (far future NextRetryAt so background processor doesn't pick it up)
futureRetry := time.Now().Add(1 * time.Hour)
notif := &QueuedNotification{
ID: "notif-multi",
Type: "webhook",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{"url":"https://hooks.example.test"}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{
{ID: "alert-1"},
{ID: "alert-2"},
{ID: "alert-3"},
},
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
count, err := nq.CancelByAlertIdentifiers([]string{"alert-1", "alert-3"})
if err != nil {
t.Errorf("CancelByAlertIdentifiers returned error: %v", err)
}
if count != 2 {
t.Errorf("CancelByAlertIdentifiers returned count %d, want 2", count)
}
stats, err := nq.GetQueueStats()
if err != nil {
t.Fatalf("GetQueueStats failed: %v", err)
}
if stats["pending"] != 1 {
t.Errorf("Expected 1 pending notification after partial match rewrite, got %d", stats["pending"])
}
if stats["cancelled"] != 0 {
t.Errorf("Expected 0 cancelled notifications after partial match rewrite, got %d (stats: %v)", stats["cancelled"], stats)
}
var notifType string
var alertsJSON string
var configJSON string
var nextRetryAt sql.NullInt64
if err := nq.db.QueryRow(
`SELECT type, alerts, config, next_retry_at FROM notification_queue WHERE id = ?`,
notif.ID,
).Scan(&notifType, &alertsJSON, &configJSON, &nextRetryAt); err != nil {
t.Fatalf("Failed to query rewritten notification row: %v", err)
}
if notifType != notif.Type {
t.Fatalf("type = %q, want %q", notifType, notif.Type)
}
if configJSON != string(notif.Config) {
t.Fatalf("config = %q, want %q", configJSON, string(notif.Config))
}
if !nextRetryAt.Valid || nextRetryAt.Int64 != futureRetry.Unix() {
t.Fatalf("next_retry_at = %+v, want %d", nextRetryAt, futureRetry.Unix())
}
var remaining []*alerts.Alert
if err := json.Unmarshal([]byte(alertsJSON), &remaining); err != nil {
t.Fatalf("failed to unmarshal rewritten alerts: %v", err)
}
if len(remaining) != 1 || remaining[0].ID != "alert-2" {
t.Fatalf("remaining alerts = %#v, want only alert-2", remaining)
}
}
func TestCancelByAlertIdentifiers_SendingRowsCancelledButNotCounted(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
notif := &QueuedNotification{
ID: "notif-sending",
Type: "webhook",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-1"}},
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
if _, err := nq.db.Exec(`UPDATE notification_queue SET status = 'sending' WHERE id = ?`, notif.ID); err != nil {
t.Fatalf("Failed to mark notification as sending: %v", err)
}
// A mid-send row may still complete delivery, so it must not count as an
// undelivered firing entry even though the cancellation is still applied.
count, err := nq.CancelByAlertIdentifiers([]string{"alert-1"})
if err != nil {
t.Fatalf("CancelByAlertIdentifiers returned error: %v", err)
}
if count != 0 {
t.Fatalf("CancelByAlertIdentifiers returned count %d, want 0 for a sending row", count)
}
var status string
if err := nq.db.QueryRow(`SELECT status FROM notification_queue WHERE id = ?`, notif.ID).Scan(&status); err != nil {
t.Fatalf("Failed to query notification status: %v", err)
}
if status != string(QueueStatusCancelled) {
t.Fatalf("status = %q, want %q", status, QueueStatusCancelled)
}
}
func TestCancelByAlertIdentifiers_SetsCompletedAtAndClearsNextRetry(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
notif := &QueuedNotification{
ID: "notif-cancelled-metadata",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-1"}},
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
if _, err := nq.CancelByAlertIdentifiers([]string{"alert-1"}); err != nil {
t.Fatalf("CancelByAlertIdentifiers returned error: %v", err)
}
var status string
var completedAt sql.NullInt64
var nextRetryAt sql.NullInt64
if err := nq.db.QueryRow(
`SELECT status, completed_at, next_retry_at FROM notification_queue WHERE id = ?`,
notif.ID,
).Scan(&status, &completedAt, &nextRetryAt); err != nil {
t.Fatalf("Failed to query cancelled notification row: %v", err)
}
if status != string(QueueStatusCancelled) {
t.Fatalf("status = %q, want %q", status, QueueStatusCancelled)
}
if !completedAt.Valid || completedAt.Int64 <= 0 {
t.Fatalf("completed_at should be set for cancelled notifications, got %v", completedAt)
}
if nextRetryAt.Valid {
t.Fatalf("next_retry_at should be NULL after cancellation, got %v", nextRetryAt)
}
}
func TestCancelByTypes_CancelsOnlyMatchingNotificationTypes(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
for _, notif := range []*QueuedNotification{
{
ID: "notif-email",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-email"}},
},
{
ID: "notif-email-resolved",
Type: "email_resolved",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-email-resolved"}},
},
{
ID: "notif-webhook",
Type: "webhook",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
Alerts: []*alerts.Alert{{ID: "alert-webhook"}},
},
} {
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue %s: %v", notif.ID, err)
}
}
if err := nq.CancelByTypes([]string{"email", "email_resolved"}, "Email notifications disabled"); err != nil {
t.Fatalf("CancelByTypes returned error: %v", err)
}
stats, err := nq.GetQueueStats()
if err != nil {
t.Fatalf("GetQueueStats failed: %v", err)
}
if stats["cancelled"] != 2 {
t.Fatalf("expected 2 cancelled notifications, got %d (stats: %v)", stats["cancelled"], stats)
}
if stats["pending"] != 1 {
t.Fatalf("expected 1 pending notification remaining, got %d (stats: %v)", stats["pending"], stats)
}
}
func TestProcessNotification_CancelledNotification(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Create a cancelled notification
notif := &QueuedNotification{
ID: "test-cancelled",
Type: "email",
Status: QueueStatusCancelled,
}
// processNotification should return early without processing
// No panic or error expected
nq.processNotification(notif)
// Verify the notification wasn't modified (no attempts incremented)
// Since it's cancelled, it should just return
}
func TestProcessNotification_NoProcessor(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
// Enqueue a notification first so IncrementAttemptAndSetStatus works
notif := &QueuedNotification{
ID: "test-no-processor",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
// Don't set a processor - processNotification should handle this
nq.processNotification(notif)
// The notification should be scheduled for retry or moved to DLQ
// since no processor means failure
}
func TestProcessNotification_ProcessorSuccess(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
// Enqueue a notification
notif := &QueuedNotification{
ID: "test-success",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
// Set a processor that succeeds
processorCalled := make(chan struct{}, 1)
nq.SetProcessor(func(n *QueuedNotification) error {
select {
case processorCalled <- struct{}{}:
default:
}
return nil
})
nq.processNotification(notif)
select {
case <-processorCalled:
default:
t.Error("Processor was not called")
}
}
func TestProcessNotification_ProcessorFailure(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
// Enqueue a notification with low max attempts
notif := &QueuedNotification{
ID: "test-failure",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 1, // Only 1 attempt, so failure goes to DLQ
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
// Set a processor that fails
nq.SetProcessor(func(n *QueuedNotification) error {
return fmt.Errorf("simulated failure")
})
nq.processNotification(notif)
// Notification should be in DLQ since max attempts reached
}
func TestScanNotification_DLQWithTimestamps(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
futureRetry := time.Now().Add(1 * time.Hour)
// Enqueue a notification with max 1 attempt
notif := &QueuedNotification{
ID: "test-dlq-timestamps",
Type: "webhook",
Status: QueueStatusPending,
MaxAttempts: 1, // Will go to DLQ on first failure
Config: []byte(`{"url":"http://example.com"}`),
NextRetryAt: &futureRetry,
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
// Set a failing processor to trigger DLQ
nq.SetProcessor(func(n *QueuedNotification) error {
return fmt.Errorf("simulated failure")
})
nq.processNotification(notif)
// Get DLQ notifications - this exercises scanNotification with timestamps
dlq, err := nq.GetDLQ(10)
if err != nil {
t.Fatalf("GetDLQ failed: %v", err)
}
if len(dlq) != 1 {
t.Fatalf("Expected 1 DLQ notification, got %d", len(dlq))
}
// DLQ notification should have CompletedAt set (when it was moved to DLQ)
if dlq[0].CompletedAt == nil {
t.Error("Expected CompletedAt to be set for DLQ notification")
}
// Also verify LastAttempt is set
if dlq[0].LastAttempt == nil {
t.Error("Expected LastAttempt to be set for DLQ notification")
}
}
func TestIncrementAttempt(t *testing.T) {
t.Run("increments attempt counter", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Set next_retry_at far in the future so background processor doesn't pick it up
futureRetry := time.Now().Add(1 * time.Hour)
// Enqueue a notification
notif := &QueuedNotification{
ID: "test-increment",
Type: "email",
Status: QueueStatusPending,
MaxAttempts: 3,
Config: []byte(`{}`),
NextRetryAt: &futureRetry,
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue: %v", err)
}
// Increment the attempt counter multiple times
for i := 0; i < 3; i++ {
if err := nq.IncrementAttempt("test-increment"); err != nil {
t.Fatalf("IncrementAttempt failed on iteration %d: %v", i, err)
}
}
// Verify via DLQ - first move to DLQ to query it
// (This exercises the function; actual count verification would require db access)
if err := nq.UpdateStatus("test-increment", QueueStatusDLQ, "test"); err != nil {
t.Fatalf("UpdateStatus to DLQ failed: %v", err)
}
dlq, err := nq.GetDLQ(10)
if err != nil {
t.Fatalf("GetDLQ failed: %v", err)
}
if len(dlq) != 1 {
t.Fatalf("Expected 1 DLQ notification, got %d", len(dlq))
}
if dlq[0].Attempts != 3 {
t.Errorf("After 3 increments, attempts = %d, want 3", dlq[0].Attempts)
}
})
t.Run("non-existent ID does not error", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Calling IncrementAttempt on non-existent ID should not error
// (the SQL UPDATE just affects 0 rows)
err = nq.IncrementAttempt("non-existent-id")
if err != nil {
t.Errorf("IncrementAttempt with non-existent ID returned error: %v", err)
}
})
}
func TestGetQueueStats(t *testing.T) {
t.Run("empty queue", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
stats, err := nq.GetQueueStats()
if err != nil {
t.Fatalf("GetQueueStats failed: %v", err)
}
// Empty queue should return empty map
if len(stats) != 0 {
t.Errorf("Expected empty stats map, got %v", stats)
}
})
t.Run("with notifications in various statuses", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Enqueue notifications with different statuses
notifications := []*QueuedNotification{
{ID: "pending-1", Type: "email", Status: QueueStatusPending, MaxAttempts: 3, Config: []byte(`{}`)},
{ID: "pending-2", Type: "email", Status: QueueStatusPending, MaxAttempts: 3, Config: []byte(`{}`)},
{ID: "sending-1", Type: "webhook", Status: QueueStatusSending, MaxAttempts: 3, Config: []byte(`{}`)},
}
for _, notif := range notifications {
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Failed to enqueue %s: %v", notif.ID, err)
}
}
// Mark one as sent (completed)
if err := nq.UpdateStatus("pending-1", QueueStatusSent, ""); err != nil {
t.Fatalf("Failed to update status: %v", err)
}
// Mark one as failed
if err := nq.UpdateStatus("sending-1", QueueStatusFailed, "connection refused"); err != nil {
t.Fatalf("Failed to update status with error: %v", err)
}
stats, err := nq.GetQueueStats()
if err != nil {
t.Fatalf("GetQueueStats failed: %v", err)
}
// Verify counts
if stats["pending"] != 1 {
t.Errorf("pending count = %d, want 1", stats["pending"])
}
if stats["sent"] != 1 {
t.Errorf("sent count = %d, want 1", stats["sent"])
}
if stats["failed"] != 1 {
t.Errorf("failed count = %d, want 1", stats["failed"])
}
})
t.Run("UpdateStatus returns error for non-existent notification", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
err = nq.UpdateStatus("non-existent-id", QueueStatusSent, "")
if err == nil {
t.Error("expected error when updating non-existent notification, got nil")
}
})
}
func TestPerformCleanup(t *testing.T) {
t.Run("cleanup removes old completed entries", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Insert a notification directly with old completed_at timestamp
oldTime := time.Now().Add(-10 * 24 * time.Hour).Unix() // 10 days ago
_, err = nq.db.Exec(`
INSERT INTO notification_queue
(id, type, status, config, alerts, attempts, max_attempts, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"old-sent-1", "email", "sent", "{}", "[]", 1, 3, oldTime, oldTime)
if err != nil {
t.Fatalf("Failed to insert old notification: %v", err)
}
// Insert a recent completed notification (should NOT be cleaned)
recentTime := time.Now().Add(-1 * 24 * time.Hour).Unix() // 1 day ago
_, err = nq.db.Exec(`
INSERT INTO notification_queue
(id, type, status, config, alerts, attempts, max_attempts, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"recent-sent-1", "email", "sent", "{}", "[]", 1, 3, recentTime, recentTime)
if err != nil {
t.Fatalf("Failed to insert recent notification: %v", err)
}
// Run cleanup
nq.performCleanup()
// Verify old entry was removed
var count int
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_queue WHERE id = ?`, "old-sent-1").Scan(&count)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if count != 0 {
t.Error("old completed notification should have been cleaned up")
}
// Verify recent entry still exists
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_queue WHERE id = ?`, "recent-sent-1").Scan(&count)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if count != 1 {
t.Error("recent completed notification should NOT have been cleaned up")
}
})
t.Run("cleanup removes old completed entries with audit rows", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
oldTime := time.Now().Add(-10 * 24 * time.Hour).Unix() // 10 days ago
recentAuditTime := time.Now().Add(-1 * time.Hour).Unix()
_, err = nq.db.Exec(`
INSERT INTO notification_queue
(id, type, status, config, alerts, attempts, max_attempts, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"old-sent-with-audit", "email", "sent", "{}", "[]", 1, 3, oldTime, oldTime)
if err != nil {
t.Fatalf("Failed to insert old notification: %v", err)
}
_, err = nq.db.Exec(`
INSERT INTO notification_audit (notification_id, type, status, timestamp)
VALUES (?, ?, ?, ?)`,
"old-sent-with-audit", "email", "sent", recentAuditTime)
if err != nil {
t.Fatalf("Failed to insert audit row: %v", err)
}
nq.performCleanup()
var count int
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_queue WHERE id = ?`, "old-sent-with-audit").Scan(&count)
if err != nil {
t.Fatalf("Failed to query queue row: %v", err)
}
if count != 0 {
t.Error("old completed notification with audit row should have been cleaned up")
}
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_audit WHERE notification_id = ?`, "old-sent-with-audit").Scan(&count)
if err != nil {
t.Fatalf("Failed to query audit rows: %v", err)
}
if count != 0 {
t.Error("audit rows for deleted notification should have been cleaned up")
}
})
t.Run("cleanup removes old DLQ entries", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Insert old DLQ entry (> 30 days)
oldTime := time.Now().Add(-35 * 24 * time.Hour).Unix()
_, err = nq.db.Exec(`
INSERT INTO notification_queue
(id, type, status, config, alerts, attempts, max_attempts, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"old-dlq-1", "webhook", "dlq", "{}", "[]", 5, 3, oldTime, oldTime)
if err != nil {
t.Fatalf("Failed to insert old DLQ entry: %v", err)
}
// Insert recent DLQ entry (< 30 days)
recentTime := time.Now().Add(-20 * 24 * time.Hour).Unix()
_, err = nq.db.Exec(`
INSERT INTO notification_queue
(id, type, status, config, alerts, attempts, max_attempts, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"recent-dlq-1", "webhook", "dlq", "{}", "[]", 5, 3, recentTime, recentTime)
if err != nil {
t.Fatalf("Failed to insert recent DLQ entry: %v", err)
}
// Run cleanup
nq.performCleanup()
// Verify old DLQ was removed
var count int
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_queue WHERE id = ?`, "old-dlq-1").Scan(&count)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if count != 0 {
t.Error("old DLQ entry should have been cleaned up")
}
// Verify recent DLQ still exists
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_queue WHERE id = ?`, "recent-dlq-1").Scan(&count)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if count != 1 {
t.Error("recent DLQ entry should NOT have been cleaned up")
}
})
t.Run("cleanup removes old audit logs", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Insert parent notifications first (foreign key constraint)
oldTime := time.Now().Add(-35 * 24 * time.Hour).Unix()
recentTime := time.Now().Add(-5 * 24 * time.Hour).Unix()
_, err = nq.db.Exec(`
INSERT INTO notification_queue
(id, type, status, config, alerts, attempts, max_attempts, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
"test-1", "email", "sent", "{}", "[]", 1, 3, oldTime)
if err != nil {
t.Fatalf("Failed to insert parent notification 1: %v", err)
}
_, err = nq.db.Exec(`
INSERT INTO notification_queue
(id, type, status, config, alerts, attempts, max_attempts, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
"test-2", "email", "sent", "{}", "[]", 1, 3, recentTime)
if err != nil {
t.Fatalf("Failed to insert parent notification 2: %v", err)
}
// Insert old audit log (> 30 days)
_, err = nq.db.Exec(`
INSERT INTO notification_audit (notification_id, type, status, timestamp)
VALUES (?, ?, ?, ?)`,
"test-1", "email", "created", oldTime)
if err != nil {
t.Fatalf("Failed to insert old audit: %v", err)
}
// Insert recent audit log (< 30 days)
_, err = nq.db.Exec(`
INSERT INTO notification_audit (notification_id, type, status, timestamp)
VALUES (?, ?, ?, ?)`,
"test-2", "email", "sent", recentTime)
if err != nil {
t.Fatalf("Failed to insert recent audit: %v", err)
}
// Run cleanup
nq.performCleanup()
// Verify old audit was removed
var count int
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_audit WHERE timestamp = ?`, oldTime).Scan(&count)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if count != 0 {
t.Error("old audit log should have been cleaned up")
}
// Verify recent audit still exists
err = nq.db.QueryRow(`SELECT COUNT(*) FROM notification_audit WHERE timestamp = ?`, recentTime).Scan(&count)
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if count != 1 {
t.Error("recent audit log should NOT have been cleaned up")
}
})
t.Run("cleanup with empty database", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
// Should not panic or error
nq.performCleanup()
})
}
func TestNewNotificationQueue_InvalidPath(t *testing.T) {
// Test with a path that cannot be created (file exists where directory expected)
tempDir := t.TempDir()
// Create a file at the path where we'd want to create a directory
blockingFile := tempDir + "/blocked"
if err := os.WriteFile(blockingFile, []byte("blocking"), 0644); err != nil {
t.Fatalf("failed to create blocking file: %v", err)
}
// Try to create queue at a path nested under the blocking file
invalidPath := blockingFile + "/subdir"
_, err := NewNotificationQueue(invalidPath)
if err == nil {
t.Error("expected error when creating notification queue with invalid path, got nil")
}
}
func TestNotificationQueueStopIsIdempotent(t *testing.T) {
nq, err := NewNotificationQueue(t.TempDir())
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
const callers = 16
var wg sync.WaitGroup
wg.Add(callers)
panicCh := make(chan interface{}, callers)
errCh := make(chan error, callers)
for i := 0; i < callers; i++ {
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
panicCh <- r
}
}()
errCh <- nq.Stop()
}()
}
wg.Wait()
close(panicCh)
close(errCh)
if len(panicCh) > 0 {
t.Fatalf("Stop panicked under concurrent calls: %v", <-panicCh)
}
for stopErr := range errCh {
if stopErr != nil {
t.Fatalf("Stop returned error under concurrent calls: %v", stopErr)
}
}
}
func TestProcessBatchWithoutProcessorLeavesNotificationPending(t *testing.T) {
nq, err := NewNotificationQueue(t.TempDir())
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
notif := &QueuedNotification{
ID: "pending-without-processor",
Type: "email",
Status: QueueStatusPending,
Config: []byte(`{}`),
Alerts: []*alerts.Alert{{ID: "alert-1"}},
MaxAttempts: 3,
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Enqueue() error = %v", err)
}
nq.processBatch()
var status string
var attempts int
if err := nq.db.QueryRow(`SELECT status, attempts FROM notification_queue WHERE id = ?`, notif.ID).Scan(&status, &attempts); err != nil {
t.Fatalf("query notification: %v", err)
}
if status != string(QueueStatusPending) {
t.Fatalf("status = %q, want %q", status, QueueStatusPending)
}
if attempts != 0 {
t.Fatalf("attempts = %d, want 0", attempts)
}
}
func TestSetProcessorTriggersPendingDelivery(t *testing.T) {
nq, err := NewNotificationQueue(t.TempDir())
if err != nil {
t.Fatalf("Failed to create notification queue: %v", err)
}
defer func() { _ = nq.Stop() }()
notif := &QueuedNotification{
ID: "process-after-set-processor",
Type: "email",
Status: QueueStatusPending,
Config: []byte(`{}`),
Alerts: []*alerts.Alert{{ID: "alert-1"}},
MaxAttempts: 3,
}
if err := nq.Enqueue(notif); err != nil {
t.Fatalf("Enqueue() error = %v", err)
}
processed := make(chan string, 1)
nq.SetProcessor(func(notif *QueuedNotification) error {
processed <- notif.ID
return nil
})
select {
case got := <-processed:
if got != notif.ID {
t.Fatalf("processed notification id = %q, want %q", got, notif.ID)
}
case <-time.After(2 * time.Second):
t.Fatal("SetProcessor did not trigger pending notification delivery")
}
deadline := time.Now().Add(2 * time.Second)
for {
var status string
var attempts int
if err := nq.db.QueryRow(`SELECT status, attempts FROM notification_queue WHERE id = ?`, notif.ID).Scan(&status, &attempts); err != nil {
t.Fatalf("query notification: %v", err)
}
if status == string(QueueStatusSent) {
if attempts != 1 {
t.Fatalf("attempts = %d, want 1 after successful delivery", attempts)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("notification status = %q after processor wakeup, want %q", status, QueueStatusSent)
}
time.Sleep(10 * time.Millisecond)
}
}