mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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.
This commit is contained in:
@@ -436,6 +436,11 @@ type UnifiedFindingCallback func(f *Finding) bool
|
||||
// PushNotifyCallback is called to send a push notification through the relay.
|
||||
type PushNotifyCallback func(notification relay.PushNotificationPayload)
|
||||
|
||||
// FindingNotifyCallback delivers a newly stored finding to the operator's
|
||||
// alert notification channels (email, webhooks). The API layer owns config
|
||||
// gating and the projection into the notification payload shape.
|
||||
type FindingNotifyCallback func(f *Finding)
|
||||
|
||||
// InvestigationOrchestrator is the interface for autonomous investigation of findings.
|
||||
// Re-exported from pkg/aicontracts for backwards compatibility.
|
||||
type InvestigationOrchestrator = aicontracts.InvestigationOrchestrator
|
||||
@@ -533,6 +538,10 @@ type PatrolService struct {
|
||||
// Push notification callback - sends push via relay to mobile devices
|
||||
pushNotifyCallback PushNotifyCallback
|
||||
|
||||
// Finding notification callback - routes new warning+ findings to the
|
||||
// operator's alert notification channels (email, webhooks)
|
||||
findingNotifyCallback FindingNotifyCallback
|
||||
|
||||
// Cached thresholds (recalculated when thresholdProvider changes)
|
||||
thresholds PatrolThresholds
|
||||
proactiveMode bool // When true, warn before thresholds; when false, use exact thresholds
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package ai
|
||||
|
||||
import "testing"
|
||||
|
||||
// The finding notification callback must fire exactly once per finding
|
||||
// lifetime, on the genuinely-new path, and only for warning+ severities.
|
||||
func TestRecordFinding_FindingNotifyCallback(t *testing.T) {
|
||||
newFinding := func(id string, severity FindingSeverity) *Finding {
|
||||
return &Finding{
|
||||
ID: id,
|
||||
Severity: severity,
|
||||
Category: FindingCategoryReliability,
|
||||
ResourceID: "vm-101",
|
||||
ResourceName: "web",
|
||||
ResourceType: "vm",
|
||||
Title: "Test finding " + id,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("fires once for a new warning finding", func(t *testing.T) {
|
||||
ps := NewPatrolService(nil, nil)
|
||||
var notified []*Finding
|
||||
ps.SetFindingNotifyCallback(func(f *Finding) {
|
||||
notified = append(notified, f)
|
||||
})
|
||||
|
||||
if !ps.recordFinding(newFinding("warn-1", FindingSeverityWarning)) {
|
||||
t.Fatal("expected finding to record as new")
|
||||
}
|
||||
if len(notified) != 1 {
|
||||
t.Fatalf("callback fired %d times, want 1", len(notified))
|
||||
}
|
||||
if notified[0].ID != "warn-1" {
|
||||
t.Fatalf("notified finding ID = %q", notified[0].ID)
|
||||
}
|
||||
|
||||
// A re-detection of the same finding must not notify again.
|
||||
ps.recordFinding(newFinding("warn-1", FindingSeverityWarning))
|
||||
if len(notified) != 1 {
|
||||
t.Fatalf("re-detection fired the callback; %d calls, want 1", len(notified))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fires for critical findings", func(t *testing.T) {
|
||||
ps := NewPatrolService(nil, nil)
|
||||
calls := 0
|
||||
ps.SetFindingNotifyCallback(func(*Finding) { calls++ })
|
||||
ps.recordFinding(newFinding("crit-1", FindingSeverityCritical))
|
||||
if calls != 1 {
|
||||
t.Fatalf("callback fired %d times, want 1", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not fire for info findings", func(t *testing.T) {
|
||||
ps := NewPatrolService(nil, nil)
|
||||
calls := 0
|
||||
ps.SetFindingNotifyCallback(func(*Finding) { calls++ })
|
||||
ps.recordFinding(newFinding("info-1", FindingSeverityInfo))
|
||||
if calls != 0 {
|
||||
t.Fatalf("callback fired %d times for an info finding, want 0", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil callback is safe", func(t *testing.T) {
|
||||
ps := NewPatrolService(nil, nil)
|
||||
if !ps.recordFinding(newFinding("warn-2", FindingSeverityWarning)) {
|
||||
t.Fatal("expected finding to record as new")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -78,6 +78,7 @@ func (p *PatrolService) recordFindingWithInvestigation(f *Finding, investigate b
|
||||
if stored.Severity == FindingSeverityCritical || stored.Severity == FindingSeverityWarning {
|
||||
p.mu.RLock()
|
||||
pushCb := p.pushNotifyCallback
|
||||
notifyCb := p.findingNotifyCallback
|
||||
p.mu.RUnlock()
|
||||
if pushCb != nil {
|
||||
pushCb(relay.NewPatrolFindingNotification(
|
||||
@@ -87,6 +88,13 @@ func (p *PatrolService) recordFindingWithInvestigation(f *Finding, investigate b
|
||||
stored.Title,
|
||||
))
|
||||
}
|
||||
// Route the finding to the operator's alert notification
|
||||
// channels. Fires only here, on the genuinely-new path, so a
|
||||
// finding notifies at most once per lifetime regardless of how
|
||||
// many later runs re-detect it.
|
||||
if notifyCb != nil {
|
||||
notifyCb(stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,14 @@ func (p *PatrolService) SetPushNotifyCallback(cb PushNotifyCallback) {
|
||||
p.pushNotifyCallback = cb
|
||||
}
|
||||
|
||||
// SetFindingNotifyCallback sets the callback that routes newly detected
|
||||
// warning+ findings to the operator's alert notification channels.
|
||||
func (p *PatrolService) SetFindingNotifyCallback(cb FindingNotifyCallback) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.findingNotifyCallback = cb
|
||||
}
|
||||
|
||||
// SetUnifiedFindingCallback sets the callback for pushing findings to the unified store
|
||||
// When set, it also syncs existing active findings to the unified store
|
||||
func (p *PatrolService) SetUnifiedFindingCallback(cb UnifiedFindingCallback) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
)
|
||||
|
||||
// patrolFindingAlertIDPrefix namespaces synthetic notification alerts so
|
||||
// their IDs can never collide with threshold alert IDs in the notification
|
||||
// manager's cooldown map.
|
||||
const patrolFindingAlertIDPrefix = "patrol-finding-"
|
||||
|
||||
// patrolFindingNotificationAlert projects a Patrol finding into the alert
|
||||
// shape the notification manager delivers, so findings reach the operator's
|
||||
// existing email, webhook, and Apprise destinations. The synthetic alert is
|
||||
// never stored in the active-alert store; it exists only for delivery.
|
||||
func patrolFindingNotificationAlert(f *ai.Finding) *alerts.Alert {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
level := alerts.AlertLevelWarning
|
||||
if f.Severity == ai.FindingSeverityCritical {
|
||||
level = alerts.AlertLevelCritical
|
||||
}
|
||||
|
||||
startTime := f.DetectedAt
|
||||
if startTime.IsZero() {
|
||||
startTime = time.Now()
|
||||
}
|
||||
lastSeen := f.LastSeenAt
|
||||
if lastSeen.IsZero() {
|
||||
lastSeen = startTime
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"findingId": f.ID,
|
||||
"category": string(f.Category),
|
||||
}
|
||||
if f.Description != "" {
|
||||
metadata["description"] = f.Description
|
||||
}
|
||||
if f.Recommendation != "" {
|
||||
metadata["recommendation"] = f.Recommendation
|
||||
}
|
||||
|
||||
return &alerts.Alert{
|
||||
ID: patrolFindingAlertIDPrefix + f.ID,
|
||||
Type: "patrol_finding",
|
||||
Level: level,
|
||||
ResourceID: f.ResourceID,
|
||||
ResourceName: f.ResourceName,
|
||||
Node: f.Node,
|
||||
Message: f.Title,
|
||||
StartTime: startTime,
|
||||
LastSeen: lastSeen,
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2298,6 +2298,26 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool {
|
||||
}
|
||||
})
|
||||
|
||||
// Wire finding notifications: new warning+ patrol findings go to
|
||||
// the operator's alert notification channels (email, webhooks,
|
||||
// Apprise), so Patrol can reach operators who do not use the
|
||||
// mobile push path. Config gating is read live per finding.
|
||||
patrol.SetFindingNotifyCallback(func(f *ai.Finding) {
|
||||
if f == nil || ai.IsDemoMode() {
|
||||
return
|
||||
}
|
||||
cfg := aiService.GetAIConfig()
|
||||
if cfg == nil || !cfg.PatrolFindingTriggersNotification(string(f.Severity)) {
|
||||
return
|
||||
}
|
||||
if r.monitor == nil {
|
||||
return
|
||||
}
|
||||
if nm := r.monitor.GetNotificationManager(); nm != nil {
|
||||
nm.SendAlert(patrolFindingNotificationAlert(f))
|
||||
}
|
||||
})
|
||||
|
||||
log.Info().Msg("AI Intelligence: Patrol findings wired to unified store")
|
||||
|
||||
// Sync existing findings from persistence to the unified store
|
||||
|
||||
@@ -75,6 +75,18 @@ type AIConfig struct {
|
||||
UseProactiveThresholds bool `json:"use_proactive_thresholds,omitempty"` // When true, patrol warns 5-15% BEFORE alert thresholds (default: false, use exact thresholds)
|
||||
AutoFixModel string `json:"auto_fix_model,omitempty"` // Model for automatic remediation (defaults to PatrolModel, may want more capable model)
|
||||
|
||||
// Patrol finding notifications route each newly detected warning+ finding
|
||||
// through the operator's alert notification channels (email, webhooks,
|
||||
// Apprise). Findings are deduplicated upstream, so at most one
|
||||
// notification fires per finding lifetime. The enabled flag is persisted
|
||||
// without omitempty so an explicit opt-out survives reload; configs saved
|
||||
// before the field existed inherit the default (enabled).
|
||||
PatrolFindingNotificationsEnabled bool `json:"patrol_finding_notifications_enabled"`
|
||||
// PatrolFindingNotifyMinSeverity is the minimum finding severity that
|
||||
// warrants a notification ("warning" accepts warning+critical;
|
||||
// "critical" accepts only critical; empty = "warning").
|
||||
PatrolFindingNotifyMinSeverity string `json:"patrol_finding_notify_min_severity,omitempty"`
|
||||
|
||||
// Alert-triggered AI analysis - analyze specific resources when alerts fire
|
||||
AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // Enable AI analysis when alerts fire (token-efficient)
|
||||
|
||||
@@ -252,6 +264,10 @@ func NewDefaultAIConfig() *AIConfig {
|
||||
// Default to critical-only so alert-triggered investigations stay
|
||||
// token-conservative out of the box. Operators can opt warnings in.
|
||||
PatrolAlertTriggerMinSeverity: AlertTriggerSeverityCritical,
|
||||
// Finding notifications default on at warning+, matching the
|
||||
// long-standing default for Patrol mobile push. Findings fire once
|
||||
// per lifetime, so the volume is inherently low.
|
||||
PatrolFindingNotificationsEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +310,39 @@ func (c *AIConfig) AlertTriggersInvestigation(alertType, level string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GetPatrolFindingNotifyMinSeverity returns the configured minimum finding
|
||||
// severity that warrants a notification, normalizing the empty default to
|
||||
// warning. Warning is the right floor here because most actionable findings
|
||||
// (data-loss risks, failing health checks) are reported at warning severity.
|
||||
func (c *AIConfig) GetPatrolFindingNotifyMinSeverity() string {
|
||||
if c == nil {
|
||||
return AlertTriggerSeverityWarning
|
||||
}
|
||||
min := strings.ToLower(strings.TrimSpace(c.PatrolFindingNotifyMinSeverity))
|
||||
switch min {
|
||||
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
|
||||
return min
|
||||
default:
|
||||
return AlertTriggerSeverityWarning
|
||||
}
|
||||
}
|
||||
|
||||
// PatrolFindingTriggersNotification reports whether a newly detected finding
|
||||
// of the given severity should be routed to the operator's alert notification
|
||||
// channels. Only warning and critical findings ever qualify; info and watch
|
||||
// findings are UI-only detail.
|
||||
func (c *AIConfig) PatrolFindingTriggersNotification(severity string) bool {
|
||||
if c == nil || !c.Enabled || !c.PatrolFindingNotificationsEnabled {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(severity)) {
|
||||
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return alertLevelMeetsMinimum(severity, c.GetPatrolFindingNotifyMinSeverity())
|
||||
}
|
||||
|
||||
// alertLevelMeetsMinimum ranks warning < critical. An empty minimum defaults to
|
||||
// critical-only. An unknown alert level is treated as critical so it is never
|
||||
// silently dropped.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPatrolFindingTriggersNotification(t *testing.T) {
|
||||
enabled := func() *AIConfig {
|
||||
cfg := NewDefaultAIConfig()
|
||||
cfg.Enabled = true
|
||||
return cfg
|
||||
}
|
||||
|
||||
t.Run("nil config never notifies", func(t *testing.T) {
|
||||
var cfg *AIConfig
|
||||
if cfg.PatrolFindingTriggersNotification("critical") {
|
||||
t.Fatal("nil config must not notify")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled AI master switch blocks", func(t *testing.T) {
|
||||
cfg := NewDefaultAIConfig()
|
||||
if cfg.PatrolFindingTriggersNotification("critical") {
|
||||
t.Fatal("AI disabled must not notify")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit opt-out blocks", func(t *testing.T) {
|
||||
cfg := enabled()
|
||||
cfg.PatrolFindingNotificationsEnabled = false
|
||||
if cfg.PatrolFindingTriggersNotification("critical") {
|
||||
t.Fatal("opted-out config must not notify")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default floor accepts warning and critical", func(t *testing.T) {
|
||||
cfg := enabled()
|
||||
if !cfg.PatrolFindingTriggersNotification("warning") {
|
||||
t.Fatal("warning finding must notify at the default floor")
|
||||
}
|
||||
if !cfg.PatrolFindingTriggersNotification("critical") {
|
||||
t.Fatal("critical finding must notify at the default floor")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("info and watch never notify", func(t *testing.T) {
|
||||
cfg := enabled()
|
||||
for _, severity := range []string{"info", "watch", "", "bogus"} {
|
||||
if cfg.PatrolFindingTriggersNotification(severity) {
|
||||
t.Fatalf("severity %q must not notify", severity)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("critical floor drops warning", func(t *testing.T) {
|
||||
cfg := enabled()
|
||||
cfg.PatrolFindingNotifyMinSeverity = AlertTriggerSeverityCritical
|
||||
if cfg.PatrolFindingTriggersNotification("warning") {
|
||||
t.Fatal("warning finding must not notify at the critical floor")
|
||||
}
|
||||
if !cfg.PatrolFindingTriggersNotification("critical") {
|
||||
t.Fatal("critical finding must notify at the critical floor")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetPatrolFindingNotifyMinSeverity(t *testing.T) {
|
||||
var nilCfg *AIConfig
|
||||
if got := nilCfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityWarning {
|
||||
t.Fatalf("nil config floor = %q, want warning", got)
|
||||
}
|
||||
cfg := &AIConfig{}
|
||||
if got := cfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityWarning {
|
||||
t.Fatalf("empty floor = %q, want warning", got)
|
||||
}
|
||||
cfg.PatrolFindingNotifyMinSeverity = " Critical "
|
||||
if got := cfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityCritical {
|
||||
t.Fatalf("normalized floor = %q, want critical", got)
|
||||
}
|
||||
cfg.PatrolFindingNotifyMinSeverity = "bogus"
|
||||
if got := cfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityWarning {
|
||||
t.Fatalf("invalid floor = %q, want warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Configs saved before the field existed must inherit the enabled default,
|
||||
// while a persisted explicit opt-out must survive a save/load round trip.
|
||||
// LoadAIConfig unmarshals over NewDefaultAIConfig, which this mirrors.
|
||||
func TestPatrolFindingNotificationsPersistence(t *testing.T) {
|
||||
t.Run("missing field inherits default on", func(t *testing.T) {
|
||||
settings := NewDefaultAIConfig()
|
||||
if err := json.Unmarshal([]byte(`{"enabled":true}`), settings); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !settings.PatrolFindingNotificationsEnabled {
|
||||
t.Fatal("legacy config without the field must default to enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit false survives round trip", func(t *testing.T) {
|
||||
saved := NewDefaultAIConfig()
|
||||
saved.PatrolFindingNotificationsEnabled = false
|
||||
data, err := json.Marshal(saved)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
loaded := NewDefaultAIConfig()
|
||||
if err := json.Unmarshal(data, loaded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if loaded.PatrolFindingNotificationsEnabled {
|
||||
t.Fatal("explicit opt-out must survive save/load")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user