From cfe341348d183ea9c6a7867130ee24aa8935cfb2 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:42:01 +0100 Subject: [PATCH] test(notifications): preserve recurring receipts after delayed recovery Guard delayed and duplicate recovery completion across manager restarts. Verify that consuming one occurrence and destination leaves the newer occurrence and other destination eligible, including nanosecond-distinct starts. Change-source: pulse-maintainer --- .../notifications/delivery_receipts_test.go | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) 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) +}