Files
pulse/internal/api/patrol_finding_notifications_test.go
T
rcourtman 23695681ac Route new Patrol findings to alert notification channels
Patrol findings previously reached operators only through the Relay
mobile push path. Anyone relying on the email, webhook, or Apprise
destinations they already configured for alerts got no proactive signal
when Patrol detected a problem and had to open /patrol to learn about
it. This is the delivery half of #1369.

Each newly stored warning or critical finding now also flows through a
FindingNotifyCallback wired in the router, which projects the finding
into the alert shape the notification manager delivers. The callback
fires only on the genuinely-new path in recordFindingWithInvestigation,
so a finding notifies at most once per lifetime regardless of how many
later runs re-detect it, and SendAlert's own per-ID cooldown backstops
that. Demo mode never notifies.

Gating lives in AIConfig via patrol_finding_notifications_enabled
(default on, matching the long-standing default for mobile push) and
patrol_finding_notify_min_severity (warning or critical, default
warning). The enabled flag persists without omitempty so an explicit
opt-out survives reload while pre-existing configs inherit the default.
The settings surface for these fields follows in a separate commit once
the AI settings handler is free.
2026-07-18 11:59:05 +01:00

97 lines
3.2 KiB
Go

package api
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
)
func TestPatrolFindingNotificationAlert(t *testing.T) {
t.Run("nil finding yields nil alert", func(t *testing.T) {
if patrolFindingNotificationAlert(nil) != nil {
t.Fatal("nil finding must map to nil alert")
}
})
t.Run("critical finding maps to critical alert", func(t *testing.T) {
detected := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC)
lastSeen := detected.Add(30 * time.Minute)
finding := &ai.Finding{
ID: "f-123",
Severity: ai.FindingSeverityCritical,
Category: ai.FindingCategoryReliability,
ResourceID: "docker:abc123",
ResourceName: "app-container",
Node: "edge-host",
Title: "Container health check is failing",
Description: "The running container is reported unhealthy.",
Recommendation: "Inspect the container health-check output.",
DetectedAt: detected,
LastSeenAt: lastSeen,
}
alert := patrolFindingNotificationAlert(finding)
if alert == nil {
t.Fatal("expected an alert")
}
if alert.ID != "patrol-finding-f-123" {
t.Fatalf("ID = %q", alert.ID)
}
if alert.Type != "patrol_finding" {
t.Fatalf("Type = %q", alert.Type)
}
if alert.Level != alerts.AlertLevelCritical {
t.Fatalf("Level = %q", alert.Level)
}
if alert.ResourceID != finding.ResourceID || alert.ResourceName != finding.ResourceName || alert.Node != finding.Node {
t.Fatalf("resource identity mismatch: %+v", alert)
}
if alert.Message != finding.Title {
t.Fatalf("Message = %q", alert.Message)
}
if !alert.StartTime.Equal(detected) || !alert.LastSeen.Equal(lastSeen) {
t.Fatalf("timestamps mismatch: start=%v lastSeen=%v", alert.StartTime, alert.LastSeen)
}
if alert.Metadata["findingId"] != "f-123" {
t.Fatalf("metadata findingId = %v", alert.Metadata["findingId"])
}
if alert.Metadata["category"] != string(ai.FindingCategoryReliability) {
t.Fatalf("metadata category = %v", alert.Metadata["category"])
}
if alert.Metadata["description"] != finding.Description {
t.Fatalf("metadata description = %v", alert.Metadata["description"])
}
if alert.Metadata["recommendation"] != finding.Recommendation {
t.Fatalf("metadata recommendation = %v", alert.Metadata["recommendation"])
}
})
t.Run("warning finding maps to warning alert with zero-time fallbacks", func(t *testing.T) {
finding := &ai.Finding{
ID: "f-456",
Severity: ai.FindingSeverityWarning,
Category: ai.FindingCategoryGeneral,
Title: "Array running without parity protection",
}
alert := patrolFindingNotificationAlert(finding)
if alert == nil {
t.Fatal("expected an alert")
}
if alert.Level != alerts.AlertLevelWarning {
t.Fatalf("Level = %q", alert.Level)
}
if alert.StartTime.IsZero() || alert.LastSeen.IsZero() {
t.Fatal("zero finding timestamps must fall back to a real time")
}
if _, present := alert.Metadata["description"]; present {
t.Fatal("empty description must not be present in metadata")
}
if _, present := alert.Metadata["recommendation"]; present {
t.Fatal("empty recommendation must not be present in metadata")
}
})
}