From 324ea0ca9290d2f4ffb359a7e4cbd2413aa49007 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 21 Dec 2025 20:22:47 +0000 Subject: [PATCH] Improve AI test coverage - baseline/store_test.go: Add tests for CheckResourceAnomalies, formatAnomalyDescription, formatRatio, GetAllAnomalies, floatToStr (67.9% -> 92.2%) - memory/incidents_test.go: Add tests for RecordAlertUnacknowledged, RecordRunbook, ListIncidentsByResource, FormatForAlert, FormatForResource, FormatForPatrol (66.8% -> 81.1%) - intelligence_test.go: Add tests for SetStateProvider, FormatGlobalContext, RecordLearning, severityOrder, CheckBaselinesForResource with baselines (61.4% -> 63.1%) --- internal/ai/baseline/store_test.go | 272 +++++++++++++ internal/ai/intelligence_test.go | 289 ++++++++++++++ internal/ai/memory/incidents_test.go | 556 ++++++++++++++++++++++++++- 3 files changed, 1115 insertions(+), 2 deletions(-) diff --git a/internal/ai/baseline/store_test.go b/internal/ai/baseline/store_test.go index f28740f..51f20f3 100644 --- a/internal/ai/baseline/store_test.go +++ b/internal/ai/baseline/store_test.go @@ -304,3 +304,275 @@ func TestFormatDays(t *testing.T) { } } +func TestCheckResourceAnomalies_Disk(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Create stable data with mean ~60% disk usage + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{Value: 60 + float64(i%5) - 2} // 58-62 + } + store.Learn("test-vm", "vm", "disk", points) + + // Test: disk above 85% should be reported + metrics := map[string]float64{"disk": 90} + anomalies := store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) == 0 { + t.Error("Expected disk anomaly to be reported for 90% usage") + } + + // Test: disk increase >15 points from baseline should be reported + metrics = map[string]float64{"disk": 80} + anomalies = store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) == 0 { + t.Error("Expected disk anomaly to be reported for 20 point increase from baseline") + } + + // Test: disk at baseline should not be reported (no significant deviation) + metrics = map[string]float64{"disk": 60} + anomalies = store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) != 0 { + t.Errorf("Expected no anomaly for disk at baseline, got %d", len(anomalies)) + } +} + +func TestCheckResourceAnomalies_CPU(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Create stable data with mean ~20% CPU usage + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{Value: 20 + float64(i%3) - 1} // 19-21 + } + store.Learn("test-vm", "vm", "cpu", points) + + // Test: CPU at 80% (above 70% and >2x baseline) should be reported + metrics := map[string]float64{"cpu": 80} + anomalies := store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) == 0 { + t.Error("Expected CPU anomaly to be reported for 80% (>70% and 4x baseline)") + } + + // Test: CPU at 50% should NOT be reported (below 70% threshold) + metrics = map[string]float64{"cpu": 50} + anomalies = store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) != 0 { + t.Errorf("Expected no anomaly for CPU at 50%%, got %d", len(anomalies)) + } + + // Test: CPU at 20% (baseline) should not be reported + metrics = map[string]float64{"cpu": 20} + anomalies = store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) != 0 { + t.Errorf("Expected no anomaly for CPU at baseline, got %d", len(anomalies)) + } +} + +func TestCheckResourceAnomalies_Memory(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Create stable data with mean ~40% memory usage + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{Value: 40 + float64(i%3) - 1} // 39-41 + } + store.Learn("test-vm", "vm", "memory", points) + + // Test: Memory at 85% should be reported (above 80% threshold) + metrics := map[string]float64{"memory": 85} + anomalies := store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) == 0 { + t.Error("Expected memory anomaly to be reported for 85% (above 80%)") + } + + // Test: Memory at 70% with 1.75x baseline should be reported (>1.5x and >60%) + metrics = map[string]float64{"memory": 70} + anomalies = store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) == 0 { + t.Error("Expected memory anomaly to be reported for 70% (1.75x baseline, >60%)") + } + + // Test: Memory at 50% should NOT be reported (not >1.5x enough or >80%) + metrics = map[string]float64{"memory": 50} + anomalies = store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) != 0 { + t.Errorf("Expected no anomaly for memory at 50%%, got %d", len(anomalies)) + } +} + +func TestCheckResourceAnomalies_OtherMetrics(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Create stable network data with mean ~100 + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{Value: 100 + float64(i%5) - 2} // 98-102 + } + store.Learn("test-vm", "vm", "network_in", points) + + // Test: network_in at 2x baseline should be reported + metrics := map[string]float64{"network_in": 250} + anomalies := store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) == 0 { + t.Error("Expected network anomaly to be reported for 2.5x baseline") + } + + // Test: network_in at 0.3x baseline should be reported (below 0.5x) + metrics = map[string]float64{"network_in": 30} + anomalies = store.CheckResourceAnomalies("test-vm", metrics) + if len(anomalies) == 0 { + t.Error("Expected network anomaly to be reported for 0.3x baseline") + } +} + +func TestCheckResourceAnomalies_NoBaseline(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // No baselines learned - should return empty + metrics := map[string]float64{"cpu": 90, "memory": 85} + anomalies := store.CheckResourceAnomalies("unknown-vm", metrics) + if len(anomalies) != 0 { + t.Errorf("Expected no anomalies for unknown resource, got %d", len(anomalies)) + } +} + +func TestFormatRatio(t *testing.T) { + testCases := []struct { + ratio float64 + expected string + }{ + {0.005, "near zero"}, + {0.5, "significantly below"}, + {0.8, "significantly below"}, + {1.2, "slightly above"}, + {1.4, "slightly above"}, + {1.7, "1.5x"}, + {2.5, "2x"}, + {4.0, "3x"}, + {6.0, "~6x"}, + } + + for _, tc := range testCases { + result := formatRatio(tc.ratio) + if result != tc.expected { + t.Errorf("formatRatio(%f): expected %q, got %q", tc.ratio, tc.expected, result) + } + } +} + +func TestFormatAnomalyDescription(t *testing.T) { + testCases := []struct { + metric string + ratio float64 + direction string + severity AnomalySeverity + contains string + }{ + {"cpu", 2.0, "above", AnomalyCritical, "Critical anomaly: CPU usage"}, + {"memory", 1.5, "above", AnomalyHigh, "High anomaly: Memory usage"}, + {"disk", 1.8, "above", AnomalyMedium, "Moderate anomaly: Disk usage"}, + {"network_in", 2.0, "below", AnomalyLow, "Minor anomaly: Network inbound"}, + {"network_out", 1.5, "above", AnomalyNone, "Network outbound"}, + } + + for _, tc := range testCases { + result := formatAnomalyDescription(tc.metric, tc.ratio, tc.direction, tc.severity) + if !contains(result, tc.contains) { + t.Errorf("formatAnomalyDescription(%s, %f, %s, %s): expected to contain %q, got %q", + tc.metric, tc.ratio, tc.direction, tc.severity, tc.contains, result) + } + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestGetAllAnomalies(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + // Learn baselines for multiple resources + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{Value: 20 + float64(i%3) - 1} + } + store.Learn("vm-1", "vm", "cpu", points) + store.Learn("vm-2", "vm", "cpu", points) + + diskPoints := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + diskPoints[i] = MetricPoint{Value: 50 + float64(i%3) - 1} + } + store.Learn("vm-1", "vm", "disk", diskPoints) + + // Create a metrics provider that returns anomalous values + metricsProvider := func(resourceID string) map[string]float64 { + switch resourceID { + case "vm-1": + return map[string]float64{"cpu": 80, "disk": 90} // CPU 4x baseline, disk high + case "vm-2": + return map[string]float64{"cpu": 25} // Normal + default: + return nil + } + } + + anomalies := store.GetAllAnomalies(metricsProvider) + + // Should have anomalies for vm-1 (cpu 4x baseline + disk at 90%) + if len(anomalies) < 1 { + t.Errorf("Expected at least 1 anomaly, got %d", len(anomalies)) + } + + // vm-2 should not have anomalies + for _, a := range anomalies { + if a.ResourceID == "vm-2" { + t.Errorf("Did not expect anomaly for vm-2 with normal metrics") + } + } +} + +func TestGetAllAnomalies_EmptyStore(t *testing.T) { + store := NewStore(StoreConfig{MinSamples: 10}) + + metricsProvider := func(resourceID string) map[string]float64 { + return map[string]float64{"cpu": 90} + } + + anomalies := store.GetAllAnomalies(metricsProvider) + if len(anomalies) != 0 { + t.Errorf("Expected no anomalies from empty store, got %d", len(anomalies)) + } +} + +func TestFloatToStr(t *testing.T) { + testCases := []struct { + value float64 + precision int + expected string + }{ + {1.5, 1, "1.5"}, + {2.0, 1, "2"}, + {1.05, 2, "1.05"}, + {3.0, 2, "3"}, + {0.5, 1, "0.5"}, + {0.05, 2, "0.05"}, + } + + for _, tc := range testCases { + result := floatToStr(tc.value, tc.precision) + if result != tc.expected { + t.Errorf("floatToStr(%f, %d): expected %q, got %q", tc.value, tc.precision, tc.expected, result) + } + } +} + diff --git a/internal/ai/intelligence_test.go b/internal/ai/intelligence_test.go index f5a8276..39581f0 100644 --- a/internal/ai/intelligence_test.go +++ b/internal/ai/intelligence_test.go @@ -4,6 +4,10 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/correlation" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" "github.com/rcourtman/pulse-go-rewrite/internal/ai/patterns" ) @@ -168,3 +172,288 @@ func TestIntelligence_CheckBaselinesForResource(t *testing.T) { t.Error("Expected nil anomalies when baseline store not configured") } } + +// Note: mockStateProvider is already defined in alert_triggered_test.go + +func TestIntelligence_SetStateProvider(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + mockSP := &mockStateProvider{} + intel.SetStateProvider(mockSP) + + // State provider should be set (we can't directly access it, but no panic = success) +} + +func TestIntelligence_FormatGlobalContext(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // With no subsystems, should return empty + ctx := intel.FormatGlobalContext() + if ctx != "" { + t.Errorf("Expected empty context with no subsystems, got: %s", ctx) + } + + // Set up knowledge store + knowledgeStore, err := knowledge.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("Failed to create knowledge store: %v", err) + } + intel.SetSubsystems(nil, nil, nil, nil, nil, knowledgeStore, nil, nil) + + // Should still be empty (no knowledge saved) + ctx = intel.FormatGlobalContext() + // Empty or with headers only is fine +} + +func TestIntelligence_FormatGlobalContext_WithSubsystems(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Set up incident store with some data + incidentStore := memory.NewIncidentStore(memory.IncidentStoreConfig{ + MaxIncidents: 10, + }) + + // Create some incidents to format + incidentStore.RecordAnalysis("alert-1", "Test analysis", nil) + + intel.SetSubsystems(nil, nil, nil, nil, incidentStore, nil, nil, nil) + + ctx := intel.FormatGlobalContext() + // Having set incidents, there should be some context + // (may be empty if no actual incidents, but shouldn't panic) + _ = ctx +} + +func TestIntelligence_RecordLearning(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Without knowledge store, should return nil + err := intel.RecordLearning("vm-100", "test-vm", "vm", "Test Title", "Test content") + if err != nil { + t.Errorf("Expected nil error without knowledge store, got: %v", err) + } + + // With knowledge store + knowledgeStore, err := knowledge.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("Failed to create knowledge store: %v", err) + } + intel.SetSubsystems(nil, nil, nil, nil, nil, knowledgeStore, nil, nil) + + err = intel.RecordLearning("vm-100", "test-vm", "vm", "Test Title", "Test content") + if err != nil { + t.Errorf("Expected nil error, got: %v", err) + } +} + +func TestSeverityOrder(t *testing.T) { + tests := []struct { + severity FindingSeverity + expected int + }{ + {FindingSeverityCritical, 0}, + {FindingSeverityWarning, 1}, + {FindingSeverityWatch, 2}, + {FindingSeverityInfo, 3}, + {FindingSeverity("unknown"), 4}, + } + + for _, tt := range tests { + result := severityOrder(tt.severity) + if result != tt.expected { + t.Errorf("severityOrder(%s) = %d, expected %d", tt.severity, result, tt.expected) + } + } +} + +func TestIntelligence_CheckBaselinesForResource_WithBaselines(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Create baseline store with learned data + baselineStore := baseline.NewStore(baseline.StoreConfig{MinSamples: 10}) + + // Learn baseline for CPU at ~20% + points := make([]baseline.MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = baseline.MetricPoint{Value: 20 + float64(i%3) - 1} // 19-21 + } + baselineStore.Learn("vm-100", "vm", "cpu", points) + + intel.SetSubsystems(nil, nil, nil, baselineStore, nil, nil, nil, nil) + + // Check with anomalous value (80% is 4x baseline) + anomalies := intel.CheckBaselinesForResource("vm-100", map[string]float64{ + "cpu": 80.0, + }) + + // Should detect anomaly for CPU (80% with baseline of 20% is 4x = anomalous) + if len(anomalies) == 0 { + t.Error("Expected anomaly for CPU at 80% with baseline of 20%") + } +} + +func TestIntelligence_GetResourceIntelligence_WithSubsystems(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Create findings store with a finding for the resource + findings := NewFindingsStore() + findings.Add(&Finding{ + ID: "f1", + Key: "test:f1", + Severity: FindingSeverityCritical, + Category: FindingCategoryPerformance, + ResourceID: "vm-200", + ResourceName: "critical-vm", + ResourceType: "vm", + Title: "Critical CPU", + DetectedAt: time.Now(), + LastSeenAt: time.Now(), + Source: "test", + }) + + // Create correlation detector + correlationDetector := correlation.NewDetector(correlation.DefaultConfig()) + + intel.SetSubsystems(findings, nil, correlationDetector, nil, nil, nil, nil, nil) + + resourceIntel := intel.GetResourceIntelligence("vm-200") + + if len(resourceIntel.ActiveFindings) != 1 { + t.Errorf("Expected 1 active finding, got %d", len(resourceIntel.ActiveFindings)) + } + + if resourceIntel.ResourceName != "critical-vm" { + t.Errorf("Expected resource name 'critical-vm', got %s", resourceIntel.ResourceName) + } + + // Health should be reduced due to critical finding + if resourceIntel.Health.Score >= 100 { + t.Error("Expected reduced health score due to critical finding") + } + + // Grade should be less than A + if resourceIntel.Health.Grade == HealthGradeA { + t.Error("Expected health grade less than A due to critical finding") + } +} + +func TestIntelligence_FormatContext_WithKnowledge(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Create knowledge store with data + knowledgeStore, err := knowledge.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("Failed to create knowledge store: %v", err) + } + knowledgeStore.SaveNote("vm-300", "test-vm", "vm", "general", "Test Note", "This is test content") + + intel.SetSubsystems(nil, nil, nil, nil, nil, knowledgeStore, nil, nil) + + ctx := intel.FormatContext("vm-300") + + // Should contain the knowledge context + if ctx == "" { + t.Error("Expected non-empty context with knowledge") + } +} + +func TestIntelligence_CreatePredictionFinding_LowSeverity(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Prediction far in the future with low confidence + pred := patterns.FailurePrediction{ + ResourceID: "vm-100", + EventType: patterns.EventHighMemory, + DaysUntil: 14, // Far away + Confidence: 0.3, // Low confidence + Basis: "Pattern detected", + } + + finding := intel.CreatePredictionFinding(pred) + + // Should be watch severity (not critical or warning) + if finding.Severity != FindingSeverityWatch { + t.Errorf("Expected watch severity for far-off low-confidence prediction, got %s", finding.Severity) + } +} + +func TestIntelligence_CreatePredictionFinding_WarningSeverity(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + // Prediction soon but low confidence + pred := patterns.FailurePrediction{ + ResourceID: "vm-100", + EventType: patterns.EventHighCPU, + DaysUntil: 0.5, // Soon + Confidence: 0.6, // Medium confidence (not > 0.8) + Basis: "Pattern detected", + } + + finding := intel.CreatePredictionFinding(pred) + + // Should be warning (soon but not high confidence) + if finding.Severity != FindingSeverityWarning { + t.Errorf("Expected warning severity for imminent medium-confidence prediction, got %s", finding.Severity) + } +} + +func TestIntelligence_GetSummary_WithCriticalFindings(t *testing.T) { + intel := NewIntelligence(IntelligenceConfig{}) + + findings := NewFindingsStore() + + // Add multiple critical findings + for i := 0; i < 3; i++ { + findings.Add(&Finding{ + ID: "crit-" + string(rune('A'+i)), + Key: "critical:" + string(rune('A'+i)), + Severity: FindingSeverityCritical, + Category: FindingCategoryReliability, + ResourceID: "vm-crit-" + string(rune('A'+i)), + Title: "Critical Issue", + DetectedAt: time.Now(), + LastSeenAt: time.Now(), + Source: "test", + }) + } + + intel.SetSubsystems(findings, nil, nil, nil, nil, nil, nil, nil) + + summary := intel.GetSummary() + + // Should have 3 critical + if summary.FindingsCount.Critical != 3 { + t.Errorf("Expected 3 critical findings, got %d", summary.FindingsCount.Critical) + } + + // Health should be significantly reduced + // Note: critical impact is capped at 40 points, so score = 100 - 40 = 60 + if summary.OverallHealth.Score > 60 { + t.Errorf("Expected health score <= 60 with 3 critical findings, got %f", summary.OverallHealth.Score) + } + + // Prediction text should mention critical issues + if summary.OverallHealth.Prediction == "" { + t.Error("Expected non-empty prediction text") + } +} + +func TestAbsFloatIntel(t *testing.T) { + tests := []struct { + input float64 + expected float64 + }{ + {5.5, 5.5}, + {-5.5, 5.5}, + {0, 0}, + {-0, 0}, + } + + for _, tt := range tests { + result := absFloatIntel(tt.input) + if result != tt.expected { + t.Errorf("absFloatIntel(%f) = %f, expected %f", tt.input, result, tt.expected) + } + } +} + diff --git a/internal/ai/memory/incidents_test.go b/internal/ai/memory/incidents_test.go index 0726cc0..ed9c76c 100644 --- a/internal/ai/memory/incidents_test.go +++ b/internal/ai/memory/incidents_test.go @@ -1,6 +1,7 @@ package memory import ( + "strings" "testing" "time" @@ -9,7 +10,6 @@ import ( func TestIncidentStore_RecordTimeline(t *testing.T) { store := NewIncidentStore(IncidentStoreConfig{ - DataDir: t.TempDir(), MaxIncidents: 10, MaxEventsPerIncident: 10, MaxAgeDays: 30, @@ -55,7 +55,6 @@ func TestIncidentStore_RecordTimeline(t *testing.T) { func TestIncidentStore_GetTimelineByAlertAt(t *testing.T) { store := NewIncidentStore(IncidentStoreConfig{ - DataDir: t.TempDir(), MaxIncidents: 10, MaxEventsPerIncident: 10, MaxAgeDays: 30, @@ -106,3 +105,556 @@ func TestIncidentStore_GetTimelineByAlertAt(t *testing.T) { t.Fatalf("expected no timeline for mismatched start time") } } + +func TestIncidentStore_RecordAlertUnacknowledged(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + MaxEventsPerIncident: 10, + MaxAgeDays: 30, + }) + + alert := &alerts.Alert{ + ID: "alert-unack-1", + Type: "memory", + Level: alerts.AlertLevelWarning, + ResourceID: "res-3", + ResourceName: "vm-3", + StartTime: time.Now().Add(-10 * time.Minute), + } + + // Fire alert and acknowledge it + store.RecordAlertFired(alert) + store.RecordAlertAcknowledged(alert, "admin") + + timeline := store.GetTimelineByAlertID(alert.ID) + if timeline == nil { + t.Fatalf("expected timeline after ack") + } + if !timeline.Acknowledged { + t.Fatal("expected acknowledged=true after acknowledgement") + } + if timeline.AckUser != "admin" { + t.Errorf("expected ack user admin, got %q", timeline.AckUser) + } + + // Now unacknowledge + store.RecordAlertUnacknowledged(alert, "operator") + + timeline = store.GetTimelineByAlertID(alert.ID) + if timeline == nil { + t.Fatalf("expected timeline after unack") + } + if timeline.Acknowledged { + t.Fatal("expected acknowledged=false after unacknowledgement") + } + if timeline.AckUser != "" { + t.Errorf("expected empty ack user after unack, got %q", timeline.AckUser) + } + if timeline.AckTime != nil { + t.Error("expected nil ack time after unack") + } + + // Check for the unacknowledge event + foundUnackEvent := false + for _, evt := range timeline.Events { + if evt.Type == IncidentEventAlertUnacknowledged { + foundUnackEvent = true + if user, ok := evt.Details["user"].(string); !ok || user != "operator" { + t.Errorf("expected user 'operator' in event details, got %v", evt.Details["user"]) + } + } + } + if !foundUnackEvent { + t.Error("expected to find unacknowledge event in timeline") + } +} + +func TestIncidentStore_RecordAlertUnacknowledged_NilAlert(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + // Should not panic with nil alert + store.RecordAlertUnacknowledged(nil, "admin") +} + +func TestIncidentStore_RecordRunbook(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + MaxEventsPerIncident: 10, + MaxAgeDays: 30, + }) + + alert := &alerts.Alert{ + ID: "alert-runbook-1", + Type: "disk", + Level: alerts.AlertLevelCritical, + ResourceID: "res-4", + ResourceName: "storage-1", + StartTime: time.Now().Add(-5 * time.Minute), + } + + store.RecordAlertFired(alert) + + // Record a runbook execution + store.RecordRunbook(alert.ID, "runbook-cleanup", "Disk Cleanup", "success", true, "Freed 10GB") + + timeline := store.GetTimelineByAlertID(alert.ID) + if timeline == nil { + t.Fatal("expected timeline after runbook") + } + + // Find the runbook event + foundRunbookEvent := false + for _, evt := range timeline.Events { + if evt.Type == IncidentEventRunbook { + foundRunbookEvent = true + if !strings.Contains(evt.Summary, "Disk Cleanup") { + t.Errorf("expected summary to contain 'Disk Cleanup', got %q", evt.Summary) + } + if !strings.Contains(evt.Summary, "success") { + t.Errorf("expected summary to contain 'success', got %q", evt.Summary) + } + if runbookID, ok := evt.Details["runbook_id"].(string); !ok || runbookID != "runbook-cleanup" { + t.Errorf("expected runbook_id 'runbook-cleanup', got %v", evt.Details["runbook_id"]) + } + if automatic, ok := evt.Details["automatic"].(bool); !ok || !automatic { + t.Errorf("expected automatic=true, got %v", evt.Details["automatic"]) + } + if message, ok := evt.Details["message"].(string); !ok || message != "Freed 10GB" { + t.Errorf("expected message 'Freed 10GB', got %v", evt.Details["message"]) + } + } + } + if !foundRunbookEvent { + t.Error("expected to find runbook event in timeline") + } +} + +func TestIncidentStore_RecordRunbook_EmptyParams(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + // Should not create incident with empty alertID + store.RecordRunbook("", "runbook-1", "Test", "success", false, "") + + // Should not create incident with empty runbookID + store.RecordRunbook("alert-1", "", "Test", "success", false, "") +} + +func TestIncidentStore_RecordRunbook_CreatesIncident(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + // RecordRunbook should create incident if none exists + store.RecordRunbook("new-alert", "runbook-1", "Test Runbook", "completed", false, "") + + timeline := store.GetTimelineByAlertID("new-alert") + if timeline == nil { + t.Fatal("expected timeline to be created by RecordRunbook") + } + if timeline.Status != IncidentStatusOpen { + t.Errorf("expected status 'open', got %q", timeline.Status) + } +} + +func TestIncidentStore_ListIncidentsByResource(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 20, + MaxAgeDays: 30, + }) + + // Create multiple incidents for the same resource + for i := 0; i < 5; i++ { + alert := &alerts.Alert{ + ID: "alert-list-" + string(rune('A'+i)), + Type: "cpu", + Level: alerts.AlertLevelWarning, + ResourceID: "res-list-1", + ResourceName: "vm-list-1", + StartTime: time.Now().Add(-time.Duration(i) * time.Hour), + } + store.RecordAlertFired(alert) + } + + // Create incident for different resource + otherAlert := &alerts.Alert{ + ID: "alert-other", + Type: "memory", + Level: alerts.AlertLevelCritical, + ResourceID: "res-other", + ResourceName: "vm-other", + StartTime: time.Now(), + } + store.RecordAlertFired(otherAlert) + + // List all incidents for res-list-1 + incidents := store.ListIncidentsByResource("res-list-1", 0) + if len(incidents) != 5 { + t.Errorf("expected 5 incidents for res-list-1, got %d", len(incidents)) + } + + // List with limit + incidents = store.ListIncidentsByResource("res-list-1", 3) + if len(incidents) != 3 { + t.Errorf("expected 3 incidents with limit, got %d", len(incidents)) + } + + // List for non-existent resource + incidents = store.ListIncidentsByResource("res-nonexistent", 0) + if len(incidents) != 0 { + t.Errorf("expected 0 incidents for non-existent resource, got %d", len(incidents)) + } + + // Empty resource ID + incidents = store.ListIncidentsByResource("", 0) + if incidents != nil { + t.Error("expected nil for empty resource ID") + } +} + +func TestIncidentStore_FormatForAlert(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + MaxEventsPerIncident: 50, + MaxAgeDays: 30, + }) + + alert := &alerts.Alert{ + ID: "alert-format-1", + Type: "cpu_high", + Level: alerts.AlertLevelWarning, + ResourceID: "res-format-1", + ResourceName: "vm-format-1", + StartTime: time.Now().Add(-10 * time.Minute), + } + + store.RecordAlertFired(alert) + store.RecordAlertAcknowledged(alert, "admin") + store.RecordAnalysis(alert.ID, "High CPU due to process X", nil) + + result := store.FormatForAlert(alert.ID, 10) + + if result == "" { + t.Fatal("expected non-empty format result") + } + + // Check for expected content + if !strings.Contains(result, "## Incident Memory") { + t.Error("expected '## Incident Memory' header") + } + if !strings.Contains(result, "vm-format-1") { + t.Error("expected resource name in output") + } + if !strings.Contains(result, "cpu_high") { + t.Error("expected alert type in output") + } + if !strings.Contains(result, "Status: open") { + t.Error("expected status in output") + } +} + +func TestIncidentStore_FormatForAlert_MaxEvents(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + MaxEventsPerIncident: 100, + }) + + alert := &alerts.Alert{ + ID: "alert-max-events", + Type: "cpu", + Level: alerts.AlertLevelWarning, + ResourceID: "res-1", + ResourceName: "vm-1", + } + + store.RecordAlertFired(alert) + for i := 0; i < 10; i++ { + store.RecordAnalysis(alert.ID, "Analysis "+string(rune('A'+i)), nil) + } + + // Request only 3 events + result := store.FormatForAlert(alert.ID, 3) + if result == "" { + t.Fatal("expected non-empty result") + } + + // Should only have last 3 events in output (count occurrences of timestamps) + lines := strings.Split(result, "\n") + eventLines := 0 + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "- 20") { // timestamp starts with year + eventLines++ + } + } + if eventLines > 3 { + t.Errorf("expected max 3 event lines, got %d", eventLines) + } +} + +func TestIncidentStore_FormatForAlert_NoIncident(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + result := store.FormatForAlert("nonexistent-alert", 10) + if result != "" { + t.Errorf("expected empty string for non-existent alert, got %q", result) + } +} + +func TestIncidentStore_FormatForResource(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 20, + MaxAgeDays: 30, + }) + + // Create multiple incidents for the same resource + for i := 0; i < 3; i++ { + alert := &alerts.Alert{ + ID: "alert-res-" + string(rune('A'+i)), + Type: "disk", + Level: alerts.AlertLevelWarning, + ResourceID: "res-format-res", + ResourceName: "storage-format", + StartTime: time.Now().Add(-time.Duration(i) * time.Hour), + } + store.RecordAlertFired(alert) + if i == 0 { + store.RecordAlertAcknowledged(alert, "admin") + } + } + + result := store.FormatForResource("res-format-res", 5) + + if result == "" { + t.Fatal("expected non-empty format result") + } + + if !strings.Contains(result, "## Incident Memory") { + t.Error("expected '## Incident Memory' header") + } + if !strings.Contains(result, "Recent incidents for this resource") { + t.Error("expected resource incidents header") + } + if !strings.Contains(result, "disk") { + t.Error("expected alert type in output") + } + // First incident should show as acknowledged + if !strings.Contains(result, "acknowledged") { + t.Error("expected 'acknowledged' status for first incident") + } +} + +func TestIncidentStore_FormatForResource_NoIncidents(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + result := store.FormatForResource("nonexistent-resource", 5) + if result != "" { + t.Errorf("expected empty string for resource with no incidents, got %q", result) + } +} + +func TestIncidentStore_FormatForPatrol(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 20, + MaxAgeDays: 30, + }) + + // Create incidents for multiple resources + resources := []string{"vm-1", "vm-2", "storage-1"} + for i, resName := range resources { + alert := &alerts.Alert{ + ID: "alert-patrol-" + string(rune('A'+i)), + Type: "cpu", + Level: alerts.AlertLevelWarning, + ResourceID: "res-patrol-" + string(rune('1'+i)), + ResourceName: resName, + StartTime: time.Now().Add(-time.Duration(i) * time.Hour), + Message: "High usage detected", + } + store.RecordAlertFired(alert) + store.RecordAnalysis(alert.ID, "Analysis for "+resName, nil) + } + + result := store.FormatForPatrol(10) + + if result == "" { + t.Fatal("expected non-empty format result") + } + + if !strings.Contains(result, "## Incident Memory") { + t.Error("expected '## Incident Memory' header") + } + if !strings.Contains(result, "Recent incidents across infrastructure") { + t.Error("expected infrastructure-wide header") + } + // Should contain resource names + for _, resName := range resources { + if !strings.Contains(result, resName) { + t.Errorf("expected resource name %q in output", resName) + } + } +} + +func TestIncidentStore_FormatForPatrol_WithLimit(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 20, + }) + + // Create 10 incidents + for i := 0; i < 10; i++ { + alert := &alerts.Alert{ + ID: "alert-limit-" + string(rune('A'+i)), + Type: "memory", + Level: alerts.AlertLevelWarning, + ResourceID: "res-" + string(rune('A'+i)), + ResourceName: "vm-" + string(rune('A'+i)), + } + store.RecordAlertFired(alert) + } + + // Request only 3 + result := store.FormatForPatrol(3) + + // Count incident lines + lines := strings.Split(result, "\n") + incidentLines := 0 + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "- 20") { + incidentLines++ + } + } + if incidentLines > 3 { + t.Errorf("expected max 3 incident lines, got %d", incidentLines) + } +} + +func TestIncidentStore_FormatForPatrol_Empty(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + result := store.FormatForPatrol(10) + if result != "" { + t.Errorf("expected empty string for empty store, got %q", result) + } +} + +func TestIncidentStore_FormatForPatrol_DefaultLimit(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 20, + }) + + // Create 15 incidents + for i := 0; i < 15; i++ { + alert := &alerts.Alert{ + ID: "alert-default-" + string(rune('A'+i)), + Type: "cpu", + Level: alerts.AlertLevelWarning, + ResourceID: "res-" + string(rune('A'+i)), + ResourceName: "vm-" + string(rune('A'+i)), + } + store.RecordAlertFired(alert) + } + + // Pass 0 limit - should use default of 8 + result := store.FormatForPatrol(0) + + // Count incident lines - should be at most 8 + lines := strings.Split(result, "\n") + incidentLines := 0 + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "- 20") { + incidentLines++ + } + } + if incidentLines > 8 { + t.Errorf("expected max 8 incident lines (default), got %d", incidentLines) + } +} + +func TestIncidentStore_RecordNote_ByIncidentID(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + alert := &alerts.Alert{ + ID: "alert-note-id", + Type: "cpu", + Level: alerts.AlertLevelWarning, + ResourceID: "res-note", + ResourceName: "vm-note", + } + store.RecordAlertFired(alert) + + timeline := store.GetTimelineByAlertID(alert.ID) + if timeline == nil { + t.Fatal("expected timeline") + } + + // Add note by incident ID + ok := store.RecordNote("", timeline.ID, "Test note by incident ID", "operator") + if !ok { + t.Fatal("expected note to be saved by incident ID") + } + + timeline = store.GetTimelineByAlertID(alert.ID) + foundNoteEvent := false + for _, evt := range timeline.Events { + if evt.Type == IncidentEventNote { + foundNoteEvent = true + if note, ok := evt.Details["note"].(string); !ok || note != "Test note by incident ID" { + t.Errorf("expected note text, got %v", evt.Details["note"]) + } + } + } + if !foundNoteEvent { + t.Error("expected to find note event") + } +} + +func TestIncidentStore_RecordNote_EmptyNote(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + alert := &alerts.Alert{ + ID: "alert-empty-note", + ResourceID: "res-1", + } + store.RecordAlertFired(alert) + + // Empty note should return false + ok := store.RecordNote(alert.ID, "", "", "admin") + if ok { + t.Error("expected false for empty note") + } + + // Whitespace-only note should return false + ok = store.RecordNote(alert.ID, "", " ", "admin") + if ok { + t.Error("expected false for whitespace-only note") + } +} + +func TestIncidentStore_RecordNote_NonexistentIncident(t *testing.T) { + store := NewIncidentStore(IncidentStoreConfig{ + MaxIncidents: 10, + }) + + ok := store.RecordNote("nonexistent-alert", "", "Test note", "admin") + if ok { + t.Error("expected false for non-existent alert") + } + + ok = store.RecordNote("", "nonexistent-incident", "Test note", "admin") + if ok { + t.Error("expected false for non-existent incident") + } +} +