diff --git a/internal/notifications/delivery_receipts_test.go b/internal/notifications/delivery_receipts_test.go index f9545372f..46ea5f8c2 100644 --- a/internal/notifications/delivery_receipts_test.go +++ b/internal/notifications/delivery_receipts_test.go @@ -71,3 +71,61 @@ func TestDeliveryReceiptPersistsAndIsClearedAfterRecovery(t *testing.T) { t.Fatalf("receipt remained after recovery delivery: %+v", got) } } + +// A delayed recovery for an earlier occurrence must consume only its own +// destination receipt, even when a recurrence has already fired before restart. +func TestDelayedRecoveryPreservesRecurringAndOtherDestinationReceipts(t *testing.T) { + dir := t.TempDir() + start := time.Date(2026, 9, 8, 1, 0, 0, 123, time.UTC) + old := &alerts.Alert{ID: "cpu-critical-1", StartTime: start} + current := old.Clone() + current.StartTime = start.Add(time.Nanosecond) + destinations := []WebhookConfig{ + {ID: "ops", URL: "https://ops.example.test", Enabled: true}, + {ID: "backup", URL: "https://backup.example.test", Enabled: true}, + } + job := func(a *alerts.Alert, destination int, event notificationEvent) notificationDeliveryJob { + return notificationDeliveryJob{Type: "webhook", Event: event, + Alerts: []*alerts.Alert{a}, WebhookConfig: &destinations[destination]} + } + open := func() *NotificationManager { + m := NewNotificationManagerWithDataDir("", dir) + t.Cleanup(m.Stop) + return m + } + m := open() + for _, a := range []*alerts.Alert{old, current} { + for destination := range destinations { + m.recordSuccessfulDelivery(job(a, destination, eventAlert), start.Add(time.Minute)) + } + } + m.Stop() + m = open() + assertReceipt := func(a *alerts.Alert, destination int, want bool) { + t.Helper() + got := m.filterResolvedJobsByDeliveryReceipt([]notificationDeliveryJob{job(a, destination, eventResolved)}) + if (len(got) == 1) != want { + t.Fatalf("occurrence %s destination %d: eligible jobs=%d, want receipt=%t", a.StartTime, destination, len(got), want) + } + } + for _, a := range []*alerts.Alert{old, current} { + for destination := range destinations { + assertReceipt(a, destination, true) + } + } + // Duplicate completion must be harmless as well as the first completion. + for range 2 { + m.recordSuccessfulDelivery(job(old, 0, eventResolved), start.Add(2*time.Minute)) + } + m.Stop() + m = open() + assertReceipt(old, 0, false) + assertReceipt(old, 1, true) + assertReceipt(current, 0, true) + assertReceipt(current, 1, true) + // The newer incident can still complete independently at either endpoint. + m.recordSuccessfulDelivery(job(current, 0, eventResolved), start.Add(3*time.Minute)) + assertReceipt(current, 0, false) + assertReceipt(current, 1, true) + assertReceipt(old, 1, true) +}