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%)
This commit is contained in:
rcourtman 2025-12-21 20:22:47 +00:00
parent 0ed453c1aa
commit 324ea0ca92
3 changed files with 1115 additions and 2 deletions

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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")
}
}