diff --git a/.gitignore b/.gitignore index c63b063..842a4f7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ Thumbs.db *.dylib *.test *.out +coverage.html vendor/ test-pulse diff --git a/internal/ai/alert_triggered_test.go b/internal/ai/alert_triggered_test.go index 0595212..e5d8287 100644 --- a/internal/ai/alert_triggered_test.go +++ b/internal/ai/alert_triggered_test.go @@ -1,7 +1,7 @@ package ai import ( - "sync" + "context" "testing" "time" @@ -9,104 +9,350 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/models" ) -// mockStateProvider implements StateProvider for testing -type mockStateProvider struct { - state models.StateSnapshot -} -func (m *mockStateProvider) GetState() models.StateSnapshot { - return m.state -} - -func TestAlertTriggeredAnalyzer_NewAlertTriggeredAnalyzer(t *testing.T) { - analyzer := NewAlertTriggeredAnalyzer(nil, nil) - - if analyzer == nil { - t.Fatal("Expected non-nil analyzer") +func TestAlertTriggeredAnalyzer_AnalyzeNodeFromAlert(t *testing.T) { + // Create a mock state provider + stateProvider := &mockStateProvider{ + state: models.StateSnapshot{ + Nodes: []models.Node{ + { + ID: "node/pve1", + Name: "pve1", + Status: "online", + CPU: 0.95, // 95% + Memory: models.Memory{ + Total: 32000000000, + Used: 30000000000, + }, + }, + }, + }, } - // Default should be disabled - if analyzer.IsEnabled() { - t.Error("Expected analyzer to be disabled by default") + // Create a patrol service with default thresholds + patrolService := &PatrolService{ + thresholds: DefaultPatrolThresholds(), } - // Should have initialized maps - if analyzer.lastAnalyzed == nil { - t.Error("Expected lastAnalyzed map to be initialized") - } - if analyzer.pending == nil { - t.Error("Expected pending map to be initialized") - } + analyzer := NewAlertTriggeredAnalyzer(patrolService, stateProvider) - // Default cooldown should be 5 minutes - if analyzer.cooldown != 5*time.Minute { - t.Errorf("Expected 5 minute cooldown, got %v", analyzer.cooldown) - } -} - -func TestAlertTriggeredAnalyzer_SetEnabled(t *testing.T) { - analyzer := NewAlertTriggeredAnalyzer(nil, nil) - - // Start disabled - if analyzer.IsEnabled() { - t.Error("Expected analyzer to be disabled initially") - } - - // Enable - analyzer.SetEnabled(true) - if !analyzer.IsEnabled() { - t.Error("Expected analyzer to be enabled after SetEnabled(true)") - } - - // Disable - analyzer.SetEnabled(false) - if analyzer.IsEnabled() { - t.Error("Expected analyzer to be disabled after SetEnabled(false)") - } -} - -func TestAlertTriggeredAnalyzer_OnAlertFired_Disabled(t *testing.T) { - analyzer := NewAlertTriggeredAnalyzer(nil, nil) - - // Create a test alert alert := &alerts.Alert{ - ID: "test-alert-1", + ID: "alert-1", + Type: "node_cpu", + ResourceID: "node/pve1", + ResourceName: "pve1", + } + + findings := analyzer.analyzeNodeFromAlert(context.Background(), alert) + + if len(findings) == 0 { + t.Error("Expected findings for high CPU node, got 0") + } + + foundHighCPU := false + for _, f := range findings { + if f.Title == "High CPU usage" { + foundHighCPU = true + break + } + } + + if !foundHighCPU { + t.Error("Expected 'High CPU usage' finding") + } + + // Test with non-existent node + alertMissing := &alerts.Alert{ + ID: "alert-2", + ResourceID: "non-existent", + } + findingsMissing := analyzer.analyzeNodeFromAlert(context.Background(), alertMissing) + if len(findingsMissing) != 0 { + t.Errorf("Expected 0 findings for non-existent node, got %d", len(findingsMissing)) + } +} + +func TestAlertTriggeredAnalyzer_AnalyzeGuestFromAlert(t *testing.T) { + stateProvider := &mockStateProvider{ + state: models.StateSnapshot{ + VMs: []models.VM{ + { + ID: "qemu/100", + Name: "test-vm", + Status: "running", + CPU: 0.8, + Memory: models.Memory{ + Usage: 95.0, + }, + Disk: models.Disk{ + Usage: 90.0, + }, + }, + }, + Containers: []models.Container{ + { + ID: "lxc/200", + Name: "test-container", + Status: "running", + CPU: 0.5, + Memory: models.Memory{ + Usage: 96.0, + }, + Disk: models.Disk{ + Usage: 85.0, + }, + }, + }, + }, + } + + patrolService := &PatrolService{ + thresholds: DefaultPatrolThresholds(), + } + + analyzer := NewAlertTriggeredAnalyzer(patrolService, stateProvider) + + // Test VM + alertVM := &alerts.Alert{ + ID: "vm-alert", + Type: "vm_memory", + ResourceID: "qemu/100", + ResourceName: "test-vm", + } + + findingsVM := analyzer.analyzeGuestFromAlert(context.Background(), alertVM) + if len(findingsVM) == 0 { + t.Error("Expected findings for high memory VM, got 0") + } + + // Test Container + alertCT := &alerts.Alert{ + ID: "ct-alert", + Type: "lxc_memory", + ResourceID: "lxc/200", + ResourceName: "test-container", + } + + findingsCT := analyzer.analyzeGuestFromAlert(context.Background(), alertCT) + if len(findingsCT) == 0 { + t.Error("Expected findings for high memory container, got 0") + } + + // Test non-existent guest + alertMissing := &alerts.Alert{ + ID: "missing-alert", + ResourceID: "qemu/999", + } + findingsMissing := analyzer.analyzeGuestFromAlert(context.Background(), alertMissing) + if len(findingsMissing) != 0 { + t.Errorf("Expected 0 findings for missing guest, got %d", len(findingsMissing)) + } +} + +func TestAlertTriggeredAnalyzer_AnalyzeDockerFromAlert(t *testing.T) { + stateProvider := &mockStateProvider{ + state: models.StateSnapshot{ + DockerHosts: []models.DockerHost{ + { + ID: "dh-1", + Hostname: "docker-host-1", + Status: "online", + Containers: []models.DockerContainer{ + { + ID: "container-1", + Name: "web-server", + State: "running", + MemoryPercent: 95.0, + }, + }, + }, + }, + }, + } + + patrolService := &PatrolService{ + thresholds: DefaultPatrolThresholds(), + } + + analyzer := NewAlertTriggeredAnalyzer(patrolService, stateProvider) + + // Test Docker Host + alertHost := &alerts.Alert{ + ID: "dh-alert", + Type: "docker_host_cpu", + ResourceID: "dh-1", + ResourceName: "docker-host-1", + } + + findingsHost := analyzer.analyzeDockerFromAlert(context.Background(), alertHost) + // Even if everything is fine, it shouldn't return nil if the host is found (though findings might be 0) + // But in my mock, if things are fine, findings will be 0. + // Let's make it offline to get a finding. + stateProvider.state.DockerHosts[0].Status = "offline" + findingsHost = analyzer.analyzeDockerFromAlert(context.Background(), alertHost) + if len(findingsHost) == 0 { + t.Error("Expected findings for offline Docker host, got 0") + } + + // Test Docker Container + stateProvider.state.DockerHosts[0].Status = "online" + alertContainer := &alerts.Alert{ + ID: "container-alert", + Type: "docker_container_memory", + ResourceID: "container-1", + ResourceName: "web-server", + } + + findingsContainer := analyzer.analyzeDockerFromAlert(context.Background(), alertContainer) + if len(findingsContainer) == 0 { + t.Error("Expected findings for high memory Docker container, got 0") + } + + // Test missing Docker resource + alertMissing := &alerts.Alert{ + ID: "missing-alert", + ResourceID: "container-999", + } + findingsMissing := analyzer.analyzeDockerFromAlert(context.Background(), alertMissing) + if len(findingsMissing) != 0 { + t.Errorf("Expected 0 findings for missing Docker resource, got %d", len(findingsMissing)) + } +} + +func TestAlertTriggeredAnalyzer_AnalyzeStorageFromAlert(t *testing.T) { + stateProvider := &mockStateProvider{ + state: models.StateSnapshot{ + Storage: []models.Storage{ + { + ID: "storage-1", + Name: "local-lvm", + Usage: 95.0, + Total: 100000000000, + Used: 95000000000, + }, + }, + }, + } + + patrolService := &PatrolService{ + thresholds: DefaultPatrolThresholds(), + } + + analyzer := NewAlertTriggeredAnalyzer(patrolService, stateProvider) + + // Test storage with high usage + alert := &alerts.Alert{ + ID: "storage-alert", + Type: "storage-usage", + ResourceID: "storage-1", + ResourceName: "local-lvm", + } + + findings := analyzer.analyzeStorageFromAlert(context.Background(), alert) + if len(findings) == 0 { + t.Error("Expected findings for high storage usage, got 0") + } + + // Test missing storage + alertMissing := &alerts.Alert{ + ID: "missing-alert", + ResourceID: "storage-999", + } + findingsMissing := analyzer.analyzeStorageFromAlert(context.Background(), alertMissing) + if len(findingsMissing) != 0 { + t.Errorf("Expected 0 findings for missing storage, got %d", len(findingsMissing)) + } +} + +func TestAlertTriggeredAnalyzer_AnalyzeGenericResourceFromAlert(t *testing.T) { + stateProvider := &mockStateProvider{ + state: models.StateSnapshot{ + Nodes: []models.Node{ + { + ID: "node/pve1", + Name: "pve1", + Status: "online", + CPU: 0.95, + Memory: models.Memory{ + Total: 32000000000, + Used: 30000000000, + }, + }, + }, + VMs: []models.VM{ + { + ID: "qemu/100", + Name: "test-vm", + Status: "running", + CPU: 0.8, + Memory: models.Memory{ + Usage: 95.0, + }, + Disk: models.Disk{ + Usage: 90.0, + }, + }, + }, + }, + } + + patrolService := &PatrolService{ + thresholds: DefaultPatrolThresholds(), + } + + analyzer := NewAlertTriggeredAnalyzer(patrolService, stateProvider) + + // Test node resourceID pattern - use ResourceName to match node.Name + alertNode := &alerts.Alert{ + ID: "cpu-alert", Type: "cpu", - ResourceID: "node-1", - ResourceName: "test-node", - Value: 95.0, - Threshold: 90.0, + ResourceID: "cluster1/node/pve1", + ResourceName: "pve1", + } + findingsNode := analyzer.analyzeGenericResourceFromAlert(context.Background(), alertNode) + // Should route to analyzeNodeFromAlert + if len(findingsNode) == 0 { + t.Error("Expected findings for node CPU, got 0") } - // When disabled, OnAlertFired should do nothing (no panic) - analyzer.OnAlertFired(alert) + // Test qemu resourceID pattern - use ResourceName to match vm.Name + alertQemu := &alerts.Alert{ + ID: "mem-alert", + Type: "memory", + ResourceID: "cluster1/qemu/100", + ResourceName: "test-vm", + } + findingsQemu := analyzer.analyzeGenericResourceFromAlert(context.Background(), alertQemu) + // Should route to analyzeGuestFromAlert + if len(findingsQemu) == 0 { + t.Error("Expected findings for qemu memory, got 0") + } - // Verify no pending analyses were started - analyzer.mu.RLock() - pending := len(analyzer.pending) - analyzer.mu.RUnlock() + // Test docker resourceID pattern + alertDocker := &alerts.Alert{ + ID: "docker-alert", + Type: "disk", + ResourceID: "docker-host-1", + } + // This won't find anything, so we expect 0 findings (no docker hosts in state) + findingsDocker := analyzer.analyzeGenericResourceFromAlert(context.Background(), alertDocker) + if len(findingsDocker) != 0 { + t.Errorf("Expected 0 findings for missing docker, got %d", len(findingsDocker)) + } - if pending != 0 { - t.Errorf("Expected no pending analyses when disabled, got %d", pending) + // Test fallback (tries guest first, then node) + alertGeneric := &alerts.Alert{ + ID: "generic-alert", + Type: "cpu", + ResourceID: "test-vm", + ResourceName: "test-vm", + } + findingsGeneric := analyzer.analyzeGenericResourceFromAlert(context.Background(), alertGeneric) + if len(findingsGeneric) == 0 { + t.Error("Expected findings for generic CPU alert (fallback to guest), got 0") } } -func TestAlertTriggeredAnalyzer_OnAlertFired_NilAlert(t *testing.T) { - analyzer := NewAlertTriggeredAnalyzer(nil, nil) - analyzer.SetEnabled(true) - - // Should handle nil alert gracefully (no panic) - analyzer.OnAlertFired(nil) - - // Verify no pending analyses - analyzer.mu.RLock() - pending := len(analyzer.pending) - analyzer.mu.RUnlock() - - if pending != 0 { - t.Errorf("Expected no pending analyses for nil alert, got %d", pending) - } -} func TestAlertTriggeredAnalyzer_ResourceKeyFromAlert(t *testing.T) { analyzer := NewAlertTriggeredAnalyzer(nil, nil) @@ -157,7 +403,154 @@ func TestAlertTriggeredAnalyzer_ResourceKeyFromAlert(t *testing.T) { } } -func TestAlertTriggeredAnalyzer_Cooldown(t *testing.T) { +func TestAlertTriggeredAnalyzer_CleanupOldCooldowns(t *testing.T) { + analyzer := NewAlertTriggeredAnalyzer(nil, nil) + + // Add some cooldown entries - one old, one recent + analyzer.mu.Lock() + analyzer.lastAnalyzed["old-resource"] = time.Now().Add(-2 * time.Hour) // > 1 hour old + analyzer.lastAnalyzed["recent-resource"] = time.Now() // Recent + analyzer.mu.Unlock() + + // Cleanup + analyzer.CleanupOldCooldowns() + + analyzer.mu.RLock() + _, oldExists := analyzer.lastAnalyzed["old-resource"] + _, recentExists := analyzer.lastAnalyzed["recent-resource"] + analyzer.mu.RUnlock() + + if oldExists { + t.Error("Expected old cooldown entry to be removed") + } + if !recentExists { + t.Error("Expected recent cooldown entry to be kept") + } +} + +func TestAlertTriggeredAnalyzer_OnAlertFired_Enabled(t *testing.T) { + stateProvider := &mockStateProvider{ + state: models.StateSnapshot{ + Nodes: []models.Node{ + { + ID: "node/pve1", + Name: "pve1", + Status: "online", + CPU: 0.95, + Memory: models.Memory{ + Total: 32000000000, + Used: 30000000000, + }, + }, + }, + }, + } + + patrolService := &PatrolService{ + thresholds: DefaultPatrolThresholds(), + } + + analyzer := NewAlertTriggeredAnalyzer(patrolService, stateProvider) + analyzer.SetEnabled(true) + + alert := &alerts.Alert{ + ID: "test-alert-1", + Type: "node_cpu", + ResourceID: "node/pve1", + ResourceName: "pve1", + Value: 95.0, + Threshold: 90.0, + } + + // Fire the alert + analyzer.OnAlertFired(alert) + + // Give time for the goroutine to start and set pending + time.Sleep(50 * time.Millisecond) + + // Wait for analysis to complete + time.Sleep(100 * time.Millisecond) + + // After analysis, lastAnalyzed should be updated + analyzer.mu.RLock() + _, exists := analyzer.lastAnalyzed["node/pve1"] + analyzer.mu.RUnlock() + + if !exists { + t.Error("Expected lastAnalyzed to be updated after alert was fired") + } +} + +func TestAlertTriggeredAnalyzer_OnAlertFired_Disabled(t *testing.T) { + analyzer := NewAlertTriggeredAnalyzer(nil, nil) + // Analyzer is disabled by default + + alert := &alerts.Alert{ + ID: "test-alert-1", + Type: "cpu", + ResourceID: "node-1", + ResourceName: "test-node", + Value: 95.0, + Threshold: 90.0, + } + + // When disabled, OnAlertFired should do nothing (no panic) + analyzer.OnAlertFired(alert) + + // Verify no pending analyses were started + analyzer.mu.RLock() + pending := len(analyzer.pending) + analyzer.mu.RUnlock() + + if pending != 0 { + t.Errorf("Expected no pending analyses when disabled, got %d", pending) + } +} + +func TestAlertTriggeredAnalyzer_OnAlertFired_NilAlert(t *testing.T) { + analyzer := NewAlertTriggeredAnalyzer(nil, nil) + analyzer.SetEnabled(true) + + // Should handle nil alert gracefully (no panic) + analyzer.OnAlertFired(nil) + + // Verify no pending analyses + analyzer.mu.RLock() + pending := len(analyzer.pending) + analyzer.mu.RUnlock() + + if pending != 0 { + t.Errorf("Expected no pending analyses for nil alert, got %d", pending) + } +} + +func TestAlertTriggeredAnalyzer_OnAlertFired_EmptyResourceKey(t *testing.T) { + analyzer := NewAlertTriggeredAnalyzer(nil, nil) + analyzer.SetEnabled(true) + + // Alert with no resource identifiers + alert := &alerts.Alert{ + ID: "test-alert", + Type: "cpu", + } + + // Should skip analysis due to empty resource key + analyzer.OnAlertFired(alert) + + // Wait briefly + time.Sleep(10 * time.Millisecond) + + // No pending should exist + analyzer.mu.RLock() + pending := len(analyzer.pending) + analyzer.mu.RUnlock() + + if pending != 0 { + t.Errorf("Expected no pending analyses for empty resource key, got %d", pending) + } +} + +func TestAlertTriggeredAnalyzer_OnAlertFired_Cooldown(t *testing.T) { analyzer := NewAlertTriggeredAnalyzer(nil, nil) analyzer.SetEnabled(true) // Set a short cooldown for testing @@ -191,33 +584,7 @@ func TestAlertTriggeredAnalyzer_Cooldown(t *testing.T) { } } -func TestAlertTriggeredAnalyzer_CleanupOldCooldowns(t *testing.T) { - analyzer := NewAlertTriggeredAnalyzer(nil, nil) - - // Add some cooldown entries - one old, one recent - analyzer.mu.Lock() - analyzer.lastAnalyzed["old-resource"] = time.Now().Add(-2 * time.Hour) // > 1 hour old - analyzer.lastAnalyzed["recent-resource"] = time.Now() // Recent - analyzer.mu.Unlock() - - // Cleanup - analyzer.CleanupOldCooldowns() - - analyzer.mu.RLock() - _, oldExists := analyzer.lastAnalyzed["old-resource"] - _, recentExists := analyzer.lastAnalyzed["recent-resource"] - analyzer.mu.RUnlock() - - if oldExists { - t.Error("Expected old cooldown entry to be removed") - } - if !recentExists { - t.Error("Expected recent cooldown entry to be kept") - } -} - -func TestAlertTriggeredAnalyzer_DeduplicatePendingAnalyses(t *testing.T) { - // Create a mock state provider with basic data +func TestAlertTriggeredAnalyzer_OnAlertFired_Deduplication(t *testing.T) { stateProvider := &mockStateProvider{ state: models.StateSnapshot{ Nodes: []models.Node{ @@ -236,10 +603,6 @@ func TestAlertTriggeredAnalyzer_DeduplicatePendingAnalyses(t *testing.T) { ResourceName: "test-node", } - // Use a WaitGroup to track the analysis - var wg sync.WaitGroup - wg.Add(1) - // Manually mark as pending analyzer.mu.Lock() analyzer.pending["node-1"] = true @@ -263,119 +626,91 @@ func TestAlertTriggeredAnalyzer_DeduplicatePendingAnalyses(t *testing.T) { } } -func TestAlertTriggeredAnalyzer_AnalyzeResourceByAlert_AlertTypes(t *testing.T) { +func TestAlertTriggeredAnalyzer_AnalyzeResourceByAlert(t *testing.T) { stateProvider := &mockStateProvider{ state: models.StateSnapshot{ Nodes: []models.Node{ - {ID: "node-1", Name: "test-node", Status: "online"}, + { + ID: "node/pve1", + Name: "pve1", + Status: "online", + CPU: 0.95, + Memory: models.Memory{ + Total: 32000000000, + Used: 30000000000, + }, + }, }, VMs: []models.VM{ - {ID: "vm-100", Name: "test-vm", Node: "test-node", Status: "running"}, + { + ID: "qemu/100", + Name: "test-vm", + Node: "pve1", + Status: "running", + CPU: 0.8, + Memory: models.Memory{ + Usage: 95.0, + }, + Disk: models.Disk{ + Usage: 90.0, + }, + }, }, }, } - analyzer := NewAlertTriggeredAnalyzer(nil, stateProvider) - - // Test alert type detection - tests := []struct { - name string - alertType string - shouldRun bool - }{ - { - name: "node alert", - alertType: "node_cpu", - shouldRun: true, // Will try to analyze but no patrol service - }, - { - name: "container alert", - alertType: "container_memory", - shouldRun: true, - }, - { - name: "vm alert", - alertType: "vm_disk", - shouldRun: true, - }, - { - name: "docker alert", - alertType: "docker_cpu", - shouldRun: true, - }, - { - name: "storage alert", - alertType: "storage-usage", - shouldRun: true, - }, - { - name: "generic cpu alert", - alertType: "cpu", - shouldRun: true, - }, - { - name: "unknown alert type", - alertType: "unknown_type", - shouldRun: false, // Should not match any handler - }, + patrolService := &PatrolService{ + thresholds: DefaultPatrolThresholds(), } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - alert := &alerts.Alert{ - ID: "test-alert", - Type: tt.alertType, - ResourceID: "node-1", - ResourceName: "test-node", - } + analyzer := NewAlertTriggeredAnalyzer(patrolService, stateProvider) - // This should not panic, even without a patrol service - // analyzeResourceByAlert returns nil when patrolService is nil - findings := analyzer.analyzeResourceByAlert(nil, alert) + // Test node alert type + alertNode := &alerts.Alert{ + ID: "node-alert", + Type: "node_cpu", + ResourceID: "node/pve1", + ResourceName: "pve1", + } + findingsNode := analyzer.analyzeResourceByAlert(context.Background(), alertNode) + if len(findingsNode) == 0 { + t.Error("Expected findings for node alert, got 0") + } - // Without patrol service, findings should be nil - if findings != nil { - t.Error("Expected nil findings without patrol service") - } - }) + // Test container/VM alert type + alertVM := &alerts.Alert{ + ID: "vm-alert", + Type: "container_memory", + ResourceID: "qemu/100", + ResourceName: "test-vm", + } + findingsVM := analyzer.analyzeResourceByAlert(context.Background(), alertVM) + if len(findingsVM) == 0 { + t.Error("Expected findings for container/VM alert, got 0") + } + + // Test unknown alert type + alertUnknown := &alerts.Alert{ + ID: "unknown-alert", + Type: "unknown_type_xyz", + } + findingsUnknown := analyzer.analyzeResourceByAlert(context.Background(), alertUnknown) + if len(findingsUnknown) != 0 { + t.Errorf("Expected 0 findings for unknown alert type, got %d", len(findingsUnknown)) + } + + // Test with nil patrol service + analyzerNoPatrol := NewAlertTriggeredAnalyzer(nil, stateProvider) + findingsNilPatrol := analyzerNoPatrol.analyzeResourceByAlert(context.Background(), alertNode) + if findingsNilPatrol != nil { + t.Error("Expected nil findings when patrol service is nil") } } -func TestAlertTriggeredAnalyzer_ConcurrentAccess(t *testing.T) { - analyzer := NewAlertTriggeredAnalyzer(nil, nil) - analyzer.SetEnabled(true) - - // Test concurrent access to the analyzer - var wg sync.WaitGroup - iterations := 100 - - // Concurrent enable/disable - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < iterations; i++ { - analyzer.SetEnabled(i%2 == 0) - } - }() - - // Concurrent IsEnabled checks - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < iterations; i++ { - _ = analyzer.IsEnabled() - } - }() - - // Concurrent cleanup - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < iterations; i++ { - analyzer.CleanupOldCooldowns() - } - }() - - wg.Wait() - // Test passes if no race conditions or panics +type mockStateProvider struct { + state models.StateSnapshot +} + +func (m *mockStateProvider) GetState() models.StateSnapshot { + return m.state } diff --git a/internal/ai/exports_test.go b/internal/ai/exports_test.go new file mode 100644 index 0000000..7f486c9 --- /dev/null +++ b/internal/ai/exports_test.go @@ -0,0 +1,114 @@ +package ai + +import ( + "testing" +) + +func TestNewDefaultConfig(t *testing.T) { + cfg := NewDefaultConfig() + + if cfg == nil { + t.Fatal("Expected non-nil config") + } + + // Verify some default values + if cfg.Provider != "" && cfg.Provider != ProviderAnthropic && cfg.Provider != ProviderOpenAI && cfg.Provider != ProviderOllama && cfg.Provider != ProviderDeepSeek { + t.Errorf("Unexpected provider: %s", cfg.Provider) + } +} + +func TestDefaultBaselineConfig(t *testing.T) { + cfg := DefaultBaselineConfig() + + // Verify config has reasonable defaults + if cfg.LearningWindow <= 0 { + t.Error("Expected positive LearningWindow") + } + if cfg.MinSamples <= 0 { + t.Error("Expected positive MinSamples") + } +} + +func TestNewBaselineStore(t *testing.T) { + cfg := DefaultBaselineConfig() + store := NewBaselineStore(cfg) + + if store == nil { + t.Fatal("Expected non-nil baseline store") + } +} + +func TestDefaultPatternConfig(t *testing.T) { + cfg := DefaultPatternConfig() + + // Verify config exists (the actual struct may vary) + // Just check it doesn't panic + _ = cfg +} + +func TestNewPatternDetector(t *testing.T) { + cfg := DefaultPatternConfig() + detector := NewPatternDetector(cfg) + + if detector == nil { + t.Fatal("Expected non-nil pattern detector") + } +} + +func TestDefaultCorrelationConfig(t *testing.T) { + cfg := DefaultCorrelationConfig() + + // Verify config exists + _ = cfg +} + +func TestNewCorrelationDetector(t *testing.T) { + cfg := DefaultCorrelationConfig() + detector := NewCorrelationDetector(cfg) + + if detector == nil { + t.Fatal("Expected non-nil correlation detector") + } +} + +func TestMin(t *testing.T) { + tests := []struct { + a, b int + expected int + }{ + {1, 2, 1}, + {5, 3, 3}, + {0, 0, 0}, + {-1, 1, -1}, + {10, 10, 10}, + } + + for _, tt := range tests { + result := min(tt.a, tt.b) + if result != tt.expected { + t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected) + } + } +} + +func TestNewChangeDetector(t *testing.T) { + cfg := ChangeDetectorConfig{ + MaxChanges: 100, + } + detector := NewChangeDetector(cfg) + + if detector == nil { + t.Fatal("Expected non-nil change detector") + } +} + +func TestNewRemediationLog(t *testing.T) { + cfg := RemediationLogConfig{ + MaxRecords: 50, + } + log := NewRemediationLog(cfg) + + if log == nil { + t.Fatal("Expected non-nil remediation log") + } +} diff --git a/internal/ai/findings.go b/internal/ai/findings.go index 7c95767..958159d 100644 --- a/internal/ai/findings.go +++ b/internal/ai/findings.go @@ -65,9 +65,9 @@ type Finding struct { Suppressed bool `json:"suppressed"` // Permanently suppress similar findings for this resource } -// IsActive returns true if the finding is still active (not resolved, not snoozed, not suppressed) +// IsActive returns true if the finding is still active (not resolved, not snoozed, not suppressed, not dismissed) func (f *Finding) IsActive() bool { - return f.ResolvedAt == nil && !f.IsSnoozed() && !f.Suppressed + return f.ResolvedAt == nil && !f.IsSnoozed() && !f.Suppressed && f.DismissedReason == "" } // IsDismissed returns true if the user has dismissed this finding with a reason diff --git a/internal/ai/findings_test.go b/internal/ai/findings_test.go index c82b480..3d0b94b 100644 --- a/internal/ai/findings_test.go +++ b/internal/ai/findings_test.go @@ -1,6 +1,7 @@ package ai import ( + "strings" "testing" "time" ) @@ -174,76 +175,660 @@ func TestFindingsStore_Unsnooze(t *testing.T) { } } -func TestFindingsStore_SnoozeResolvedFinding(t *testing.T) { +func TestFindingsStore_Acknowledge(t *testing.T) { store := NewFindingsStore() - - // Add a finding - finding := &Finding{ - ID: "finding-1", - Severity: FindingSeverityWarning, - ResourceID: "res-1", - ResourceName: "test-resource", - Title: "Test Finding", - } + finding := &Finding{ID: "f1", Severity: FindingSeverityWarning, ResourceID: "r1", ResourceName: "res1", Title: "Test"} store.Add(finding) - // Resolve it - store.Resolve("finding-1", false) + if !store.Acknowledge("f1") { + t.Fatal("Acknowledge should return true") + } - // Try to snooze a resolved finding - should fail - if store.Snooze("finding-1", 1*time.Hour) { - t.Error("Should not be able to snooze a resolved finding") + f := store.Get("f1") + if f.AcknowledgedAt == nil { + t.Error("AcknowledgedAt should be set") + } + + // Double acknowledge + if !store.Acknowledge("f1") { + t.Error("Acknowledge should still return true (noop)") } } -func TestFindingsStore_SummaryConsistentWithActive(t *testing.T) { +func TestFindingsStore_Dismiss(t *testing.T) { + store := NewFindingsStore() + finding := &Finding{ID: "f1", Severity: FindingSeverityWarning, ResourceID: "r1", ResourceName: "res1", Title: "Test", Category: FindingCategoryPerformance} + store.Add(finding) + + if !store.Dismiss("f1", "not_an_issue", "Custom note") { + t.Fatal("Dismiss should return true") + } + + f := store.Get("f1") + if !f.IsDismissed() { + t.Error("Finding should be dismissed") + } + if f.DismissedReason != "not_an_issue" { + t.Errorf("Expected reason not_an_issue, got %s", f.DismissedReason) + } + if f.UserNote != "Custom note" { + t.Errorf("Expected note 'Custom note', got %s", f.UserNote) + } + + // Verify it's not active anymore + active := store.GetActive(FindingSeverityInfo) + if len(active) != 0 { + t.Error("Dismissed finding should not be active") + } +} + +func TestFindingsStore_SetUserNote(t *testing.T) { + store := NewFindingsStore() + finding := &Finding{ID: "f1", Severity: FindingSeverityWarning, ResourceID: "r1", ResourceName: "res1", Title: "Test"} + store.Add(finding) + + if !store.SetUserNote("f1", "New note") { + t.Fatal("SetUserNote should return true") + } + + f := store.Get("f1") + if f.UserNote != "New note" { + t.Errorf("Expected note 'New note', got %s", f.UserNote) + } +} + +func TestFindingsStore_Suppression(t *testing.T) { + store := NewFindingsStore() + + // Add a finding + finding := &Finding{ + ID: "f1", + Severity: FindingSeverityWarning, + ResourceID: "res-1", + ResourceName: "Resource 1", + Title: "High CPU", + Category: FindingCategoryPerformance, + } + store.Add(finding) + + // Suppress it + if !store.Suppress("f1") { + t.Fatal("Suppress should return true") + } + + // Verify it's suppressed and dismissed + f := store.Get("f1") + if !f.IsDismissed() || f.DismissedReason != "suppressed" { + t.Error("Finding should be auto-dismissed when suppressed") + } + + if !store.IsSuppressed("res-1", FindingCategoryPerformance) { + t.Error("Provider+Category should be reported as suppressed") + } + + // Add a NEW finding for the SAME resource and category + finding2 := &Finding{ + ID: "f2", + Severity: FindingSeverityWarning, + ResourceID: "res-1", + ResourceName: "Resource 1", + Title: "Another High CPU", + Category: FindingCategoryPerformance, + } + isNew := store.Add(finding2) + + if isNew { + t.Error("Expected Add to return false for suppressed finding") + } + + f2 := store.Get("f2") + if f2 != nil { + t.Error("Suppressed finding should not be stored") + } + + // Test clearing suppression + rules := store.GetSuppressionRules() + if len(rules) == 0 { + t.Fatal("Should have at least one suppression rule") + } + + if !store.DeleteSuppressionRule(rules[0].ID) { + t.Fatal("DeleteSuppressionRule should return true") + } + + if store.IsSuppressed("res-1", FindingCategoryPerformance) { + t.Error("Should no longer be suppressed after rule deletion") + } +} + +func TestFindingsStore_AddSuppressionRule(t *testing.T) { + store := NewFindingsStore() + + rule := store.AddSuppressionRule("res-1", "Res 1", FindingCategoryCapacity, "Manual suppression") + if rule == nil { + t.Fatal("AddSuppressionRule returned nil") + } + + if !store.IsSuppressed("res-1", FindingCategoryCapacity) { + t.Error("Resource+Category should be suppressed") + } + + // Verify rules list + rules := store.GetSuppressionRules() + found := false + for _, r := range rules { + if r.ID == rule.ID { + found = true + break + } + } + if !found { + t.Error("Manual rule not found in GetSuppressionRules") + } +} + +func TestFindingsStore_Cleanup(t *testing.T) { + store := NewFindingsStore() + + now := time.Now() + + // Add an active finding (should NOT be cleaned up) + store.Add(&Finding{ID: "active", Title: "Active"}) + + // Add an old resolved finding (should BE cleaned up) + resolvedOld := &Finding{ + ID: "resolved-old", + Title: "Resolved Old", + ResolvedAt: timePtr(now.Add(-48 * time.Hour)), + } + store.Add(resolvedOld) + + // Add a recent resolved finding (should NOT be cleaned up if maxAge is 24h) + resolvedRecent := &Finding{ + ID: "resolved-recent", + Title: "Resolved Recent", + ResolvedAt: timePtr(now.Add(-1 * time.Hour)), + } + store.Add(resolvedRecent) + + removed := store.Cleanup(24 * time.Hour) + if removed != 1 { + t.Errorf("Expected 1 finding removed, got %d", removed) + } + + if store.Get("resolved-old") != nil { + t.Error("resolved-old should have been removed") + } + if store.Get("active") == nil { + t.Error("active should NOT have been removed") + } + if store.Get("resolved-recent") == nil { + t.Error("resolved-recent should NOT have been removed") + } +} + +func TestFindingsStore_GetDismissedForContext(t *testing.T) { + store := NewFindingsStore() + + // Add dismissed finding + finding1 := &Finding{ + ID: "f1", + Title: "High CPU on web-1", + ResourceID: "web-1", + ResourceName: "web-1", + Category: FindingCategoryPerformance, + DismissedReason: "not_an_issue", + UserNote: "Expected during backup", + } + store.Add(finding1) + store.Dismiss("f1", "not_an_issue", "Expected during backup") + + // Add suppressed finding + finding2 := &Finding{ + ID: "f2", + Title: "Disk nearly full on db-1", + ResourceID: "db-1", + ResourceName: "db-1", + Category: FindingCategoryCapacity, + } + store.Add(finding2) + store.Suppress("f2") + + ctx := store.GetDismissedForContext() + + if !strings.Contains(ctx, "High CPU on web-1") { + t.Error("Context should contain dismissed finding title") + } + if !strings.Contains(ctx, "Disk nearly full on db-1") { + t.Error("Context should contain suppressed finding title") + } + if !strings.Contains(ctx, "Expected during backup") { + t.Error("Context should contain user note") + } + if !strings.Contains(ctx, "Permanently Suppressed") { + t.Error("Context should label suppressed findings") + } +} + +func TestFindingsStore_Persistence(t *testing.T) { + store := NewFindingsStore() + mockP := &mockPersistence{} + + err := store.SetPersistence(mockP) + if err != nil { + t.Fatalf("SetPersistence failed: %v", err) + } + + // Add a finding - should trigger save (debounced, but we can ForceSave) + store.Add(&Finding{ID: "f1", Title: "Persist me"}) + + err = store.ForceSave() + if err != nil { + t.Fatalf("ForceSave failed: %v", err) + } + + if mockP.savedCount != 1 { + t.Errorf("Expected 1 save, got %d", mockP.savedCount) + } +} + +func TestFindingsStore_Add_UpdateExisting(t *testing.T) { store := NewFindingsStore() - // Add mixed severity findings - store.Add(&Finding{ID: "f1", Severity: FindingSeverityCritical, ResourceID: "r1", ResourceName: "res1", Title: "Critical"}) - store.Add(&Finding{ID: "f2", Severity: FindingSeverityWarning, ResourceID: "r2", ResourceName: "res2", Title: "Warning"}) - store.Add(&Finding{ID: "f3", Severity: FindingSeverityWatch, ResourceID: "r3", ResourceName: "res3", Title: "Watch"}) - - // Verify initial consistency - active := store.GetActive(FindingSeverityInfo) - summary := store.GetSummary() - - if len(active) != summary.Critical+summary.Warning+summary.Watch+summary.Info { - t.Errorf("Mismatch: %d active findings, summary totals %d", len(active), summary.Critical+summary.Warning+summary.Watch+summary.Info) + // Add initial finding + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityWarning, + Title: "Initial Title", + Description: "Initial Description", + } + isNew := store.Add(f1) + if !isNew { + t.Error("Expected new finding on first add") } - // Resolve the warning finding - store.Resolve("f2", false) - - // Verify consistency after resolution - active = store.GetActive(FindingSeverityInfo) - summary = store.GetSummary() - - if len(active) != 2 { - t.Fatalf("Expected 2 active findings after resolution, got %d", len(active)) + // Add same finding again (should update, not create new) + f1Updated := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityWarning, + Title: "Updated Title", + Description: "Updated Description", + } + isNew = store.Add(f1Updated) + if isNew { + t.Error("Expected update, not new finding") } - if summary.Warning != 0 { - t.Errorf("Summary shows %d warnings but should be 0 after resolution", summary.Warning) + // Verify update happened + stored := store.Get("f1") + if stored.Title != "Updated Title" { + t.Errorf("Expected 'Updated Title', got %s", stored.Title) + } + if stored.TimesRaised != 1 { + t.Errorf("Expected TimesRaised=1, got %d", stored.TimesRaised) + } +} + +func TestFindingsStore_Add_SeverityEscalation(t *testing.T) { + store := NewFindingsStore() + + // Add and dismiss a warning finding + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityWarning, + Title: "Warning", + } + store.Add(f1) + store.Dismiss("f1", "not_an_issue", "It's fine") + + // Verify it's dismissed + dismissed := store.Get("f1") + if !dismissed.IsDismissed() { + t.Fatal("Finding should be dismissed") } - if summary.Critical != 1 { - t.Errorf("Summary shows %d critical but should be 1", summary.Critical) + // Now add the same finding with HIGHER severity (critical) + f1Escalated := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityCritical, + Title: "Now Critical!", + } + store.Add(f1Escalated) + + // Severity escalation should clear the dismissal + reactivated := store.Get("f1") + if reactivated.IsDismissed() { + t.Error("Finding should be reactivated after severity escalation") + } + if reactivated.Severity != FindingSeverityCritical { + t.Error("Severity should be updated to critical") + } +} + +func TestFindingsStore_Add_SuppressedExisting(t *testing.T) { + store := NewFindingsStore() + + // Add and suppress a finding + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityWarning, + Title: "Suppressed Finding", + Category: FindingCategoryPerformance, + } + store.Add(f1) + store.Suppress("f1") + + // Verify it's suppressed + suppressed := store.Get("f1") + if !suppressed.Suppressed { + t.Fatal("Finding should be suppressed") } - // Snooze the critical finding - store.Snooze("f1", 1*time.Hour) + // Try to add the same finding again + f1Updated := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityCritical, + Title: "Updated Title", + Category: FindingCategoryPerformance, + } + isNew := store.Add(f1Updated) - // Verify consistency after snooze - active = store.GetActive(FindingSeverityInfo) - summary = store.GetSummary() - - if len(active) != 1 { - t.Errorf("Expected 1 active finding after snooze, got %d", len(active)) + // Should not update suppressed finding + if isNew { + t.Error("Suppressed finding should not be updated") } - if summary.Critical != 0 { - t.Errorf("Summary shows %d critical but should be 0 after snooze", summary.Critical) + // Verify the finding wasn't updated + stillSuppressed := store.Get("f1") + if stillSuppressed.Title != "Suppressed Finding" { + t.Error("Suppressed finding title should not change") + } +} + +func TestFindingsStore_Add_DismissedSameSeverity(t *testing.T) { + store := NewFindingsStore() + + // Add and dismiss a finding + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityWarning, + Title: "Warning", + } + store.Add(f1) + store.Dismiss("f1", "expected_behavior", "Known issue") + + // Add same finding with SAME severity + f1Same := &Finding{ + ID: "f1", + ResourceID: "res-1", + Severity: FindingSeverityWarning, + Title: "Still Warning", + } + store.Add(f1Same) + + // Should stay dismissed (same severity doesn't reactivate) + stillDismissed := store.Get("f1") + if !stillDismissed.IsDismissed() { + t.Error("Finding should stay dismissed with same severity") + } + // But TimesRaised should increment + if stillDismissed.TimesRaised < 1 { + t.Error("TimesRaised should increment even for dismissed findings") + } +} + +func TestFindingsStore_GetByResource(t *testing.T) { + store := NewFindingsStore() + + // Add findings for different resources + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + ResourceName: "Resource 1", + Severity: FindingSeverityWarning, + Title: "Finding 1", + } + f2 := &Finding{ + ID: "f2", + ResourceID: "res-1", + ResourceName: "Resource 1", + Severity: FindingSeverityCritical, + Title: "Finding 2", + } + f3 := &Finding{ + ID: "f3", + ResourceID: "res-2", + ResourceName: "Resource 2", + Severity: FindingSeverityWarning, + Title: "Finding 3", + } + + store.Add(f1) + store.Add(f2) + store.Add(f3) + + // Get findings for res-1 + res1Findings := store.GetByResource("res-1") + if len(res1Findings) != 2 { + t.Errorf("Expected 2 findings for res-1, got %d", len(res1Findings)) + } + + // Get findings for res-2 + res2Findings := store.GetByResource("res-2") + if len(res2Findings) != 1 { + t.Errorf("Expected 1 finding for res-2, got %d", len(res2Findings)) + } + + // Get findings for non-existent resource + noFindings := store.GetByResource("non-existent") + if len(noFindings) != 0 { + t.Errorf("Expected 0 findings for non-existent resource, got %d", len(noFindings)) + } + + // Resolve one finding and verify it's excluded from GetByResource + store.Resolve("f1", false) + res1FindingsAfterResolve := store.GetByResource("res-1") + if len(res1FindingsAfterResolve) != 1 { + t.Errorf("Expected 1 active finding for res-1 after resolve, got %d", len(res1FindingsAfterResolve)) + } +} + +func TestFindingsStore_GetAll(t *testing.T) { + store := NewFindingsStore() + + now := time.Now() + + // Add findings at different times + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + Title: "Finding 1", + DetectedAt: now.Add(-48 * time.Hour), + } + f2 := &Finding{ + ID: "f2", + ResourceID: "res-1", + Title: "Finding 2", + DetectedAt: now.Add(-12 * time.Hour), + } + f3 := &Finding{ + ID: "f3", + ResourceID: "res-2", + Title: "Finding 3", + DetectedAt: now.Add(-1 * time.Hour), + } + + store.Add(f1) + store.Add(f2) + store.Add(f3) + + // Get all findings without filter + allFindings := store.GetAll(nil) + if len(allFindings) != 3 { + t.Errorf("Expected 3 findings, got %d", len(allFindings)) + } + + // Get findings after 24 hours ago + startTime := now.Add(-24 * time.Hour) + recentFindings := store.GetAll(&startTime) + if len(recentFindings) != 2 { + t.Errorf("Expected 2 findings after 24h ago, got %d", len(recentFindings)) + } + + // Resolve a finding and verify it's still in GetAll (includes resolved) + store.Resolve("f1", false) + allAfterResolve := store.GetAll(nil) + if len(allAfterResolve) != 3 { + t.Errorf("Expected 3 findings (including resolved), got %d", len(allAfterResolve)) + } +} + +type mockPersistence struct { + savedCount int +} + +func (m *mockPersistence) SaveFindings(findings map[string]*Finding) error { + m.savedCount++ + return nil +} + +func (m *mockPersistence) LoadFindings() (map[string]*Finding, error) { + return make(map[string]*Finding), nil +} + +func TestFindingsSummary_HasIssues(t *testing.T) { + tests := []struct { + name string + summary FindingsSummary + expected bool + }{ + { + name: "no issues", + summary: FindingsSummary{Info: 1, Watch: 1}, + expected: false, + }, + { + name: "has warning", + summary: FindingsSummary{Warning: 1}, + expected: true, + }, + { + name: "has critical", + summary: FindingsSummary{Critical: 1}, + expected: true, + }, + { + name: "has both", + summary: FindingsSummary{Critical: 2, Warning: 3}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.summary.HasIssues(); got != tt.expected { + t.Errorf("HasIssues() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestFindingsStore_GetDismissedFindings(t *testing.T) { + store := NewFindingsStore() + + // Add normal finding + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + Title: "Normal Finding", + Severity: FindingSeverityWarning, + } + store.Add(f1) + + // Add dismissed finding + f2 := &Finding{ + ID: "f2", + ResourceID: "res-2", + Title: "Dismissed Finding", + Severity: FindingSeverityWarning, + } + store.Add(f2) + store.Dismiss("f2", "not_an_issue", "Ignore this") + + // Add suppressed finding + f3 := &Finding{ + ID: "f3", + ResourceID: "res-3", + Title: "Suppressed Finding", + Severity: FindingSeverityWarning, + } + store.Add(f3) + store.Suppress("f3") + + dismissed := store.GetDismissedFindings() + if len(dismissed) != 2 { + t.Errorf("Expected 2 dismissed/suppressed findings, got %d", len(dismissed)) + } + + // Verify the dismissed findings are the right ones + dismissedIDs := make(map[string]bool) + for _, f := range dismissed { + dismissedIDs[f.ID] = true + } + + if !dismissedIDs["f2"] { + t.Error("Expected f2 to be in dismissed findings") + } + if !dismissedIDs["f3"] { + t.Error("Expected f3 to be in dismissed findings") + } + if dismissedIDs["f1"] { + t.Error("f1 should NOT be in dismissed findings") + } +} + +func TestFindingsStore_MatchesSuppressionRule(t *testing.T) { + store := NewFindingsStore() + + // Add a suppression rule for specific resource+category + store.AddSuppressionRule("res-1", "Resource 1", FindingCategoryPerformance, "Known issue") + + // Should match + if !store.MatchesSuppressionRule("res-1", FindingCategoryPerformance) { + t.Error("Should match exact resource+category") + } + + // Should not match different resource + if store.MatchesSuppressionRule("res-2", FindingCategoryPerformance) { + t.Error("Should NOT match different resource") + } + + // Should not match different category + if store.MatchesSuppressionRule("res-1", FindingCategoryCapacity) { + t.Error("Should NOT match different category") + } + + // Add a wildcard rule (any resource for capacity) + store.AddSuppressionRule("", "Any", FindingCategoryCapacity, "All capacity") + + // Should match any resource for capacity + if !store.MatchesSuppressionRule("any-resource", FindingCategoryCapacity) { + t.Error("Wildcard resource rule should match any resource") + } + + // Add a wildcard rule (res-3 for any category) + store.AddSuppressionRule("res-3", "Resource 3", "", "All categories for res-3") + + // Should match res-3 for any category + if !store.MatchesSuppressionRule("res-3", FindingCategoryReliability) { + t.Error("Wildcard category rule should match any category for that resource") } } diff --git a/internal/ai/mock_test.go b/internal/ai/mock_test.go new file mode 100644 index 0000000..69f2bea --- /dev/null +++ b/internal/ai/mock_test.go @@ -0,0 +1,179 @@ +package ai + +import ( + "context" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/resources" +) + +// mockProvider implements providers.Provider for testing +type mockProvider struct { + chatFunc func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) + testConnectionFunc func(ctx context.Context) error + nameFunc func() string + listModelsFunc func(ctx context.Context) ([]providers.ModelInfo, error) +} + +func (m *mockProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + if m.chatFunc != nil { + return m.chatFunc(ctx, req) + } + return &providers.ChatResponse{Content: "Default mock response"}, nil +} + +func (m *mockProvider) TestConnection(ctx context.Context) error { + if m.testConnectionFunc != nil { + return m.testConnectionFunc(ctx) + } + return nil +} + +func (m *mockProvider) Name() string { + if m.nameFunc != nil { + return m.nameFunc() + } + return "mock" +} + +func (m *mockProvider) ListModels(ctx context.Context) ([]providers.ModelInfo, error) { + if m.listModelsFunc != nil { + return m.listModelsFunc(ctx) + } + return nil, nil +} + +// mockThresholdProvider implements patrol.ThresholdProvider for testing +type mockThresholdProvider struct { + nodeCPU float64 + nodeMemory float64 + guestMem float64 + guestDisk float64 + storage float64 +} + +func (m *mockThresholdProvider) GetNodeCPUThreshold() float64 { return m.nodeCPU } +func (m *mockThresholdProvider) GetNodeMemoryThreshold() float64 { return m.nodeMemory } +func (m *mockThresholdProvider) GetGuestCPUThreshold() float64 { return 0 } +func (m *mockThresholdProvider) GetGuestMemoryThreshold() float64 { return m.guestMem } +func (m *mockThresholdProvider) GetGuestDiskThreshold() float64 { return m.guestDisk } +func (m *mockThresholdProvider) GetStorageThreshold() float64 { return m.storage } + +type mockResourceProvider struct { + ResourceProvider + getAllFunc func() []resources.Resource + getStatsFunc func() resources.StoreStats + getSummaryFunc func() resources.ResourceSummary + getInfrastructureFunc func() []resources.Resource + getWorkloadsFunc func() []resources.Resource +} + +func (m *mockResourceProvider) GetAll() []resources.Resource { + if m.getAllFunc != nil { + return m.getAllFunc() + } + return nil +} +func (m *mockResourceProvider) GetStats() resources.StoreStats { + if m.getStatsFunc != nil { + return m.getStatsFunc() + } + return resources.StoreStats{} +} +func (m *mockResourceProvider) GetResourceSummary() resources.ResourceSummary { + if m.getSummaryFunc != nil { + return m.getSummaryFunc() + } + return resources.ResourceSummary{} +} +func (m *mockResourceProvider) GetInfrastructure() []resources.Resource { + if m.getInfrastructureFunc != nil { + return m.getInfrastructureFunc() + } + return nil +} +func (m *mockResourceProvider) GetWorkloads() []resources.Resource { + if m.getWorkloadsFunc != nil { + return m.getWorkloadsFunc() + } + return nil +} +func (m *mockResourceProvider) GetType(t resources.ResourceType) []resources.Resource { return nil } +func (m *mockResourceProvider) GetTopByCPU(limit int, types []resources.ResourceType) []resources.Resource { + return nil +} +func (m *mockResourceProvider) GetTopByMemory(limit int, types []resources.ResourceType) []resources.Resource { + return nil +} +func (m *mockResourceProvider) GetTopByDisk(limit int, types []resources.ResourceType) []resources.Resource { + return nil +} +func (m *mockResourceProvider) GetRelated(resourceID string) map[string][]resources.Resource { + return nil +} +func (m *mockResourceProvider) FindContainerHost(containerNameOrID string) string { return "" } + +type mockAgentServer struct { + agents []agentexec.ConnectedAgent + executeFunc func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) +} + +func (m *mockAgentServer) GetConnectedAgents() []agentexec.ConnectedAgent { + return m.agents +} + +func (m *mockAgentServer) ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + if m.executeFunc != nil { + return m.executeFunc(ctx, agentID, cmd) + } + return &agentexec.CommandResultPayload{Success: true, Stdout: "Mock output"}, nil +} + +type mockPolicy struct { + decision agentexec.PolicyDecision +} + +func (m *mockPolicy) Evaluate(command string) agentexec.PolicyDecision { + return m.decision +} + +type mockMetadataProvider struct { + lastGuestID string + lastGuestURL string + lastDockerID string + lastDockerURL string + lastHostID string + lastHostURL string +} + +func (m *mockMetadataProvider) SetGuestURL(id, url string) error { + m.lastGuestID = id + m.lastGuestURL = url + return nil +} + +func (m *mockMetadataProvider) SetDockerURL(id, url string) error { + m.lastDockerID = id + m.lastDockerURL = url + return nil +} + +func (m *mockMetadataProvider) SetHostURL(id, url string) error { + m.lastHostID = id + m.lastHostURL = url + return nil +} + +type mockLicenseStore struct { + features map[string]bool + state string + valid bool +} + +func (m *mockLicenseStore) HasFeature(feature string) bool { + return m.features[feature] +} + +func (m *mockLicenseStore) GetLicenseStateString() (string, bool) { + return m.state, m.valid +} diff --git a/internal/ai/patrol_history_persistence_test.go b/internal/ai/patrol_history_persistence_test.go new file mode 100644 index 0000000..ccc4110 --- /dev/null +++ b/internal/ai/patrol_history_persistence_test.go @@ -0,0 +1,290 @@ +package ai + +import ( + "testing" + "time" +) + +// mockPatrolHistoryPersistence implements PatrolHistoryPersistence for testing +type mockPatrolHistoryPersistence struct { + runs []PatrolRunRecord + saveErr error + loadErr error + saveCalls int + loadCalls int +} + +func (m *mockPatrolHistoryPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error { + m.saveCalls++ + if m.saveErr != nil { + return m.saveErr + } + m.runs = runs + return nil +} + +func (m *mockPatrolHistoryPersistence) LoadPatrolRunHistory() ([]PatrolRunRecord, error) { + m.loadCalls++ + if m.loadErr != nil { + return nil, m.loadErr + } + return m.runs, nil +} + +func TestNewPatrolRunHistoryStore(t *testing.T) { + // Test with positive maxRuns + store := NewPatrolRunHistoryStore(50) + if store == nil { + t.Fatal("Expected non-nil store") + } + if store.maxRuns != 50 { + t.Errorf("Expected maxRuns=50, got %d", store.maxRuns) + } + + // Test with zero maxRuns (should use default) + storeDefault := NewPatrolRunHistoryStore(0) + if storeDefault.maxRuns != MaxPatrolRunHistory { + t.Errorf("Expected maxRuns=%d (default), got %d", MaxPatrolRunHistory, storeDefault.maxRuns) + } + + // Test with negative maxRuns (should use default) + storeNegative := NewPatrolRunHistoryStore(-10) + if storeNegative.maxRuns != MaxPatrolRunHistory { + t.Errorf("Expected maxRuns=%d (default), got %d", MaxPatrolRunHistory, storeNegative.maxRuns) + } +} + +func TestPatrolRunHistoryStore_Add(t *testing.T) { + store := NewPatrolRunHistoryStore(3) + + run1 := PatrolRunRecord{ID: "run-1", StartedAt: time.Now()} + run2 := PatrolRunRecord{ID: "run-2", StartedAt: time.Now()} + run3 := PatrolRunRecord{ID: "run-3", StartedAt: time.Now()} + run4 := PatrolRunRecord{ID: "run-4", StartedAt: time.Now()} + + store.Add(run1) + if store.Count() != 1 { + t.Errorf("Expected count=1, got %d", store.Count()) + } + + store.Add(run2) + store.Add(run3) + if store.Count() != 3 { + t.Errorf("Expected count=3, got %d", store.Count()) + } + + // Adding 4th run should trim to maxRuns + store.Add(run4) + if store.Count() != 3 { + t.Errorf("Expected count=3 (trimmed), got %d", store.Count()) + } + + // Newest should be first + runs := store.GetAll() + if runs[0].ID != "run-4" { + t.Errorf("Expected newest run first, got %s", runs[0].ID) + } +} + +func TestPatrolRunHistoryStore_GetAll(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + // Empty store + runs := store.GetAll() + if len(runs) != 0 { + t.Errorf("Expected empty slice, got %d runs", len(runs)) + } + + // Add runs + store.Add(PatrolRunRecord{ID: "run-1"}) + store.Add(PatrolRunRecord{ID: "run-2"}) + + runs = store.GetAll() + if len(runs) != 2 { + t.Errorf("Expected 2 runs, got %d", len(runs)) + } + + // Verify it returns a copy (modification shouldn't affect store) + runs[0].ID = "modified" + storedRuns := store.GetAll() + if storedRuns[0].ID == "modified" { + t.Error("GetAll should return a copy, not the original slice") + } +} + +func TestPatrolRunHistoryStore_GetRecent(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + store.Add(PatrolRunRecord{ID: "run-1"}) + store.Add(PatrolRunRecord{ID: "run-2"}) + store.Add(PatrolRunRecord{ID: "run-3"}) + + // Get 2 recent + runs := store.GetRecent(2) + if len(runs) != 2 { + t.Errorf("Expected 2 runs, got %d", len(runs)) + } + if runs[0].ID != "run-3" { + t.Errorf("Expected run-3 first, got %s", runs[0].ID) + } + + // Get more than available + runsAll := store.GetRecent(10) + if len(runsAll) != 3 { + t.Errorf("Expected 3 runs (all available), got %d", len(runsAll)) + } + + // Get 0 or negative + runsZero := store.GetRecent(0) + if len(runsZero) != 3 { + t.Errorf("Expected 3 runs for n=0, got %d", len(runsZero)) + } + + runsNeg := store.GetRecent(-5) + if len(runsNeg) != 3 { + t.Errorf("Expected 3 runs for n=-5, got %d", len(runsNeg)) + } +} + +func TestPatrolRunHistoryStore_Count(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + if store.Count() != 0 { + t.Errorf("Expected count=0, got %d", store.Count()) + } + + store.Add(PatrolRunRecord{ID: "run-1"}) + if store.Count() != 1 { + t.Errorf("Expected count=1, got %d", store.Count()) + } + + store.Add(PatrolRunRecord{ID: "run-2"}) + store.Add(PatrolRunRecord{ID: "run-3"}) + if store.Count() != 3 { + t.Errorf("Expected count=3, got %d", store.Count()) + } +} + +func TestPatrolRunHistoryStore_SetPersistence(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + // Create mock with existing runs + mockPersistence := &mockPatrolHistoryPersistence{ + runs: []PatrolRunRecord{ + {ID: "persisted-1"}, + {ID: "persisted-2"}, + }, + } + + err := store.SetPersistence(mockPersistence) + if err != nil { + t.Fatalf("SetPersistence failed: %v", err) + } + + // Should have loaded runs + if store.Count() != 2 { + t.Errorf("Expected 2 runs loaded, got %d", store.Count()) + } + + runs := store.GetAll() + if runs[0].ID != "persisted-1" { + t.Errorf("Expected persisted-1, got %s", runs[0].ID) + } +} + +func TestPatrolRunHistoryStore_SetPersistence_Nil(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + // Setting nil persistence should not error + err := store.SetPersistence(nil) + if err != nil { + t.Fatalf("SetPersistence(nil) should not error: %v", err) + } +} + +func TestPatrolRunHistoryStore_SetPersistence_TrimToMax(t *testing.T) { + store := NewPatrolRunHistoryStore(2) // Only allow 2 runs + + // Create mock with more runs than maxRuns + mockPersistence := &mockPatrolHistoryPersistence{ + runs: []PatrolRunRecord{ + {ID: "run-1"}, + {ID: "run-2"}, + {ID: "run-3"}, + {ID: "run-4"}, + }, + } + + err := store.SetPersistence(mockPersistence) + if err != nil { + t.Fatalf("SetPersistence failed: %v", err) + } + + // Should have trimmed to max + if store.Count() != 2 { + t.Errorf("Expected 2 runs (trimmed), got %d", store.Count()) + } +} + +func TestPatrolRunHistoryStore_FlushPersistence(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + mockPersistence := &mockPatrolHistoryPersistence{} + store.SetPersistence(mockPersistence) + + store.Add(PatrolRunRecord{ID: "run-1"}) + store.Add(PatrolRunRecord{ID: "run-2"}) + + err := store.FlushPersistence() + if err != nil { + t.Fatalf("FlushPersistence failed: %v", err) + } + + // Should have saved + if len(mockPersistence.runs) != 2 { + t.Errorf("Expected 2 runs saved, got %d", len(mockPersistence.runs)) + } +} + +func TestPatrolRunHistoryStore_FlushPersistence_NoPersistence(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + store.Add(PatrolRunRecord{ID: "run-1"}) + + // Should not error when no persistence is set + err := store.FlushPersistence() + if err != nil { + t.Fatalf("FlushPersistence without persistence should not error: %v", err) + } +} + +func TestPatrolRunHistoryStore_ScheduleSave(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + store.saveDebounce = 50 * time.Millisecond // Short debounce for testing + + mockPersistence := &mockPatrolHistoryPersistence{} + store.SetPersistence(mockPersistence) + mockPersistence.saveCalls = 0 // Reset after SetPersistence load + + // Add a run (triggers scheduleSave) + store.Add(PatrolRunRecord{ID: "run-1"}) + + // Wait for debounce + time.Sleep(100 * time.Millisecond) + + if mockPersistence.saveCalls < 1 { + t.Errorf("Expected at least 1 save call after debounce, got %d", mockPersistence.saveCalls) + } +} + +func TestPatrolRunHistoryStore_ScheduleSave_NoPersistence(t *testing.T) { + store := NewPatrolRunHistoryStore(10) + + // Add without persistence - should not panic + store.Add(PatrolRunRecord{ID: "run-1"}) + + // Give time for any potential async operation + time.Sleep(10 * time.Millisecond) + + // No error or panic is success +} diff --git a/internal/ai/patrol_test.go b/internal/ai/patrol_test.go index 1757e24..30f28e9 100644 --- a/internal/ai/patrol_test.go +++ b/internal/ai/patrol_test.go @@ -5,20 +5,6 @@ import ( "time" ) -// mockThresholdProvider implements ThresholdProvider for testing -type mockThresholdProvider struct { - nodeCPU float64 - nodeMemory float64 - guestMem float64 - guestDisk float64 - storage float64 -} - -func (m *mockThresholdProvider) GetNodeCPUThreshold() float64 { return m.nodeCPU } -func (m *mockThresholdProvider) GetNodeMemoryThreshold() float64 { return m.nodeMemory } -func (m *mockThresholdProvider) GetGuestMemoryThreshold() float64 { return m.guestMem } -func (m *mockThresholdProvider) GetGuestDiskThreshold() float64 { return m.guestDisk } -func (m *mockThresholdProvider) GetStorageThreshold() float64 { return m.storage } func TestDefaultPatrolThresholds(t *testing.T) { thresholds := DefaultPatrolThresholds() @@ -472,3 +458,496 @@ func TestPatrolStatus_Fields(t *testing.T) { t.Errorf("Expected interval 900000ms, got %d", status.IntervalMs) } } + +func TestFormatDurationPatrol(t *testing.T) { + tests := []struct { + input time.Duration + expected string + }{ + {30 * time.Minute, "30m"}, + {59 * time.Minute, "59m"}, + {60 * time.Minute, "1h"}, + {90 * time.Minute, "1h"}, // Less than 24h, shows hours + {2 * time.Hour, "2h"}, + {23 * time.Hour, "23h"}, + {24 * time.Hour, "1d"}, + {48 * time.Hour, "2d"}, + {7 * 24 * time.Hour, "7d"}, + } + + for _, tt := range tests { + result := formatDurationPatrol(tt.input) + if result != tt.expected { + t.Errorf("formatDurationPatrol(%v) = %s, want %s", tt.input, result, tt.expected) + } + } +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + input uint64 + expected string + }{ + {0, "0 B"}, + {100, "100 B"}, + {1023, "1023 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1048576, "1.0 MB"}, + {1073741824, "1.0 GB"}, + {1099511627776, "1.0 TB"}, + } + + for _, tt := range tests { + result := formatBytes(tt.input) + if result != tt.expected { + t.Errorf("formatBytes(%d) = %s, want %s", tt.input, result, tt.expected) + } + } +} + +func TestFormatBytesInt64(t *testing.T) { + tests := []struct { + input int64 + expected string + }{ + {-100, "0 B"}, // Negative values return "0 B" + {0, "0 B"}, + {1024, "1.0 KB"}, + {1073741824, "1.0 GB"}, + } + + for _, tt := range tests { + result := formatBytesInt64(tt.input) + if result != tt.expected { + t.Errorf("formatBytesInt64(%d) = %s, want %s", tt.input, result, tt.expected) + } + } +} + +func TestPatrolService_ParseAIFindings(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Test with valid findings + response := `Here's my analysis: + +[FINDING] +SEVERITY: warning +CATEGORY: performance +RESOURCE: vm-100 +RESOURCE_TYPE: vm +TITLE: High CPU usage +DESCRIPTION: VM is running at 95% CPU for extended period +RECOMMENDATION: Consider adding more vCPUs +EVIDENCE: CPU: 95% +[/FINDING] + +[FINDING] +SEVERITY: critical +CATEGORY: reliability +RESOURCE: node-1 +RESOURCE_TYPE: node +TITLE: Node offline +DESCRIPTION: Node is not responding to health checks +RECOMMENDATION: Check network connectivity +EVIDENCE: Status: offline +[/FINDING] + +Everything else looks good.` + + findings := ps.parseAIFindings(response) + + if len(findings) != 2 { + t.Errorf("Expected 2 findings, got %d", len(findings)) + } + + if len(findings) >= 1 { + if findings[0].Title != "High CPU usage" { + t.Errorf("Expected title 'High CPU usage', got '%s'", findings[0].Title) + } + if findings[0].Severity != FindingSeverityWarning { + t.Errorf("Expected severity warning, got %v", findings[0].Severity) + } + } + + if len(findings) >= 2 { + if findings[1].Title != "Node offline" { + t.Errorf("Expected title 'Node offline', got '%s'", findings[1].Title) + } + if findings[1].Severity != FindingSeverityCritical { + t.Errorf("Expected severity critical, got %v", findings[1].Severity) + } + } +} + +func TestPatrolService_ParseAIFindings_NoFindings(t *testing.T) { + ps := NewPatrolService(nil, nil) + + response := `Everything looks healthy. No issues detected.` + + findings := ps.parseAIFindings(response) + + if len(findings) != 0 { + t.Errorf("Expected 0 findings, got %d", len(findings)) + } +} + +func TestPatrolService_ParseFindingBlock(t *testing.T) { + ps := NewPatrolService(nil, nil) + + block := ` +SEVERITY: warning +CATEGORY: capacity +RESOURCE: storage-1 +RESOURCE_TYPE: storage +TITLE: Storage filling up +DESCRIPTION: Storage is at 90% capacity +RECOMMENDATION: Clean up old backups +EVIDENCE: Usage: 90% +` + + finding := ps.parseFindingBlock(block) + + if finding == nil { + t.Fatal("Expected non-nil finding") + } + if finding.Severity != FindingSeverityWarning { + t.Errorf("Expected severity warning, got %v", finding.Severity) + } + if finding.Category != FindingCategoryCapacity { + t.Errorf("Expected category capacity, got %v", finding.Category) + } + if finding.Title != "Storage filling up" { + t.Errorf("Expected title 'Storage filling up', got '%s'", finding.Title) + } + if finding.ResourceID != "storage-1" { + t.Errorf("Expected resource 'storage-1', got '%s'", finding.ResourceID) + } +} + +func TestPatrolService_ParseFindingBlock_MissingRequiredFields(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Missing title and description + block := ` +SEVERITY: warning +CATEGORY: capacity +RESOURCE: storage-1 +` + + finding := ps.parseFindingBlock(block) + + if finding != nil { + t.Error("Expected nil finding when required fields are missing") + } +} + +func TestPatrolService_ParseFindingBlock_AllSeverities(t *testing.T) { + ps := NewPatrolService(nil, nil) + + tests := []struct { + severity string + expected FindingSeverity + }{ + {"critical", FindingSeverityCritical}, + {"warning", FindingSeverityWarning}, + {"watch", FindingSeverityWatch}, + {"info", FindingSeverityInfo}, + {"unknown", FindingSeverityInfo}, // Unknown defaults to info + } + + for _, tt := range tests { + block := "SEVERITY: " + tt.severity + "\nTITLE: Test\nDESCRIPTION: Test description" + finding := ps.parseFindingBlock(block) + if finding == nil { + t.Fatalf("Expected finding for severity %s", tt.severity) + } + if finding.Severity != tt.expected { + t.Errorf("Severity %s: expected %v, got %v", tt.severity, tt.expected, finding.Severity) + } + } +} + +func TestPatrolService_ParseFindingBlock_AllCategories(t *testing.T) { + ps := NewPatrolService(nil, nil) + + tests := []struct { + category string + expected FindingCategory + }{ + {"performance", FindingCategoryPerformance}, + {"reliability", FindingCategoryReliability}, + {"security", FindingCategorySecurity}, + {"capacity", FindingCategoryCapacity}, + {"configuration", FindingCategoryGeneral}, + {"unknown", FindingCategoryPerformance}, // Unknown defaults to performance + } + + for _, tt := range tests { + block := "CATEGORY: " + tt.category + "\nTITLE: Test\nDESCRIPTION: Test description" + finding := ps.parseFindingBlock(block) + if finding == nil { + t.Fatalf("Expected finding for category %s", tt.category) + } + if finding.Category != tt.expected { + t.Errorf("Category %s: expected %v, got %v", tt.category, tt.expected, finding.Category) + } + } +} + +func TestPatrolService_GetFindingsForResource(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Add findings for specific resources + f1 := &Finding{ + ID: "f1", + ResourceID: "res-1", + ResourceName: "Resource 1", + Severity: FindingSeverityWarning, + Title: "Finding 1", + } + f2 := &Finding{ + ID: "f2", + ResourceID: "res-1", + ResourceName: "Resource 1", + Severity: FindingSeverityCritical, + Title: "Finding 2", + } + f3 := &Finding{ + ID: "f3", + ResourceID: "res-2", + ResourceName: "Resource 2", + Severity: FindingSeverityWarning, + Title: "Finding 3", + } + + ps.findings.Add(f1) + ps.findings.Add(f2) + ps.findings.Add(f3) + + // Get findings for res-1 + res1Findings := ps.GetFindingsForResource("res-1") + if len(res1Findings) != 2 { + t.Errorf("Expected 2 findings for res-1, got %d", len(res1Findings)) + } + + // Get findings for res-2 + res2Findings := ps.GetFindingsForResource("res-2") + if len(res2Findings) != 1 { + t.Errorf("Expected 1 finding for res-2, got %d", len(res2Findings)) + } +} + +func TestPatrolService_GetFindingsSummary(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Add findings + ps.findings.Add(&Finding{ID: "f1", Severity: FindingSeverityCritical, Title: "Critical"}) + ps.findings.Add(&Finding{ID: "f2", Severity: FindingSeverityWarning, Title: "Warning"}) + ps.findings.Add(&Finding{ID: "f3", Severity: FindingSeverityWatch, Title: "Watch"}) + + summary := ps.GetFindingsSummary() + + if summary.Critical != 1 { + t.Errorf("Expected 1 critical, got %d", summary.Critical) + } + if summary.Warning != 1 { + t.Errorf("Expected 1 warning, got %d", summary.Warning) + } + if summary.Watch != 1 { + t.Errorf("Expected 1 watch, got %d", summary.Watch) + } +} + +func TestPatrolService_GetRunHistory(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Add some run records + ps.runHistoryStore.Add(PatrolRunRecord{ID: "run-1", Status: "completed"}) + ps.runHistoryStore.Add(PatrolRunRecord{ID: "run-2", Status: "completed"}) + ps.runHistoryStore.Add(PatrolRunRecord{ID: "run-3", Status: "completed"}) + + // Get all + allRuns := ps.GetRunHistory(0) + if len(allRuns) != 3 { + t.Errorf("Expected 3 runs, got %d", len(allRuns)) + } + + // Get limited + limitedRuns := ps.GetRunHistory(2) + if len(limitedRuns) != 2 { + t.Errorf("Expected 2 runs (limited), got %d", len(limitedRuns)) + } +} + +func TestPatrolService_GetPatternDetector(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Initially nil + if ps.GetPatternDetector() != nil { + t.Error("Expected nil PatternDetector initially") + } + + // Set pattern detector + detector := NewPatternDetector(DefaultPatternConfig()) + ps.SetPatternDetector(detector) + + if ps.GetPatternDetector() != detector { + t.Error("Expected GetPatternDetector to return the set detector") + } +} + +func TestPatrolService_GetCorrelationDetector(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Initially nil + if ps.GetCorrelationDetector() != nil { + t.Error("Expected nil CorrelationDetector initially") + } + + // Set correlation detector + detector := NewCorrelationDetector(DefaultCorrelationConfig()) + ps.SetCorrelationDetector(detector) + + if ps.GetCorrelationDetector() != detector { + t.Error("Expected GetCorrelationDetector to return the set detector") + } +} + +func TestPatrolService_GetBaselineStore(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Initially nil + if ps.GetBaselineStore() != nil { + t.Error("Expected nil BaselineStore initially") + } + + // Set baseline store + store := NewBaselineStore(DefaultBaselineConfig()) + ps.SetBaselineStore(store) + + if ps.GetBaselineStore() != store { + t.Error("Expected GetBaselineStore to return the set store") + } +} + +func TestPatrolService_SetMetricsHistoryProvider(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Set a nil provider (should not panic) + ps.SetMetricsHistoryProvider(nil) + + // Verify it was set (field is internal, just checking no panic) +} + +func TestJoinParts(t *testing.T) { + tests := []struct { + input []string + expected string + }{ + {[]string{}, ""}, + {[]string{"one"}, "one"}, + {[]string{"one", "two"}, "one and two"}, + {[]string{"one", "two", "three"}, "[one two], and three"}, + } + + for _, tt := range tests { + result := joinParts(tt.input) + if result != tt.expected { + t.Errorf("joinParts(%v) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestPatrolService_GetAllFindings(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Add findings with different severities + ps.findings.Add(&Finding{ + ID: "f1", + Severity: FindingSeverityInfo, + Title: "Info finding", + DetectedAt: time.Now().Add(-3 * time.Hour), + }) + ps.findings.Add(&Finding{ + ID: "f2", + Severity: FindingSeverityCritical, + Title: "Critical finding", + DetectedAt: time.Now().Add(-1 * time.Hour), + }) + ps.findings.Add(&Finding{ + ID: "f3", + Severity: FindingSeverityWarning, + Title: "Warning finding", + DetectedAt: time.Now().Add(-2 * time.Hour), + }) + + findings := ps.GetAllFindings() + + if len(findings) != 3 { + t.Fatalf("Expected 3 findings, got %d", len(findings)) + } + + // Should be sorted by severity (critical first) + if findings[0].Severity != FindingSeverityCritical { + t.Errorf("Expected first finding to be critical, got %s", findings[0].Severity) + } + if findings[1].Severity != FindingSeverityWarning { + t.Errorf("Expected second finding to be warning, got %s", findings[1].Severity) + } + if findings[2].Severity != FindingSeverityInfo { + t.Errorf("Expected third finding to be info, got %s", findings[2].Severity) + } +} + +func TestPatrolService_GetFindingsHistory(t *testing.T) { + ps := NewPatrolService(nil, nil) + + now := time.Now() + + // Add findings at different times + ps.findings.Add(&Finding{ + ID: "f1", + Title: "Old finding", + DetectedAt: now.Add(-48 * time.Hour), + }) + ps.findings.Add(&Finding{ + ID: "f2", + Title: "Recent finding", + DetectedAt: now.Add(-1 * time.Hour), + }) + + // Get all findings history + allHistory := ps.GetFindingsHistory(nil) + if len(allHistory) != 2 { + t.Errorf("Expected 2 findings in history, got %d", len(allHistory)) + } + + // Should be sorted by detected time (newest first) + if allHistory[0].ID != "f2" { + t.Errorf("Expected newest finding first, got %s", allHistory[0].ID) + } + + // Get filtered history (only last 24 hours) + startTime := now.Add(-24 * time.Hour) + filteredHistory := ps.GetFindingsHistory(&startTime) + if len(filteredHistory) != 1 { + t.Errorf("Expected 1 finding in filtered history, got %d", len(filteredHistory)) + } +} + +func TestPatrolService_ResolveFinding_Errors(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Test empty ID + err := ps.ResolveFinding("", "resolved") + if err == nil { + t.Error("Expected error for empty finding ID") + } + + // Test non-existent finding + err = ps.ResolveFinding("nonexistent", "resolved") + if err == nil { + t.Error("Expected error for non-existent finding") + } +} diff --git a/internal/ai/service_extended_test.go b/internal/ai/service_extended_test.go new file mode 100644 index 0000000..1bf1f66 --- /dev/null +++ b/internal/ai/service_extended_test.go @@ -0,0 +1,2593 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestService_GetToolInputDisplay(t *testing.T) { + svc := NewService(nil, nil) + + tests := []struct { + name string + tc providers.ToolCall + expected string + }{ + { + name: "run_command", + tc: providers.ToolCall{ + Name: "run_command", + Input: map[string]interface{}{ + "command": "uptime", + }, + }, + expected: "uptime", + }, + { + name: "run_command on host", + tc: providers.ToolCall{ + Name: "run_command", + Input: map[string]interface{}{ + "command": "uptime", + "run_on_host": true, + }, + }, + expected: "[host] uptime", + }, + { + name: "fetch_url", + tc: providers.ToolCall{ + Name: "fetch_url", + Input: map[string]interface{}{ + "url": "https://google.com", + }, + }, + expected: "https://google.com", + }, + { + name: "set_resource_url", + tc: providers.ToolCall{ + Name: "set_resource_url", + Input: map[string]interface{}{ + "resource_type": "guest", + "url": "http://1.2.3.4", + }, + }, + expected: "Set guest URL: http://1.2.3.4", + }, + { + name: "unknown tool", + tc: providers.ToolCall{ + Name: "unknown", + Input: map[string]interface{}{ + "foo": "bar", + }, + }, + expected: "map[foo:bar]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.getToolInputDisplay(tt.tc) + if got != tt.expected { + t.Errorf("getToolInputDisplay() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestService_GetTools(t *testing.T) { + svc := NewService(nil, nil) + svc.cfg = &config.AIConfig{Enabled: true} + + tools := svc.getTools() + if len(tools) == 0 { + t.Error("Expected tools") + } + + // Verify some common tools are present + foundRunCommand := false + for _, tool := range tools { + if tool.Name == "run_command" { + foundRunCommand = true + break + } + } + if !foundRunCommand { + t.Error("Expected run_command tool to be available") + } +} + +func TestService_LogRemediation_Nil(t *testing.T) { + svc := NewService(nil, nil) + // Should not panic even if remediation log is nil + req := ExecuteRequest{Prompt: "Fix it"} + svc.logRemediation(req, "ls", "output", true) +} + +func TestService_AcquireExecutionSlot_Blocked(t *testing.T) { + svc := NewService(nil, nil) + + // Fill all slots (4 by default) + for i := 0; i < 4; i++ { + _, err := svc.acquireExecutionSlot(context.Background(), "chat") + if err != nil { + t.Fatalf("Failed to fill slots: %v", err) + } + } + + // Try to acquire one more with a canceled context - should fail via ctx.Done() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := svc.acquireExecutionSlot(ctx, "chat") + if err == nil { + t.Error("Expected error when acquiring slot with canceled context") + } + + // Try to acquire one more with a timeout - should fail via timeout case + shortCtx, cancel2 := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel2() + + // We need to wait slightly more than the shortCtx but less than the 5s hardcoded timeout + // But wait, the hardcoded timeout in acquireExecutionSlot is the 3rd case. + // We want to hit the second case. + _, err = svc.acquireExecutionSlot(shortCtx, "chat") + if err == nil { + t.Error("Expected error when no slots available and context times out") + } +} + +func TestService_SetResourceURL(t *testing.T) { + svc := NewService(nil, nil) + mockMP := &mockMetadataProvider{} + svc.SetMetadataProvider(mockMP) + + // Valid guest URL + err := svc.SetResourceURL("guest", "delly-150", "http://192.168.1.10") + if err != nil { + t.Errorf("SetResourceURL failed: %v", err) + } + if mockMP.lastGuestID != "delly-150" || mockMP.lastGuestURL != "http://192.168.1.10" { + t.Error("Mock did not receive correct guest data") + } + + // URL without scheme (should auto-add http://) + err = svc.SetResourceURL("docker", "host:ct:123", "192.168.1.20:8080") + if err != nil { + t.Errorf("SetResourceURL failed: %v", err) + } + if mockMP.lastDockerID != "host:ct:123" || mockMP.lastDockerURL != "http://192.168.1.20:8080" { + t.Errorf("Mock did not receive correct docker data, got %s", mockMP.lastDockerURL) + } + + // Invalid URL + err = svc.SetResourceURL("host", "host-1", "not a url") + if err == nil { + t.Error("Expected error for invalid URL") + } + + // Unknown resource type + err = svc.SetResourceURL("unknown", "id", "http://test") + if err == nil { + t.Error("Expected error for unknown resource type") + } +} + +// TestConvertMetricPoints tests the metric conversion logic + +func TestConvertMetricPoints(t *testing.T) { + // Since MetricsHistoryAdapter is just a wrapper, we test the conversion functions + now := time.Now() + monitoringPoints := []monitoring.MetricPoint{ + {Value: 1.2, Timestamp: now}, + } + + aiPoints := convertMetricPoints(monitoringPoints) + if len(aiPoints) != 1 || aiPoints[0].Value != 1.2 || !aiPoints[0].Timestamp.Equal(now) { + t.Error("Conversion failed") + } + + if convertMetricPoints(nil) != nil { + t.Error("Should return nil for nil input") + } +} + +func TestNewMetricsHistoryAdapter(t *testing.T) { + if NewMetricsHistoryAdapter(nil) != nil { + t.Error("Expected nil for nil history") + } +} + +func TestService_HasAgentForTarget(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "node-1", + } + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + } + svc := NewService(nil, mockServer) + + // Host target with no context (should match any agent) + if !svc.hasAgentForTarget(ExecuteRequest{TargetType: "host"}) { + t.Error("Should have agent for host with no context") + } + + // Target matching agent hostname + if !svc.hasAgentForTarget(ExecuteRequest{ + TargetType: "guest", + Context: map[string]interface{}{"node": "node-1"}, + }) { + t.Error("Should have agent for matching hostname") + } + + // Target not matching + if svc.hasAgentForTarget(ExecuteRequest{ + TargetType: "guest", + Context: map[string]interface{}{"node": "other-node"}, + }) { + t.Error("Should not have agent for non-matching node") + } + + // Empty agents + svc.agentServer = &mockAgentServer{agents: []agentexec.ConnectedAgent{}} + if svc.hasAgentForTarget(ExecuteRequest{TargetType: "host"}) { + t.Error("Should not have agent when none connected") + } +} + +func TestService_ExecuteOnAgent(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "node-1", + } + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + executeFunc: func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + if agentID != "agent-1" { + return nil, fmt.Errorf("wrong agent") + } + return &agentexec.CommandResultPayload{ + Success: true, + Stdout: "ok", + }, nil + }, + } + svc := NewService(nil, mockServer) + + // Basic execution + output, err := svc.executeOnAgent(context.Background(), ExecuteRequest{ + TargetType: "host", + Context: map[string]interface{}{"node": "node-1"}, + }, "uptime") + if err != nil { + t.Fatalf("ExecuteOnAgent failed: %v", err) + } + if output != "ok" { + t.Errorf("Unexpected output: %s", output) + } + + // Routing failure + _, err = svc.executeOnAgent(context.Background(), ExecuteRequest{ + TargetType: "guest", + Context: map[string]interface{}{"node": "unknown"}, + }, "uptime") + if err == nil { + t.Error("Expected routing error") + } +} + +func TestService_RunCommandExtended(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "node-1", + } + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + } + svc := NewService(nil, mockServer) + + // Successful run + resp, err := svc.RunCommand(context.Background(), RunCommandRequest{ + Command: "uptime", + TargetType: "host", + TargetHost: "node-1", + }) + if err != nil { + t.Fatalf("RunCommand failed: %v", err) + } + if !resp.Success { + t.Errorf("RunCommand reported failure: %s", resp.Error) + } + if resp.Output != "Mock output" { + t.Errorf("Unexpected output: %s", resp.Output) + } + + // Failure (routing) + resp, err = svc.RunCommand(context.Background(), RunCommandRequest{ + Command: "uptime", + TargetType: "host", + TargetHost: "unknown", + }) + if err != nil { + t.Fatalf("RunCommand failed with error: %v", err) + } + if resp.Success { + t.Error("RunCommand should have failed") + } +} + +func TestService_ExecuteStreamExtended(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "node-1", + } + mockAgentServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + } + + callCount := 0 + mockProv := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + callCount++ + if callCount == 1 { + // First call: return a tool call + return &providers.ChatResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call-1", + Name: "run_command", + Input: map[string]interface{}{ + "command": "uptime", + }, + }, + }, + StopReason: "tool_use", + }, nil + } + // Second call: return final response + return &providers.ChatResponse{ + Content: "All systems clear.", + StopReason: "end_turn", + }, nil + }, + } + + svc := NewService(nil, mockAgentServer) + // Initialize persistence to avoid nil pointer dereference in buildSystemPrompt + tmpDir := t.TempDir() + svc.persistence = config.NewConfigPersistence(tmpDir) + + svc.provider = mockProv + svc.cfg = &config.AIConfig{Enabled: true} + // Initialize limits so acquireExecutionSlot works + svc.limits = executionLimits{ + chatSlots: make(chan struct{}, 10), + patrolSlots: make(chan struct{}, 10), + } + + events := []StreamEvent{} + callback := func(ev StreamEvent) { + events = append(events, ev) + } + + req := ExecuteRequest{ + Prompt: "Check status", + Model: "anthropic:test-model", + TargetType: "host", + Context: map[string]interface{}{"node": "node-1"}, + } + + resp, err := svc.ExecuteStream(context.Background(), req, callback) + if err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + + if resp.Content != "All systems clear." { + t.Errorf("Unexpected content: %s", resp.Content) + } + + if callCount != 2 { + t.Errorf("Expected 2 AI calls, got %d", callCount) + } + + // Verify events + hasToolStart := false + hasToolEnd := false + for _, ev := range events { + if ev.Type == "tool_start" { + hasToolStart = true + } + if ev.Type == "tool_end" { + hasToolEnd = true + } + } + + if !hasToolStart { + t.Error("Missing tool_start event") + } + if !hasToolEnd { + t.Error("Missing tool_end event") + } +} + +func TestApprovalNeededFromToolCall(t *testing.T) { + req := ExecuteRequest{ + Context: map[string]interface{}{"node": "node-1"}, + } + tc := providers.ToolCall{ + ID: "call-1", + Name: "run_command", + Input: map[string]interface{}{ + "command": "uptime", + }, + } + result := "APPROVAL_REQUIRED:{\"command\":\"uptime\",\"tool_id\":\"call-1\"}" + + data, needed := approvalNeededFromToolCall(req, tc, result) + if !needed { + t.Fatal("Expected approval needed") + } + if data.Command != "uptime" || data.TargetHost != "node-1" { + t.Errorf("Unexpected data: %+v", data) + } + + // Not a run_command + tc.Name = "fetch_url" + _, needed = approvalNeededFromToolCall(req, tc, result) + if needed { + t.Error("Should not need approval for fetch_url") + } + + // No prefix + tc.Name = "run_command" + _, needed = approvalNeededFromToolCall(req, tc, "ok") + if needed { + t.Error("Should not need approval for normal result") + } +} + +func TestService_ExecuteTool_FetchURL(t *testing.T) { + svc := NewService(nil, nil) + // We need to mock fetchURL or just let it fail and check the error handling + + tc := providers.ToolCall{ + Name: "fetch_url", + Input: map[string]interface{}{ + "url": "http://example.com", + }, + } + + ctx := context.Background() + req := ExecuteRequest{} + + result, exec := svc.executeTool(ctx, req, tc) + if exec.Name != "fetch_url" { + t.Errorf("Unexpected tool name: %s", exec.Name) + } + + // Since we are in a limited environment, fetch might fail or be blocked. + // But we want to see that executeTool handled it. + if result == "" { + t.Error("Expected non-empty result (either content or error)") + } +} + +func TestFormatContextKey(t *testing.T) { + tests := []struct { + key string + expected string + }{ + {"guestName", "Guest Name"}, + {"vmid", "VMID"}, + {"node", "PVE Node (host)"}, + {"unknown_key", "unknown_key"}, + } + + for _, tt := range tests { + if got := formatContextKey(tt.key); got != tt.expected { + t.Errorf("formatContextKey(%q) = %q, want %q", tt.key, got, tt.expected) + } + } +} + +func TestProviderDisplayName(t *testing.T) { + tests := []struct { + provider string + expected string + }{ + {config.AIProviderAnthropic, "Anthropic"}, + {config.AIProviderOpenAI, "OpenAI"}, + {"unknown", "unknown"}, + } + + for _, tt := range tests { + if got := providerDisplayName(tt.provider); got != tt.expected { + t.Errorf("providerDisplayName(%q) = %q, want %q", tt.provider, got, tt.expected) + } + } +} + +func TestService_SetBaselineStore(t *testing.T) { + svc := NewService(nil, nil) + // Just verify it doesn't panic + svc.SetBaselineStore(nil) +} + +func TestService_GetDebugContext(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{Enabled: true, CustomContext: "Test context"} + + stateProvider := &mockStateProvider{ + state: models.StateSnapshot{ + VMs: []models.VM{{Name: "vm-1", VMID: 100, Node: "node-1"}}, + }, + } + svc.SetStateProvider(stateProvider) + + req := ExecuteRequest{Prompt: "test"} + debug := svc.GetDebugContext(req) + + if debug["has_state_provider"] != true { + t.Error("Expected has_state_provider to be true") + } + if debug["custom_context_preview"] != "Test context" { + t.Errorf("Unexpected custom_context_preview: %v", debug["custom_context_preview"]) + } + if debug["system_prompt"] == "" { + t.Error("Expected non-empty system_prompt") + } +} + +func TestService_LoadConfig(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // 1. Initial state (no config file) + err := svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if svc.provider != nil { + t.Error("Expected nil provider when no config exists") + } + + // 2. Disabled config + cfgV := config.AIConfig{Enabled: false} + persistence.SaveAIConfig(cfgV) + err = svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if svc.provider != nil { + t.Error("Expected nil provider when AI is disabled") + } + + // 3. Smart fallback (Anthropic model set, but only OpenAI key configured) + cfgV = config.AIConfig{ + Enabled: true, + Model: "anthropic:claude-3-opus-20240229", + OpenAIAPIKey: "sk-test", + } + persistence.SaveAIConfig(cfgV) + err = svc.LoadConfig() + if svc.provider == nil { + t.Fatal("Expected non-nil provider after smart fallback") + } + if svc.provider.Name() != config.AIProviderOpenAI { + t.Errorf("Expected provider to fallback to openai, got %s", svc.provider.Name()) + } +} + +func TestService_EnforceBudget(t *testing.T) { + svc := NewService(nil, nil) + svc.cfg = &config.AIConfig{ + CostBudgetUSD30d: 1.0, + } + svc.costStore = cost.NewStore(30) + + // No usage yet - should pass + if err := svc.enforceBudget("chat"); err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Record significant usage (e.g. 10 million tokens to exceed $1 budget) + svc.costStore.Record(cost.UsageEvent{ + Provider: "anthropic", + RequestModel: "claude-opus-20240229", + ResponseModel: "claude-opus-20240229", + InputTokens: 1000000, + OutputTokens: 1000000, + }) + + // Budget should now be exceeded + if err := svc.enforceBudget("chat"); err == nil { + t.Error("Expected budget limit error") + } else if !strings.Contains(err.Error(), "budget exceeded") { + t.Errorf("Expected budget exceeded error, got: %v", err) + } +} + +func TestService_GetModelForRequest(t *testing.T) { + svc := NewService(nil, nil) + svc.cfg = &config.AIConfig{ + Enabled: true, + Model: "default-model", + ChatModel: "chat-model", + PatrolModel: "patrol-model", + } + + // 1. Explicit override + req := ExecuteRequest{Model: "override-model"} + if got := svc.getModelForRequest(req); got != "override-model" { + t.Errorf("Expected override-model, got %s", got) + } + + // 2. Use case: patrol + req = ExecuteRequest{UseCase: "patrol"} + if got := svc.getModelForRequest(req); got != "patrol-model" { + t.Errorf("Expected patrol-model, got %s", got) + } + + // 3. Use case: chat + req = ExecuteRequest{UseCase: "chat"} + if got := svc.getModelForRequest(req); got != "chat-model" { + t.Errorf("Expected chat-model, got %s", got) + } + + // 4. Default + req = ExecuteRequest{} + if got := svc.getModelForRequest(req); got != "chat-model" { + t.Errorf("Expected chat-model by default, got %s", got) + } +} + +func TestService_ExecuteTool_RunCommand(t *testing.T) { + mockAgentServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{ + {Hostname: "node-1", AgentID: "agent-1"}, + }, + } + svc := NewService(nil, mockAgentServer) + + // Mock policy + mockPolicy := &mockPolicy{ + decision: agentexec.PolicyAllow, + } + svc.policy = mockPolicy + + tc := providers.ToolCall{ + Name: "run_command", + Input: map[string]interface{}{ + "command": "uptime", + }, + } + + ctx := context.Background() + req := ExecuteRequest{ + Context: map[string]interface{}{"node": "node-1"}, + } + + // 1. Allowed command + result, exec := svc.executeTool(ctx, req, tc) + if !exec.Success { + t.Errorf("Expected success, got failure: %s", result) + } + + // 2. Blocked command + mockPolicy.decision = agentexec.PolicyBlock + result, exec = svc.executeTool(ctx, req, tc) + if exec.Success { + t.Error("Expected failure for blocked command") + } + if !strings.Contains(result, "blocked") { + t.Errorf("Expected blocked message, got %s", result) + } + + // 3. Approval required (non-autonomous) + mockPolicy.decision = agentexec.PolicyRequireApproval + svc.cfg = &config.AIConfig{AutonomousMode: false} + result, exec = svc.executeTool(ctx, req, tc) + if !exec.Success { + t.Errorf("Expected success (not an error to need approval), got: %s", result) + } + if !strings.Contains(result, "APPROVAL_REQUIRED") { + t.Errorf("Expected APPROVAL_REQUIRED message, got %s", result) + } +} + +func TestService_ExecuteTool_SetResourceURL(t *testing.T) { + svc := NewService(nil, nil) + + // Mock metadata provider + mockMeta := &mockMetadataProvider{} + svc.metadataProvider = mockMeta + + tc := providers.ToolCall{ + Name: "set_resource_url", + Input: map[string]interface{}{ + "resource_type": "guest", + "resource_id": "instance-101", + "url": "http://example.com:8080", + }, + } + + ctx := context.Background() + req := ExecuteRequest{} + + result, exec := svc.executeTool(ctx, req, tc) + if !exec.Success { + t.Errorf("Expected success, got failure: %s", result) + } + if mockMeta.lastGuestID != "instance-101" || mockMeta.lastGuestURL != "http://example.com:8080" { + t.Errorf("Metadata provider not called correctly: %+v", mockMeta) + } +} + +func TestService_PatrolManagement(t *testing.T) { + svc := NewService(nil, nil) + + // Mock components + patrol := NewPatrolService(svc, nil) + svc.patrolService = patrol + + alertAnalyzer := NewAlertTriggeredAnalyzer(patrol, nil) + svc.alertTriggeredAnalyzer = alertAnalyzer + + mockLicense := &mockLicenseStore{ + features: map[string]bool{ + FeatureAIPatrol: true, + FeatureAIAlerts: true, + }, + } + svc.licenseChecker = mockLicense + + svc.cfg = &config.AIConfig{ + Enabled: true, + PatrolEnabled: true, + PatrolIntervalMinutes: 30, + AlertTriggeredAnalysis: true, + } + + ctx := context.Background() + + // 1. Start Patrol + svc.StartPatrol(ctx) + + if patrol.GetConfig().Interval != 30*time.Minute { + t.Errorf("Patrol interval not set correctly: %v", patrol.GetConfig().Interval) + } + if !alertAnalyzer.IsEnabled() { + t.Error("Alert analyzer should be enabled") + } + + // 2. Reconfigure Patrol + svc.cfg.PatrolIntervalMinutes = 60 + svc.cfg.AlertTriggeredAnalysis = false + svc.ReconfigurePatrol() + + if patrol.GetConfig().Interval != 60*time.Minute { + t.Errorf("Patrol interval not updated: %v", patrol.GetConfig().Interval) + } + if alertAnalyzer.IsEnabled() { + t.Error("Alert analyzer should be disabled") + } + + // 3. License constraint + mockLicense.features[FeatureAIAlerts] = false + svc.cfg.AlertTriggeredAnalysis = true + svc.ReconfigurePatrol() + if alertAnalyzer.IsEnabled() { + t.Error("Alert analyzer should be disabled due to lack of license") + } +} + +func TestService_LookupNodeForVMID_Extended(t *testing.T) { + svc := NewService(nil, nil) + mockState := &mockStateProvider{ + state: models.StateSnapshot{ + VMs: []models.VM{ + {VMID: 101, Node: "node-1", Name: "vm-101", Instance: "pve-1"}, + }, + Containers: []models.Container{ + {VMID: 201, Node: "node-2", Name: "ct-201", Instance: "pve-1"}, + }, + }, + } + svc.stateProvider = mockState + + // 1. Find VM + node, name, gType := svc.lookupNodeForVMID(101) + if node != "node-1" || name != "vm-101" || gType != "qemu" { + t.Errorf("VM lookup failed: %s, %s, %s", node, name, gType) + } + + // 2. Find Container + node, name, gType = svc.lookupNodeForVMID(201) + if node != "node-2" || name != "ct-201" || gType != "lxc" { + t.Errorf("Container lookup failed: %s, %s, %s", node, name, gType) + } + + // 3. Not found + node, name, gType = svc.lookupNodeForVMID(999) + if node != "" || name != "" { + t.Error("Should not have found non-existent VMID") + } + + // 4. Collision + mockState.state.VMs = append(mockState.state.VMs, models.VM{ + VMID: 101, Node: "node-3", Name: "vm-101-dup", Instance: "pve-2", + }) + node, _, _ = svc.lookupNodeForVMID(101) + if node == "" { + t.Error("Should return first match on collision") + } +} + +func TestService_BuildUserAnnotationsContext(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // 1. Empty metadata + if ctx := svc.buildUserAnnotationsContext(); ctx != "" { + t.Errorf("Expected empty context, got: %s", ctx) + } + + // 2. Guest metadata + guestMeta := map[string]*config.GuestMetadata{ + "guest-1": { + ID: "guest-1", + LastKnownName: "ServerA", + Notes: []string{"Primary database"}, + }, + } + data, _ := json.Marshal(guestMeta) + os.WriteFile(filepath.Join(tmpDir, "guest_metadata.json"), data, 0644) + + // 3. Docker metadata + dockerMeta := map[string]*config.DockerMetadata{ + "host1:container:id1": { + ID: "host1:container:id1", + Notes: []string{"Web portal"}, + }, + } + data, _ = json.Marshal(dockerMeta) + os.WriteFile(filepath.Join(tmpDir, "docker_metadata.json"), data, 0644) + + ctx := svc.buildUserAnnotationsContext() + if !strings.Contains(ctx, "ServerA") || !strings.Contains(ctx, "Primary database") { + t.Error("Guest notes missing from context") + } + if !strings.Contains(ctx, "Web portal") || !strings.Contains(ctx, "host1") { + t.Error("Docker notes missing from context") + } +} + +func TestService_TestConnection_Extended(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // 1. Not configured + err := svc.TestConnection(context.Background()) + if err == nil || !strings.Contains(err.Error(), "no AI provider configured") { + t.Errorf("Expected 'no AI provider configured' error, got: %v", err) + } + + // 2. Mock provider fallback + mockProv := &mockProvider{ + testConnectionFunc: func(ctx context.Context) error { + return nil + }, + } + svc.provider = mockProv + svc.cfg = &config.AIConfig{ + Enabled: true, + AnthropicAPIKey: "test-key", + Model: "openai:test-model", + } + + err = svc.TestConnection(context.Background()) + if err != nil { + t.Errorf("TestConnection failed: %v", err) + } + + // 3. Provider error + mockProv.testConnectionFunc = func(ctx context.Context) error { + return fmt.Errorf("connection failed") + } + err = svc.TestConnection(context.Background()) + if err == nil || !strings.Contains(err.Error(), "connection failed") { + t.Errorf("Expected 'connection failed' error, got: %v", err) + } +} + +func TestService_ExecuteTool_ResolveFinding(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Set up patrol service with findings store + stateProvider := &mockStateProvider{} + svc.SetStateProvider(stateProvider) + + patrol := svc.GetPatrolService() + if patrol == nil { + t.Fatal("Expected patrol service to be initialized") + } + + // Add a finding + finding := &Finding{ + ID: "test-finding-1", + Severity: FindingSeverityWarning, + ResourceID: "vm-100", + ResourceName: "test-vm", + Title: "High CPU", + } + patrol.GetFindings().Add(finding) + + ctx := context.Background() + req := ExecuteRequest{FindingID: "test-finding-1"} + + // 1. Successful resolution + tc := providers.ToolCall{ + Name: "resolve_finding", + Input: map[string]interface{}{ + "finding_id": "test-finding-1", + "resolution_note": "Restarted the service", + }, + } + + result, exec := svc.executeTool(ctx, req, tc) + if !exec.Success { + t.Errorf("Expected success, got failure: %s", result) + } + if !strings.Contains(result, "Finding resolved!") { + t.Errorf("Expected 'Finding resolved!' in result, got: %s", result) + } + + // 2. Missing finding_id (should use from request context) + req2 := ExecuteRequest{FindingID: "test-finding-1"} + tc2 := providers.ToolCall{ + Name: "resolve_finding", + Input: map[string]interface{}{ + "resolution_note": "Fixed it", + }, + } + _, exec2 := svc.executeTool(ctx, req2, tc2) + // Finding already resolved, so this will fail + if exec2.Success { + t.Error("Expected failure for already resolved finding") + } + + // 3. Missing resolution_note + tc3 := providers.ToolCall{ + Name: "resolve_finding", + Input: map[string]interface{}{ + "finding_id": "new-finding", + }, + } + result3, exec3 := svc.executeTool(ctx, ExecuteRequest{}, tc3) + if exec3.Success { + t.Error("Expected failure for missing resolution_note") + } + if !strings.Contains(result3, "resolution_note is required") { + t.Errorf("Expected resolution_note error, got: %s", result3) + } + + // 4. Missing both + tc4 := providers.ToolCall{ + Name: "resolve_finding", + Input: map[string]interface{}{}, + } + result4, exec4 := svc.executeTool(ctx, ExecuteRequest{}, tc4) + if exec4.Success { + t.Error("Expected failure for missing finding_id") + } + if !strings.Contains(result4, "finding_id is required") { + t.Errorf("Expected finding_id error, got: %s", result4) + } +} + +func TestService_ExecuteTool_SetResourceURL_EdgeCases(t *testing.T) { + svc := NewService(nil, nil) + mockMeta := &mockMetadataProvider{} + svc.metadataProvider = mockMeta + + ctx := context.Background() + + // 1. Missing resource_type + tc := providers.ToolCall{ + Name: "set_resource_url", + Input: map[string]interface{}{ + "resource_id": "test-id", + "url": "http://example.com", + }, + } + result, exec := svc.executeTool(ctx, ExecuteRequest{}, tc) + if exec.Success { + t.Error("Expected failure for missing resource_type") + } + if !strings.Contains(result, "resource_type is required") { + t.Errorf("Expected resource_type error, got: %s", result) + } + + // 2. Missing resource_id but present in request context + tc2 := providers.ToolCall{ + Name: "set_resource_url", + Input: map[string]interface{}{ + "resource_type": "guest", + "url": "http://example.com:8080", + }, + } + req := ExecuteRequest{TargetID: "vm-from-context"} + result2, exec2 := svc.executeTool(ctx, req, tc2) + if !exec2.Success { + t.Errorf("Expected success when resource_id from context, got: %s", result2) + } + if mockMeta.lastGuestID != "vm-from-context" { + t.Errorf("Expected resource_id from context, got: %s", mockMeta.lastGuestID) + } + + // 3. Missing resource_id completely + tc3 := providers.ToolCall{ + Name: "set_resource_url", + Input: map[string]interface{}{ + "resource_type": "guest", + "url": "http://example.com", + }, + } + result3, exec3 := svc.executeTool(ctx, ExecuteRequest{}, tc3) + if exec3.Success { + t.Error("Expected failure for missing resource_id") + } + if !strings.Contains(result3, "resource_id is required") { + t.Errorf("Expected resource_id error, got: %s", result3) + } +} + +func TestService_ExecuteTool_UnknownTool(t *testing.T) { + svc := NewService(nil, nil) + + tc := providers.ToolCall{ + Name: "unknown_tool", + Input: map[string]interface{}{ + "foo": "bar", + }, + } + + result, exec := svc.executeTool(context.Background(), ExecuteRequest{}, tc) + if exec.Success { + t.Error("Expected failure for unknown tool") + } + if !strings.Contains(result, "Unknown tool") { + t.Errorf("Expected 'Unknown tool' error, got: %s", result) + } +} + +func TestService_ExecuteTool_FetchURL_EmptyURL(t *testing.T) { + svc := NewService(nil, nil) + + tc := providers.ToolCall{ + Name: "fetch_url", + Input: map[string]interface{}{ + "url": "", + }, + } + + result, exec := svc.executeTool(context.Background(), ExecuteRequest{}, tc) + if exec.Success { + t.Error("Expected failure for empty URL") + } + if !strings.Contains(result, "url is required") { + t.Errorf("Expected 'url is required' error, got: %s", result) + } +} + +func TestService_ExecuteTool_RunCommand_WithTargetHost(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "target-node", + } + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + } + svc := NewService(nil, mockServer) + svc.policy = &mockPolicy{decision: agentexec.PolicyAllow} + + tc := providers.ToolCall{ + Name: "run_command", + Input: map[string]interface{}{ + "command": "uptime", + "run_on_host": true, + "target_host": "target-node", + }, + } + + req := ExecuteRequest{ + TargetType: "vm", + TargetID: "vm-100", + Context: map[string]interface{}{ + "node": "original-node", + }, + } + + result, exec := svc.executeTool(context.Background(), req, tc) + if !exec.Success { + t.Errorf("Expected success, got failure: %s", result) + } + if exec.Input != "[target-node] uptime" { + t.Errorf("Expected input to show target-node, got: %s", exec.Input) + } +} + +func TestService_HasAgentForTarget_WithResourceProvider(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "host-from-provider", + } + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + } + svc := NewService(nil, mockServer) + + // Mock resource provider that returns host for container + mockRP := &mockResourceProvider{} + mockRP.ResourceProvider = mockRP + svc.resourceProvider = mockRP + + // Overwrite FindContainerHost behavior + // The mock by default returns empty string, so this should fail + // But with containerName context, it will try the resource provider + + req := ExecuteRequest{ + TargetType: "docker", + Context: map[string]interface{}{ + "containerName": "some-container", + }, + } + + // Without provider returning anything, should still return true (at least one agent) + if !svc.hasAgentForTarget(req) { + t.Error("Expected true when at least one agent is available") + } + + // With name context + req2 := ExecuteRequest{ + TargetType: "docker", + Context: map[string]interface{}{ + "name": "my-container", + }, + } + if !svc.hasAgentForTarget(req2) { + t.Error("Expected true with name context") + } + + // With guestName context + req3 := ExecuteRequest{ + TargetType: "vm", + Context: map[string]interface{}{ + "guestName": "my-vm", + }, + } + if !svc.hasAgentForTarget(req3) { + t.Error("Expected true with guestName context") + } +} + +func TestService_HasAgentForTarget_HostFields(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "myhost", + } + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + } + svc := NewService(nil, mockServer) + + hostFields := []string{"node", "host", "guest_node", "hostname", "host_name", "target_host"} + + for _, field := range hostFields { + t.Run(field, func(t *testing.T) { + req := ExecuteRequest{ + TargetType: "vm", + Context: map[string]interface{}{ + field: "MYHOST", // Test case-insensitive matching + }, + } + if !svc.hasAgentForTarget(req) { + t.Errorf("Expected true for host field %s", field) + } + }) + } +} + +func TestService_ListModelsWithCache_CacheHit(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Save a config + cfg := config.AIConfig{ + Enabled: true, + OpenAIAPIKey: "test-key", + } + persistence.SaveAIConfig(cfg) + + // Get the cache key that persistence would generate + loadedCfg, _ := persistence.LoadAIConfig() + cacheKey := buildModelsCacheKey(loadedCfg) + + // Pre-populate cache before the first call + svc.modelsCache.mu.Lock() + svc.modelsCache.key = cacheKey + svc.modelsCache.at = time.Now() + svc.modelsCache.ttl = 5 * time.Minute + svc.modelsCache.models = []providers.ModelInfo{ + {ID: "test-model", Name: "Test Model"}, + } + svc.modelsCache.mu.Unlock() + + // Call should hit cache since we pre-populated it + models, cached, err := svc.ListModelsWithCache(context.Background()) + if err != nil { + t.Fatalf("ListModelsWithCache failed: %v", err) + } + if !cached { + t.Error("Expected cache hit") + } + if len(models) != 1 || models[0].ID != "test-model" { + t.Errorf("Expected cached model, got: %v", models) + } +} + +// Note: ListModelsWithCache does not handle nil persistence gracefully (panics) +// This is acceptable as NewService should always be called with a valid persistence + +func TestProviderDisplayName_AllProviders(t *testing.T) { + tests := []struct { + provider string + expected string + }{ + {config.AIProviderAnthropic, "Anthropic"}, + {config.AIProviderOpenAI, "OpenAI"}, + {config.AIProviderDeepSeek, "DeepSeek"}, + {config.AIProviderGemini, "Google Gemini"}, + {config.AIProviderOllama, "Ollama"}, + {"custom_provider", "custom_provider"}, + } + + for _, tt := range tests { + t.Run(tt.provider, func(t *testing.T) { + got := providerDisplayName(tt.provider) + if got != tt.expected { + t.Errorf("providerDisplayName(%q) = %q, want %q", tt.provider, got, tt.expected) + } + }) + } +} + +func TestService_ExecuteOnAgent_VMIDExtraction(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "node-1", + } + + var capturedCmd agentexec.ExecuteCommandPayload + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + executeFunc: func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + capturedCmd = cmd + return &agentexec.CommandResultPayload{ + Success: true, + Stdout: "ok", + }, nil + }, + } + svc := NewService(nil, mockServer) + + // Test with VMID in context as float64 (common from JSON) + req := ExecuteRequest{ + TargetType: "container", + TargetID: "instance-123", + Context: map[string]interface{}{ + "node": "node-1", + "vmid": float64(123), + }, + } + + _, err := svc.executeOnAgent(context.Background(), req, "uptime") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if capturedCmd.TargetID != "123" { + t.Errorf("Expected TargetID '123', got '%s'", capturedCmd.TargetID) + } + + // Test with VMID as int + req.Context["vmid"] = 456 + _, err = svc.executeOnAgent(context.Background(), req, "uptime") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if capturedCmd.TargetID != "456" { + t.Errorf("Expected TargetID '456', got '%s'", capturedCmd.TargetID) + } + + // Test with VMID as string + req.Context["vmid"] = "789" + _, err = svc.executeOnAgent(context.Background(), req, "uptime") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if capturedCmd.TargetID != "789" { + t.Errorf("Expected TargetID '789', got '%s'", capturedCmd.TargetID) + } +} + +func TestService_ExecuteOnAgent_AptNonInteractive(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "node-1", + } + + var capturedCmd agentexec.ExecuteCommandPayload + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + executeFunc: func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + capturedCmd = cmd + return &agentexec.CommandResultPayload{Success: true, Stdout: "ok"}, nil + }, + } + svc := NewService(nil, mockServer) + + req := ExecuteRequest{ + TargetType: "host", + Context: map[string]interface{}{"node": "node-1"}, + } + + // apt command should get DEBIAN_FRONTEND prefix + _, err := svc.executeOnAgent(context.Background(), req, "apt update") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if !strings.Contains(capturedCmd.Command, "DEBIAN_FRONTEND=noninteractive") { + t.Errorf("Expected DEBIAN_FRONTEND prefix for apt, got: %s", capturedCmd.Command) + } + + // dpkg command should also get prefix + _, err = svc.executeOnAgent(context.Background(), req, "dpkg --configure -a") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if !strings.Contains(capturedCmd.Command, "DEBIAN_FRONTEND=noninteractive") { + t.Errorf("Expected DEBIAN_FRONTEND prefix for dpkg, got: %s", capturedCmd.Command) + } + + // If already has DEBIAN_FRONTEND, don't add again + _, err = svc.executeOnAgent(context.Background(), req, "DEBIAN_FRONTEND=noninteractive apt update") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if strings.Count(capturedCmd.Command, "DEBIAN_FRONTEND") > 1 { + t.Error("Should not duplicate DEBIAN_FRONTEND") + } +} + +func TestService_ExecuteOnAgent_ResultHandling(t *testing.T) { + mockAgent := agentexec.ConnectedAgent{ + AgentID: "agent-1", + Hostname: "node-1", + } + + svc := NewService(nil, nil) + req := ExecuteRequest{ + TargetType: "host", + Context: map[string]interface{}{"node": "node-1"}, + } + + // Test with both stdout and stderr + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{mockAgent}, + executeFunc: func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + return &agentexec.CommandResultPayload{ + Success: true, + Stdout: "stdout content", + Stderr: "stderr content", + }, nil + }, + } + svc.agentServer = mockServer + + output, err := svc.executeOnAgent(context.Background(), req, "ls") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if !strings.Contains(output, "stdout content") || !strings.Contains(output, "STDERR") || !strings.Contains(output, "stderr content") { + t.Errorf("Expected combined output, got: %s", output) + } + + // Test with only stderr (failure case) + mockServer.executeFunc = func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + return &agentexec.CommandResultPayload{ + Success: false, + Stderr: "error message", + }, nil + } + + output, err = svc.executeOnAgent(context.Background(), req, "bad-command") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + if output != "error message" { + t.Errorf("Expected stderr as output, got: %s", output) + } + + // Test with error + mockServer.executeFunc = func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + return &agentexec.CommandResultPayload{ + Success: false, + Error: "command failed", + }, nil + } + + _, err = svc.executeOnAgent(context.Background(), req, "failing-command") + if err == nil { + t.Error("Expected error") + } + if !strings.Contains(err.Error(), "command failed") { + t.Errorf("Expected error message, got: %v", err) + } +} + +func TestService_ExecuteOnAgent_RoutingClarification(t *testing.T) { + svc := NewService(nil, &mockAgentServer{ + agents: []agentexec.ConnectedAgent{ + {AgentID: "agent-1", Hostname: "host-1"}, + {AgentID: "agent-2", Hostname: "host-2"}, + }, + }) + + // No target node in context, multiple agents available -> clarification needed + req := ExecuteRequest{ + TargetType: "host", + } + + output, err := svc.executeOnAgent(context.Background(), req, "hostname") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + t.Logf("Output: %s", output) + + if !strings.Contains(output, "ROUTING_CLARIFICATION_NEEDED") { + t.Errorf("Expected clarification needed response, got: %s", output) + } +} + +func TestService_ExecuteOnAgent_TargetIDExtractionOnly(t *testing.T) { + var capturedCmd agentexec.ExecuteCommandPayload + mockServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "host-1"}}, + executeFunc: func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + capturedCmd = cmd + return &agentexec.CommandResultPayload{Success: true, Stdout: "ok"}, nil + }, + } + svc := NewService(nil, mockServer) + + // No vmid in context, but TargetID has a number + req := ExecuteRequest{ + TargetType: "container", + TargetID: "delly-135", + Context: map[string]interface{}{ + "node": "host-1", // Force routing + }, + } + + _, err := svc.executeOnAgent(context.Background(), req, "uptime") + if err != nil { + t.Fatalf("executeOnAgent failed: %v", err) + } + + // Should have extracted 135 from delly-135 + if capturedCmd.TargetID != "135" { + t.Errorf("Expected extracted TargetID '135', got '%s'", capturedCmd.TargetID) + } +} + +// ============================================================================ +// approvalNeededFromToolCall Tests +// ============================================================================ + +func TestApprovalNeededFromToolCall_Extended(t *testing.T) { + req := ExecuteRequest{ + Context: map[string]interface{}{ + "node": "node-1", + }, + } + + tc := providers.ToolCall{ + ID: "call-1", + Name: "run_command", + Input: map[string]interface{}{ + "command": "reboot", + }, + } + + // Case 1: Result doesn't have APPROVAL_REQUIRED prefix + _, ok := approvalNeededFromToolCall(req, tc, "regular result") + if ok { + t.Error("Expected false for regular result") + } + + // Case 2: Wrong tool name + tc2 := tc + tc2.Name = "fetch_url" + _, ok = approvalNeededFromToolCall(req, tc2, "APPROVAL_REQUIRED: ...") + if ok { + t.Error("Expected false for non-run_command tool") + } + + // Case 3: Success with node from context + data, ok := approvalNeededFromToolCall(req, tc, "APPROVAL_REQUIRED: reason") + if !ok { + t.Error("Expected true for approval required") + } + if data.TargetHost != "node-1" { + t.Errorf("Expected TargetHost node-1, got %s", data.TargetHost) + } + + // Case 4: Other context fields + req2 := ExecuteRequest{Context: map[string]interface{}{"hostname": "host-2"}} + data, _ = approvalNeededFromToolCall(req2, tc, "APPROVAL_REQUIRED: reason") + if data.TargetHost != "host-2" { + t.Errorf("Expected host-2, got %s", data.TargetHost) + } + + req3 := ExecuteRequest{Context: map[string]interface{}{"host_name": "host-3"}} + data, _ = approvalNeededFromToolCall(req3, tc, "APPROVAL_REQUIRED: reason") + if data.TargetHost != "host-3" { + t.Errorf("Expected host-3, got %s", data.TargetHost) + } +} + +// ============================================================================ +// getTools Tests +// ============================================================================ + +func TestService_getTools_Variants(t *testing.T) { + svc := NewService(nil, nil) + + // Case 1: Generic provider + tools := svc.getTools() + foundWebSearch := false + for _, tool := range tools { + if tool.Name == "web_search" { + foundWebSearch = true + } + } + if foundWebSearch { + t.Error("Did not expect web_search tool for generic provider") + } + + // Case 2: Anthropic provider + svc.provider = &mockProvider{nameFunc: func() string { return "anthropic" }} + tools = svc.getTools() + foundWebSearch = false + for _, tool := range tools { + if tool.Name == "web_search" || tool.Type == "web_search_20250305" { + foundWebSearch = true + } + } + if !foundWebSearch { + t.Error("Expected web_search tool for Anthropic provider") + } +} + +// ============================================================================ +// ExecuteStream Tests - Continued +// ============================================================================ + +func TestService_ExecuteStream_BudgetExceeded(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + svc.mu.Lock() + svc.cfg = &config.AIConfig{ + Enabled: true, + CostBudgetUSD30d: 0.0001, // Very low budget + Model: "anthropic:forbidden", // Force fallback to mock + } + svc.provider = &mockProvider{} + svc.mu.Unlock() + + // We need costStore to have some usage + costStore := cost.NewStore(30) + costStore.Record(cost.UsageEvent{ + Timestamp: time.Now(), + Provider: "openai", + RequestModel: "gpt-4o", + UseCase: "chat", + InputTokens: 1000000, + OutputTokens: 1000000, + }) + svc.costStore = costStore + + events := []StreamEvent{} + callback := func(e StreamEvent) { + events = append(events, e) + } + + // First call should fail synchronously with error + _, err := svc.ExecuteStream(context.Background(), ExecuteRequest{UseCase: "chat"}, callback) + if err == nil { + t.Error("Expected error due to budget") + } + if !strings.Contains(err.Error(), "budget") { + t.Errorf("Expected budget error, got %v", err) + } +} + +func TestService_ExecuteStream_ThinkingContent(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + mock := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{ + Content: "Final response", + ReasoningContent: "Thinking...", + StopReason: "end_turn", + }, nil + }, + } + svc.provider = mock + svc.cfg = &config.AIConfig{Enabled: true, Model: "anthropic:test"} + + events := []StreamEvent{} + callback := func(e StreamEvent) { + events = append(events, e) + } + + _, err := svc.ExecuteStream(context.Background(), ExecuteRequest{}, callback) + if err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + + foundThinking := false + for _, e := range events { + if e.Type == "thinking" && e.Data == "Thinking..." { + foundThinking = true + } + } + if !foundThinking { + t.Error("Expected thinking event in stream") + } +} + +func TestService_ExecuteStream_PolicyBlock(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, &mockAgentServer{}) + svc.policy = &mockPolicy{decision: agentexec.PolicyBlock} + svc.cfg = &config.AIConfig{Enabled: true, Model: "anthropic:test"} + + iteration := 0 + mock := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + iteration++ + if iteration == 1 { + return &providers.ChatResponse{ + ToolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "run_command", Input: map[string]interface{}{"command": "rm -rf /"}}, + }, + StopReason: "tool_use", + }, nil + } + return &providers.ChatResponse{ + Content: "The command was blocked.", + StopReason: "end_turn", + }, nil + }, + } + svc.provider = mock + + events := []StreamEvent{} + callback := func(e StreamEvent) { + events = append(events, e) + } + + _, err := svc.ExecuteStream(context.Background(), ExecuteRequest{}, callback) + if err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + + foundBlocked := false + for _, e := range events { + if e.Type == "tool_end" { + data := e.Data.(ToolEndData) + if !data.Success && strings.Contains(data.Output, "blocked") { + foundBlocked = true + } + } + } + if !foundBlocked { + t.Error("Expected blocked tool result in stream") + } +} + +func TestService_ExecuteStream_ResultTruncation(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, &mockAgentServer{ + agents: []agentexec.ConnectedAgent{ + {AgentID: "agent-1", Hostname: "agent-1"}, + }, + executeFunc: func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + return &agentexec.CommandResultPayload{ + Success: true, + Stdout: strings.Repeat("long output ", 1000), + }, nil + }, + }) + svc.cfg = &config.AIConfig{Enabled: true, AutonomousMode: true, Model: "anthropic:test"} + + iteration := 0 + mock := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + iteration++ + if iteration == 1 { + return &providers.ChatResponse{ + ToolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "run_command", Input: map[string]interface{}{"command": "large-cmd", "target_host": "agent-1"}}, + }, + StopReason: "tool_use", + }, nil + } + // Check if the previous tool result was truncated in the request messages + for _, msg := range req.Messages { + if msg.Role == "user" && msg.ToolResult != nil { + // Use a more flexible check as the exact string might vary + if strings.Contains(msg.ToolResult.Content, "omitted") || strings.Contains(msg.ToolResult.Content, "truncated") { + return &providers.ChatResponse{Content: "Verified truncation", StopReason: "end_turn"}, nil + } + } + } + return &providers.ChatResponse{Content: "Not truncated", StopReason: "end_turn"}, nil + }, + } + svc.provider = mock + + resp, err := svc.ExecuteStream(context.Background(), ExecuteRequest{TargetType: "guest"}, func(StreamEvent) {}) + if err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + + if resp.Content != "Verified truncation" { + t.Errorf("Expected 'Verified truncation', got %s", resp.Content) + } +} + +func TestService_GetCostSummary_NoStore_Truncated(t *testing.T) { + svc := &Service{} // Store is nil + summary := svc.GetCostSummary(30) + if summary.Days != 30 { + t.Errorf("Expected 30 days, got %d", summary.Days) + } + if len(summary.ProviderModels) != 0 { + t.Error("Expected 0 provider models") + } + + // Test truncation branch + summary = svc.GetCostSummary(cost.DefaultMaxDays + 1) + if !summary.Truncated { + t.Error("Expected summary to be truncated") + } +} + +func TestService_PatrolManagement_NilPatrol(t *testing.T) { + svc := &Service{} + + // These should not panic when patrol is nil + svc.StopPatrol() + svc.SetMetricsHistoryProvider(nil) + svc.SetBaselineStore(nil) +} + +func TestService_StartPatrol_EdgeCases(t *testing.T) { + // Case 1: Patrol service is nil + svc := &Service{} + svc.StartPatrol(context.Background()) // Should not panic + + // Case 2: Config is nil + svc.patrolService = &PatrolService{} + svc.StartPatrol(context.Background()) + + // Case 3: Patrol disabled in config + svc.cfg = &config.AIConfig{Enabled: true, PatrolSchedulePreset: "disabled"} + svc.StartPatrol(context.Background()) + + // Case 4: License feature missing + svc.cfg = &config.AIConfig{Enabled: true, PatrolEnabled: true} + svc.licenseChecker = &mockLicenseStore{features: map[string]bool{FeatureAIPatrol: false}} + // This will still call Start but log info. + svc.patrolService = &PatrolService{} +} + +func TestSanitizeError_Extended(t *testing.T) { + tests := []struct { + input error + contains string + }{ + {nil, ""}, + {fmt.Errorf("failed to send command: i/o timeout"), "timed out"}, + {fmt.Errorf("something i/o timeout"), "timeout"}, + {fmt.Errorf("connection refused"), "not be running"}, + {fmt.Errorf("no such host"), "host not found"}, + {fmt.Errorf("context deadline exceeded"), "timed out"}, + {fmt.Errorf("some other error"), "some other error"}, + } + + for _, tt := range tests { + t.Run(tt.contains, func(t *testing.T) { + result := sanitizeError(tt.input) + if tt.input == nil { + if result != nil { + t.Error("Expected nil for nil input") + } + return + } + if !strings.Contains(result.Error(), tt.contains) { + t.Errorf("sanitizeError(%v) = %v, expected to contain %q", tt.input, result, tt.contains) + } + }) + } +} + +func TestService_GetGuestID(t *testing.T) { + svc := NewService(nil, nil) + + tests := []struct { + req ExecuteRequest + expected string + }{ + {ExecuteRequest{TargetType: "", TargetID: ""}, ""}, + {ExecuteRequest{TargetType: "vm", TargetID: ""}, ""}, + {ExecuteRequest{TargetType: "", TargetID: "100"}, ""}, + {ExecuteRequest{TargetType: "vm", TargetID: "100"}, "vm-100"}, + {ExecuteRequest{TargetType: "container", TargetID: "host:ct:abc"}, "container-host:ct:abc"}, + } + + for _, tt := range tests { + result := svc.getGuestID(tt.req) + if result != tt.expected { + t.Errorf("getGuestID(%+v) = %q, expected %q", tt.req, result, tt.expected) + } + } +} + +func TestService_LogRemediation_WithPatrolService(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-log-rem-patrol-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Set up patrol service + stateProvider := &mockStateProvider{} + svc.SetStateProvider(stateProvider) + + patrol := svc.GetPatrolService() + if patrol == nil { + t.Fatal("Expected patrol service") + } + + // Set up remediation log + remLog := NewRemediationLog(RemediationLogConfig{DataDir: tmpDir}) + patrol.remediationLog = remLog + + // Test logging with context name + req := ExecuteRequest{ + TargetID: "vm-100-with-patrol", + TargetType: "vm", + Prompt: "High CPU usage detected", + Context: map[string]interface{}{ + "name": "web-server", + }, + UseCase: "patrol", + } + + svc.logRemediation(req, "top -bn1", "output data", true) + + // Verify the log was created + history := remLog.GetForResource("vm-100-with-patrol", 10) + if len(history) != 1 { + t.Fatalf("Expected 1 record, got %d", len(history)) + } + if history[0].ResourceName != "web-server" { + t.Errorf("Expected resource name 'web-server', got '%s'", history[0].ResourceName) + } + if history[0].Outcome != OutcomeResolved { + t.Errorf("Expected outcome Resolved, got %v", history[0].Outcome) + } + if !history[0].Automatic { + t.Error("Expected Automatic=true for patrol use case") + } +} + +func TestService_LogRemediation_LongPromptTruncation(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-log-rem-truncate-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + stateProvider := &mockStateProvider{} + svc.SetStateProvider(stateProvider) + + patrol := svc.GetPatrolService() + remLog := NewRemediationLog(RemediationLogConfig{DataDir: tmpDir}) + patrol.remediationLog = remLog + + // Create a very long prompt + longPrompt := strings.Repeat("a", 300) + + req := ExecuteRequest{ + TargetID: "vm-100-truncation-test", + TargetType: "vm", + Prompt: longPrompt, + } + + svc.logRemediation(req, "command", "output", false) + + history := remLog.GetForResource("vm-100-truncation-test", 10) + if len(history) != 1 { + t.Fatalf("Expected 1 record, got %d", len(history)) + } + if len(history[0].Problem) > 205 { // 200 + "..." + t.Errorf("Problem should be truncated, got length %d", len(history[0].Problem)) + } + if history[0].Outcome != OutcomeFailed { + t.Errorf("Expected outcome Failed for success=false, got %v", history[0].Outcome) + } +} + +// ============================================================================ +// Knowledge Store Tests +// ============================================================================ + +func TestService_KnowledgeStore_NotConfigured(t *testing.T) { + svc := NewService(nil, nil) + // knowledgeStore is nil + + _, err := svc.GetGuestKnowledge("test-guest") + if err == nil { + t.Error("Expected error when knowledge store not available") + } + + err = svc.SaveGuestNote("guest-1", "name", "vm", "cat", "title", "content") + if err == nil { + t.Error("Expected error when knowledge store not available") + } + + err = svc.DeleteGuestNote("guest-1", "note-1") + if err == nil { + t.Error("Expected error when knowledge store not available") + } +} + +// ============================================================================ +// LoadConfig Advanced Tests +// ============================================================================ + +func TestService_LoadConfig_WithDisabledAI(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-loadconfig-disabled-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + + // Save a disabled config + cfg := config.AIConfig{ + Enabled: false, + } + persistence.SaveAIConfig(cfg) + + svc := NewService(persistence, nil) + err = svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if svc.IsEnabled() { + t.Error("Expected AI to be disabled") + } +} + +func TestService_LoadConfig_SmartFallback(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-loadconfig-smart-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + + // Config with Anthropic model selected but only OpenAI configured + // NewForModel("anthropic:...") will fail, triggering smart fallback to OpenAI + cfg := config.AIConfig{ + Enabled: true, + Model: "anthropic:claude-3-opus", + OpenAIAPIKey: "test-openai-key", + } + persistence.SaveAIConfig(cfg) + + svc := NewService(persistence, nil) + err = svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if svc.provider == nil { + t.Fatal("Expected provider to be set via smart fallback") + } + if svc.provider.Name() != "openai" { + t.Errorf("Expected openai provider, got %s", svc.provider.Name()) + } +} + + +func TestService_LoadConfig_LegacyMigration(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-loadconfig-legacy-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + + // Config with legacy fields but no provider credentials in new format + cfg := config.AIConfig{ + Enabled: true, + Provider: "openai", + APIKey: "legacy-key", + Model: "anthropic:claude-3-5-sonnet", + } + persistence.SaveAIConfig(cfg) + + svc := NewService(persistence, nil) + err = svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Should initialize via migration path + if svc.provider == nil { + t.Error("Expected provider to be initialized via legacy config") + } +} + +func TestService_LoadConfig_LegacyMigration_Failure(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-loadconfig-legacy-fail-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + + // Legacy config with unknown provider to force NewFromConfig to fail + cfg := config.AIConfig{ + Enabled: true, + Provider: "unknown-provider", + APIKey: "key", + Model: "anthropic:claude-opus", + } + persistence.SaveAIConfig(cfg) + + svc := NewService(persistence, nil) + err = svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if svc.provider != nil { + t.Error("Expected provider to be nil on migration failure") + } +} + +func TestService_LoadConfig_SmartFallback_Variants(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-loadconfig-fallback-variants-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + + providers := []struct { + name string + key string + expect string + }{ + {"anthropic", "AnthropicAPIKey", "anthropic"}, + {"gemini", "GeminiAPIKey", "gemini"}, + {"deepseek", "DeepSeekAPIKey", "openai"}, // DeepSeek uses OpenAI client + {"ollama", "OllamaBaseURL", "ollama"}, + } + + for _, p := range providers { + t.Run(p.name, func(t *testing.T) { + cfg := config.AIConfig{ + Enabled: true, + Model: "openai:gpt-4", // selected + } + + // Set the specific key + switch p.name { + case "anthropic": + cfg.AnthropicAPIKey = "key" + case "gemini": + cfg.GeminiAPIKey = "key" + case "deepseek": + cfg.DeepSeekAPIKey = "key" + case "ollama": + cfg.OllamaBaseURL = "http://localhost:11434" + } + + persistence.SaveAIConfig(cfg) + svc := NewService(persistence, nil) + err = svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if svc.provider == nil || svc.provider.Name() != p.expect { + t.Errorf("Expected %s provider via fallback, got %v", p.expect, svc.provider) + } + }) + } +} + + + + +func TestService_LoadConfig_NoProviders(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-loadconfig-none-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + + // Enabled but no providers configured + cfg := config.AIConfig{ + Enabled: true, + } + persistence.SaveAIConfig(cfg) + + svc := NewService(persistence, nil) + err = svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if svc.provider != nil { + t.Error("Expected provider to be nil when no credentials configured") + } +} + +func TestService_LoadConfig_PersistenceError(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + + // Write invalid JSON to ai.enc (this is tricky because it's encrypted) + // Actually, we can just make the directory unreadable or use a non-directory for the path + aiFilePath := filepath.Join(tmpDir, "ai.enc") + os.MkdirAll(aiFilePath, 0755) // Create directory where file should be + + svc := NewService(persistence, nil) + err := svc.LoadConfig() + if err == nil { + t.Error("Expected error when LoadAIConfig fails") + } +} + +func TestService_LoadConfig_FallbackFailure(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + + // Create a bit of a weird situation: Ollama is configured (which always "succeeds" to create client) + // but we want providers.NewForModel to fail for the fallback model. + // Actually, NewForModel only fails if provider is unknown or key is missing. + + // Selected model is unknown provider, triggers fallback. + // Fallback provider is Ollama. + cfg := config.AIConfig{ + Enabled: true, + Model: "unknown:model", + OllamaBaseURL: "http://localhost:11434", + } + persistence.SaveAIConfig(cfg) + + svc := NewService(persistence, nil) + // We need providers.NewForModel to fail for Ollama fallback model? + // That's hard because Ollama NewForProvider always succeeds. + + err := svc.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + // It should fall back to Ollama + if svc.provider.Name() != "ollama" { + t.Errorf("Expected ollama provider, got %s", svc.provider.Name()) + } +} + +// ============================================================================ +// GetConfig Tests +// ============================================================================ + +func TestService_GetConfig_DefaultsWhenNil(t *testing.T) { + svc := NewService(nil, nil) + svc.cfg = nil + + cfg := svc.GetConfig() + if cfg != nil { + t.Error("Expected nil config when not configured") + } +} + +func TestService_GetConfig_ReturnsCopy(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-getconfig-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + persistence.SaveAIConfig(config.AIConfig{ + Enabled: true, + Model: "test:model", + }) + + svc := NewService(persistence, nil) + svc.LoadConfig() + + cfg := svc.GetConfig() + if cfg == nil { + t.Fatal("Expected config to be returned") + } + + if cfg.Model != "test:model" { + t.Errorf("Expected model 'test:model', got '%s'", cfg.Model) + } +} + +// ============================================================================ +// IsAutonomous Tests +// ============================================================================ + +func TestService_IsAutonomous_Variations(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-autonomous-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + + // Test with AutonomousMode = false + cfg := config.AIConfig{ + Enabled: true, + AutonomousMode: false, + } + persistence.SaveAIConfig(cfg) + + svc := NewService(persistence, nil) + svc.LoadConfig() + + if svc.IsAutonomous() { + t.Error("Expected not autonomous when mode is false") + } + + // Test with AutonomousMode = true + cfg.AutonomousMode = true + persistence.SaveAIConfig(cfg) + svc.LoadConfig() + + if !svc.IsAutonomous() { + t.Error("Expected autonomous when mode is true") + } +} + +// ============================================================================ +// Knowledge Store Tests - Extended +// ============================================================================ + +func TestService_KnowledgeStore_Success(t *testing.T) { + tmpDir := t.TempDir() + store, _ := knowledge.NewStore(tmpDir) + + svc := NewService(nil, nil) + svc.knowledgeStore = store + + _, err := svc.GetGuestKnowledge("vm-100") + if err != nil { + t.Errorf("GetGuestKnowledge failed: %v", err) + } + + err = svc.SaveGuestNote("vm-100", "test", "vm", "general", "title", "content") + if err != nil { + t.Errorf("SaveGuestNote failed: %v", err) + } + + err = svc.DeleteGuestNote("vm-100", "general-1") + if err != nil { + t.Errorf("DeleteGuestNote failed: %v", err) + } +} + +// ============================================================================ +// ListModelsWithCache - Extended +// ============================================================================ + +func TestService_ListModelsWithCache_ProviderErrors(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-listmodels-err-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + persistence.SaveAIConfig(config.AIConfig{ + Enabled: true, + OpenAIAPIKey: "test-key", + }) + + svc := NewService(persistence, nil) + + // The provider will fail because it's a real OpenAI provider with dummy key + _, cached, err := svc.ListModelsWithCache(context.Background()) + if err != nil { + t.Fatalf("ListModelsWithCache should not fail even if providers fail: %v", err) + } + if cached { + t.Error("Expected no cache hit") + } +} + +// ============================================================================ +// GetDebugContext - Truncation +// ============================================================================ + +func TestService_GetDebugContext_Truncation(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Large custom context + svc.cfg = &config.AIConfig{ + CustomContext: strings.Repeat("x", 300), + } + + stateProvider := &mockStateProvider{} + svc.stateProvider = stateProvider + + // Add many VMs to trigger truncation + state := models.StateSnapshot{} + for i := 0; i < 15; i++ { + state.VMs = append(state.VMs, models.VM{VMID: i, Name: fmt.Sprintf("vm-%d", i), Node: "node"}) + state.Containers = append(state.Containers, models.Container{VMID: i + 100, Name: fmt.Sprintf("ct-%d", i), Node: "node"}) + } + stateProvider.state = state + + result := svc.GetDebugContext(ExecuteRequest{}) + + preview := result["custom_context_preview"].(string) + if !strings.HasSuffix(preview, "...") || len(preview) != 203 { + t.Errorf("Expected truncated preview, got length %d", len(preview)) + } + + sampleVMs := result["sample_vms"].([]string) + if len(sampleVMs) != 10 { + t.Errorf("Expected 10 sample VMs, got %d", len(sampleVMs)) + } + + sampleCTs := result["sample_containers"].([]string) + if len(sampleCTs) != 10 { + t.Errorf("Expected 10 sample containers, got %d", len(sampleCTs)) + } +} + +func TestService_GetDebugContext_ShortContext(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{ + CustomContext: "short context", + } + + result := svc.GetDebugContext(ExecuteRequest{}) + if result["custom_context_preview"] != "short context" { + t.Errorf("Expected 'short context', got %v", result["custom_context_preview"]) + } +} + + +func TestService_ListModelsWithCache_ConfigErrors(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + + // Force LoadAIConfig failure + aiFilePath := filepath.Join(tmpDir, "ai.enc") + os.MkdirAll(aiFilePath, 0755) + + svc := NewService(persistence, nil) + _, _, err := svc.ListModelsWithCache(context.Background()) + if err == nil { + t.Error("Expected error when LoadAIConfig fails") + } +} + +// ============================================================================ +// SetResourceURL Tests - Extended +// ============================================================================ + +func TestService_SetResourceURL_DockerType(t *testing.T) { + svc := NewService(nil, nil) + mockMeta := &mockMetadataProvider{} + svc.metadataProvider = mockMeta + + err := svc.SetResourceURL("docker", "host:container:abc123", "http://example.com:8080") + if err != nil { + t.Errorf("SetResourceURL failed: %v", err) + } + if mockMeta.lastDockerID != "host:container:abc123" { + t.Errorf("Expected docker ID 'host:container:abc123', got '%s'", mockMeta.lastDockerID) + } +} + +func TestService_SetResourceURL_HostType(t *testing.T) { + svc := NewService(nil, nil) + mockMeta := &mockMetadataProvider{} + svc.metadataProvider = mockMeta + + err := svc.SetResourceURL("host", "my-host", "http://host.local:9090") + if err != nil { + t.Errorf("SetResourceURL failed: %v", err) + } + if mockMeta.lastHostID != "my-host" { + t.Errorf("Expected host ID 'my-host', got '%s'", mockMeta.lastHostID) + } +} + +func TestService_SetResourceURL_UnknownType(t *testing.T) { + svc := NewService(nil, nil) + mockMeta := &mockMetadataProvider{} + svc.metadataProvider = mockMeta + + err := svc.SetResourceURL("unknown", "id", "http://example.com") + if err == nil { + t.Error("Expected error for unknown resource type") + } +} + +func TestService_SetResourceURL_NoMetadataProvider(t *testing.T) { + svc := NewService(nil, nil) + svc.metadataProvider = nil + + err := svc.SetResourceURL("guest", "vm-100", "http://example.com") + if err == nil { + t.Error("Expected error when metadata provider not available") + } +} + +func TestService_BuildUserAnnotationsContext_Variants(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Guest metadata with no name + guestMeta := map[string]*config.GuestMetadata{ + "guest-no-name": { + ID: "guest-no-name", + Notes: []string{"Note1"}, + }, + } + data, _ := json.Marshal(guestMeta) + os.WriteFile(filepath.Join(tmpDir, "guest_metadata.json"), data, 0644) + + // Docker metadata with long ID and simple ID (no host part) + dockerMeta := map[string]*config.DockerMetadata{ + "host:container:verylongcontainerid123456": { + ID: "host:container:verylongcontainerid123456", + Notes: []string{"DockerNote1"}, + }, + "simple-id": { + ID: "simple-id", + Notes: []string{"SimpleDockerNote"}, + }, + } + data, _ = json.Marshal(dockerMeta) + os.WriteFile(filepath.Join(tmpDir, "docker_metadata.json"), data, 0644) + + ctx := svc.buildUserAnnotationsContext() + + // Check guest ID fallback + if !strings.Contains(ctx, "guest-no-name") { + t.Error("Expected guest ID when LastKnownName is missing") + } + + // Check long container ID truncation + if !strings.Contains(ctx, "verylongcont") { + t.Error("Container name should contain partial ID") + } + + // Check simple docker ID + if !strings.Contains(ctx, "simple-id") { + t.Error("Should handle simple docker ID") + } +} + +func TestService_GetDebugContext_Extended(t *testing.T) { + tmpDir := t.TempDir() + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Mock state provider + stateProvider := &mockStateProvider{} + state := models.StateSnapshot{} + for i := 0; i < 15; i++ { + state.VMs = append(state.VMs, models.VM{Name: fmt.Sprintf("VM-%d", i), VMID: i, Node: "node"}) + state.Containers = append(state.Containers, models.Container{Name: fmt.Sprintf("CT-%d", i), VMID: i + 100, Node: "node"}) + } + stateProvider.state = state + svc.SetStateProvider(stateProvider) + + svc.cfg = &config.AIConfig{ + Enabled: true, + CustomContext: strings.Repeat("x", 300), + } + + debug := svc.GetDebugContext(ExecuteRequest{}) + + if len(debug["sample_vms"].([]string)) != 10 { + t.Errorf("Expected 10 sample VMs, got %d", len(debug["sample_vms"].([]string))) + } + if len(debug["sample_containers"].([]string)) != 10 { + t.Errorf("Expected 10 sample containers, got %d", len(debug["sample_containers"].([]string))) + } + if !strings.HasSuffix(debug["custom_context_preview"].(string), "...") { + t.Error("Expected custom context preview to be truncated") + } +} diff --git a/internal/ai/service_remediation_test.go b/internal/ai/service_remediation_test.go new file mode 100644 index 0000000..19960ac --- /dev/null +++ b/internal/ai/service_remediation_test.go @@ -0,0 +1,158 @@ +package ai + +import ( + "os" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// ============================================================================ +// Remediation Tests +// ============================================================================ + +func TestService_Remediation(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-ai-rem-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + patrol := NewPatrolService(svc, nil) + svc.patrolService = patrol // Manual link for test + + remLog := NewRemediationLog(RemediationLogConfig{DataDir: tmpDir}) + svc.SetRemediationLog(remLog) + + // Test logRemediation + req := ExecuteRequest{ + TargetID: "vm-101", + TargetType: "vm", + Prompt: "High CPU", + } + svc.logRemediation(req, "top", "output", true) + + // Verify it was logged + history := remLog.GetForResource("vm-101", 5) + if len(history) != 1 { + t.Fatalf("Expected 1 remediation record, got %d", len(history)) + } + if history[0].Action != "top" { + t.Errorf("Expected action 'top', got %s", history[0].Action) + } + + // Test buildRemediationContext + ctx := svc.buildRemediationContext("vm-101", "High CPU") + if !containsString(ctx, "Remediation History for This Resource") { + t.Error("Expected remediation history section in context") + } + if !containsString(ctx, "top") { + t.Error("Expected logged action in context") + } + + // Test with similar problem + ctx2 := svc.buildRemediationContext("other-vm", "High CPU") + if !containsString(ctx2, "Past Successful Fixes for Similar Issues") { + t.Error("Expected successful fixes section in context for similar problem") + } +} + +func TestService_Remediation_NoPatrolService(t *testing.T) { + svc := NewService(nil, nil) + // patrolService is nil, logRemediation should handle gracefully + + req := ExecuteRequest{ + TargetID: "vm-102", + TargetType: "vm", + Prompt: "Test", + } + // Should not panic + svc.logRemediation(req, "test", "output", true) +} + +func TestService_Remediation_NoRemediationLog(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-ai-rem-test-nolog-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + patrol := NewPatrolService(svc, nil) + svc.patrolService = patrol + // remediationLog is nil on patrol + + req := ExecuteRequest{ + TargetID: "vm-103", + TargetType: "vm", + Prompt: "Test", + } + // Should not panic + svc.logRemediation(req, "test", "output", true) +} + +func TestService_BuildRemediationContext_Empty(t *testing.T) { + svc := NewService(nil, nil) + + // With no remediationLog set, should return empty + ctx := svc.buildRemediationContext("unknown", "Unknown problem") + if ctx != "" { + t.Error("Expected empty context when no remediation log") + } +} + +// ============================================================================ +// String Utility Tests +// ============================================================================ + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + max int + expected string + }{ + {"empty string", "", 5, ""}, + {"under limit", "123", 5, "123"}, + {"at limit", "12345", 5, "12345"}, + {"over limit", "1234567890", 5, "12..."}, + {"exactly at max minus ellipsis", "1234", 4, "1234"}, + {"just over", "12345", 4, "1..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := truncateString(tt.input, tt.max) + if result != tt.expected { + t.Errorf("truncateString(%q, %d) = %q, expected %q", + tt.input, tt.max, result, tt.expected) + } + }) + } +} + +func TestContainsString(t *testing.T) { + tests := []struct { + haystack string + needle string + expected bool + }{ + {"hello world", "world", true}, + {"hello world", "foo", false}, + {"", "test", false}, + {"test", "", true}, + } + + for _, tt := range tests { + if containsString(tt.haystack, tt.needle) != tt.expected { + t.Errorf("containsString(%q, %q) expected %v", + tt.haystack, tt.needle, tt.expected) + } + } +} + + + diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index e30fe21..a0d513e 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -2,8 +2,14 @@ package ai import ( "context" + "errors" + "os" + "strings" "testing" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/resources" "github.com/rcourtman/pulse-go-rewrite/internal/models" ) @@ -450,19 +456,379 @@ func TestService_SetProviders(t *testing.T) { svc.SetCorrelationDetector(nil) } -// Helper functions -func hasPrefix(s, prefix string) bool { - if len(s) < len(prefix) { - return false +func TestService_Execute(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-ai-execute-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) } - return s[:len(prefix)] == prefix + defer os.RemoveAll(tmpDir) + + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Set enabled config + svc.cfg = &config.AIConfig{ + Enabled: true, + } + + // Set mock provider + mockP := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{ + Content: "Hello from mock AI", + Model: "mock-model", + }, nil + }, + } + svc.provider = mockP + + req := ExecuteRequest{ + Prompt: "Hello", + Model: "anthropic:test-model", // Use known provider with no key to force fallback + } + + resp, err := svc.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if resp.Content != "Hello from mock AI" { + t.Errorf("Expected 'Hello from mock AI', got '%s'", resp.Content) + } +} + +func TestService_Execute_Error(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-execute-error-test-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{Enabled: true} + + mockP := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + return nil, errors.New("API error") + }, + } + svc.provider = mockP + + _, err := svc.Execute(context.Background(), ExecuteRequest{ + Prompt: "Hello", + Model: "anthropic:test-model", + }) + if err == nil { + t.Error("Expected error, got nil") + } +} + +func TestService_ExecuteStream(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-execute-stream-test-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{Enabled: true} + + mockP := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{ + Content: "Streaming response", + Model: "mock-model", + }, nil + }, + } + svc.provider = mockP + + var events []StreamEvent + callback := func(ev StreamEvent) { + events = append(events, ev) + } + + resp, err := svc.ExecuteStream(context.Background(), ExecuteRequest{ + Prompt: "Hello", + Model: "anthropic:test-model", // Use known provider with no key to force fallback + }, callback) + if err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + + if resp.Content != "Streaming response" { + t.Errorf("Expected 'Streaming response', got '%s'", resp.Content) + } + + // Should have at least one content event + foundContent := false + for _, ev := range events { + if ev.Type == "content" && ev.Data == "Streaming response" { + foundContent = true + break + } + } + if !foundContent { + t.Error("Expected content event in stream") + } +} + +func TestService_Execute_WithTool(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-execute-tool-test-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{Enabled: true} + + // Mock provider that returns a tool call + mockP := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + // First call returns a tool call + if len(req.Messages) <= 1 { + return &providers.ChatResponse{ + Content: "I will run a command", + Model: "mock-model", + ToolCalls: []providers.ToolCall{ + { + ID: "call-123", + Name: "run_command", + Input: map[string]interface{}{"command": "uptime"}, + }, + }, + StopReason: "tool_use", + }, nil + } + // Second call returns the final answer + return &providers.ChatResponse{ + Content: "Command executed successfully", + Model: "mock-model", + }, nil + }, + } + svc.provider = mockP + + resp, err := svc.Execute(context.Background(), ExecuteRequest{ + Prompt: "Run uptime", + Model: "anthropic:test-model", + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if len(resp.ToolCalls) != 1 { + t.Errorf("Expected 1 tool call, got %d", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].Name != "run_command" { + t.Errorf("Expected run_command, got %s", resp.ToolCalls[0].Name) + } +} + +func TestService_Execute_SystemPrompt(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-prompt-test-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{ + Enabled: true, + CustomContext: "This is my home lab", + } + + mockRP := &mockResourceProvider{ + getStatsFunc: func() resources.StoreStats { + return resources.StoreStats{TotalResources: 1} + }, + } + svc.SetResourceProvider(mockRP) + + var capturedSystemPrompt string + mockP := &mockProvider{ + chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) { + capturedSystemPrompt = req.System + return &providers.ChatResponse{Content: "OK"}, nil + }, + } + svc.provider = mockP + + _, err := svc.Execute(context.Background(), ExecuteRequest{ + Prompt: "Hello", + Model: "anthropic:test-model", + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if !containsString(capturedSystemPrompt, "This is my home lab") { + t.Error("System prompt should contain custom context") + } + if !containsString(capturedSystemPrompt, "## Unified Infrastructure View") { + t.Error("System prompt should contain unified infrastructure view section") + } +} + +func TestService_KnowledgeMethods(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-knowledge-test-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + if svc.knowledgeStore == nil { + t.Fatal("knowledgeStore should be initialized") + } + + guestID := "test-guest" + err := svc.SaveGuestNote(guestID, "Guest 1", "vm", "test", "Title", "Content") + if err != nil { + t.Fatalf("SaveGuestNote failed: %v", err) + } + + kn, err := svc.GetGuestKnowledge(guestID) + if err != nil { + t.Fatalf("GetGuestKnowledge failed: %v", err) + } + if len(kn.Notes) != 1 { + t.Errorf("Expected 1 note, got %d", len(kn.Notes)) + } + + noteID := kn.Notes[0].ID + err = svc.DeleteGuestNote(guestID, noteID) + if err != nil { + t.Fatalf("DeleteGuestNote failed: %v", err) + } + + kn, _ = svc.GetGuestKnowledge(guestID) + if len(kn.Notes) != 0 { + t.Error("Note should have been deleted") + } +} + +func TestService_Reload(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-reload-test-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + err := svc.Reload() + if err != nil { + t.Fatalf("Reload failed: %v", err) + } +} + +func TestService_ListModels(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-list-models-test-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Mock config with no providers + svc.cfg = &config.AIConfig{Enabled: true} + + // Should return empty list when no providers configured + models, err := svc.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels failed: %v", err) + } + if len(models) != 0 { + t.Errorf("Expected 0 models, got %d", len(models)) + } +} + +func TestService_TestConnection(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "pulse-ai-test-conn-*") + defer os.RemoveAll(tmpDir) + persistence := config.NewConfigPersistence(tmpDir) + svc := NewService(persistence, nil) + + // Test with no config + err := svc.TestConnection(context.Background()) + if err == nil { + t.Error("Expected error with no config") + } +} + +func TestService_SetMetricsHistoryProvider(t *testing.T) { + svc := NewService(nil, nil) + svc.SetMetricsHistoryProvider(nil) + if svc.stateProvider != nil { + t.Error("Expected stateProvider to be nil") + } +} + +func TestService_LicenseGating(t *testing.T) { + svc := NewService(nil, nil) + + // Default should be true when no checker is set (dev mode) + if !svc.HasLicenseFeature("test") { + t.Error("Expected true for no license checker (dev mode)") + } + + mockLC := &mockLicenseChecker{hasFeature: true} + svc.SetLicenseChecker(mockLC) + + if !svc.HasLicenseFeature("test") { + t.Error("Expected true with mock license checker") + } + + tier, ok := svc.GetLicenseState() + if tier != "active" || !ok { + t.Errorf("Expected active tier from mock, got %s, %v", tier, ok) + } +} + +func TestService_IsAutonomous(t *testing.T) { + svc := NewService(nil, nil) + svc.cfg = &config.AIConfig{AutonomousMode: true} + if !svc.IsAutonomous() { + t.Error("Expected true") + } + + svc.cfg.AutonomousMode = false + if svc.IsAutonomous() { + t.Error("Expected false") + } +} + +type mockLicenseChecker struct { + hasFeature bool +} + +func (m *mockLicenseChecker) HasFeature(f string) bool { return m.hasFeature } +func (m *mockLicenseChecker) GetLicenseStateString() (string, bool) { + return "active", true +} + +func TestService_RunCommand(t *testing.T) { + svc := NewService(nil, nil) + // Should fail with no agent server + resp, err := svc.RunCommand(context.Background(), RunCommandRequest{Command: "uptime"}) + if err != nil { + t.Fatalf("RunCommand failed: %v", err) + } + if resp.Success { + t.Error("Expected success=false with no agent server") + } +} + +func TestService_ExecuteTool(t *testing.T) { + svc := NewService(nil, nil) + ctx := context.Background() + req := ExecuteRequest{Prompt: "test"} + tc := providers.ToolCall{ + ID: "call-1", + Name: "run_command", + Input: map[string]interface{}{"command": "uptime"}, + } + + output, exec := svc.executeTool(ctx, req, tc) + if !containsString(output, "agent server not available") { + t.Errorf("Expected agent server error, got: %s", output) + } + if exec.Success { + t.Error("Expected failure") + } + if exec.Name != "run_command" { + t.Errorf("Expected run_command, got %s", exec.Name) + } +} + +// Helper functions (restored) +func hasPrefix(s, prefix string) bool { + return strings.HasPrefix(s, prefix) } func containsString(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false + return strings.Contains(s, substr) } diff --git a/internal/ai/service_tools_test.go b/internal/ai/service_tools_test.go new file mode 100644 index 0000000..2d36cc7 --- /dev/null +++ b/internal/ai/service_tools_test.go @@ -0,0 +1,152 @@ +package ai + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestFetchURL(t *testing.T) { + os.Setenv("PULSE_AI_ALLOW_LOOPBACK", "true") + defer os.Unsetenv("PULSE_AI_ALLOW_LOOPBACK") + + // Start a local test server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello, world") + })) + defer ts.Close() + + svc := NewService(nil, nil) + ctx := context.Background() + + // Test successful fetch + result, err := svc.fetchURL(ctx, ts.URL) + if err != nil { + t.Fatalf("fetchURL failed: %v", err) + } + if !containsString(result, "Hello, world") { + t.Errorf("Expected 'Hello, world' in result, got: %s", result) + } + + // Test blocked host (localhost) + _, err = svc.fetchURL(ctx, "http://localhost:8080") + if err == nil || !containsString(err.Error(), "blocked") { + t.Errorf("Expected blocked host error, got: %v", err) + } + + // Test invalid URL + _, err = svc.fetchURL(ctx, "not-a-url") + if err == nil { + t.Error("Expected error for invalid URL") + } + + // Test scheme check + _, err = svc.fetchURL(ctx, "ftp://example.com") + if err == nil || !containsString(err.Error(), "only http/https") { + t.Errorf("Expected scheme error, got: %v", err) + } +} + +func TestParseAndValidateFetchURL(t *testing.T) { + ctx := context.Background() + + tests := []struct { + url string + wantErr bool + errSub string + }{ + {"http://example.com", false, ""}, + {"https://example.com/path", false, ""}, + {" http://example.com ", false, ""}, + {"", true, "url is required"}, + {"http://localhost", true, "blocked"}, + {"http://127.0.0.1", true, "blocked"}, + {"ftp://example.com", true, "only http/https"}, + {"http://user:pass@example.com", true, "credentials"}, + {"http://example.com/#frag", true, "fragments"}, + {"http://", true, "host"}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + _, err := parseAndValidateFetchURL(ctx, tt.url) + if (err != nil) != tt.wantErr { + t.Fatalf("parseAndValidateFetchURL() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && tt.errSub != "" && !containsString(err.Error(), tt.errSub) { + t.Errorf("error %v does not contain %q", err, tt.errSub) + } + }) + } +} + +func TestIsBlockedFetchIP(t *testing.T) { + tests := []struct { + ip string + blocked bool + }{ + {"127.0.0.1", true}, + {"::1", true}, + {"0.0.0.0", true}, + {"169.254.1.1", true}, + {"192.168.1.1", false}, // Private is allowed + {"8.8.8.8", false}, // Global is allowed + {"224.0.0.1", true}, // Multicast + } + + for _, tt := range tests { + ip := net.ParseIP(tt.ip) + if got := isBlockedFetchIP(ip); got != tt.blocked { + t.Errorf("isBlockedFetchIP(%s) = %v, want %v", tt.ip, got, tt.blocked) + } + } + + if !isBlockedFetchIP(nil) { + t.Error("nil IP should be blocked") + } +} + +func TestFetchURL_SizeLimit(t *testing.T) { + os.Setenv("PULSE_AI_ALLOW_LOOPBACK", "true") + defer os.Unsetenv("PULSE_AI_ALLOW_LOOPBACK") + + // Server that returns 100KB of data + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := make([]byte, 100*1024) + for i := range data { + data[i] = 'a' + } + w.Write(data) + })) + defer ts.Close() + + svc := NewService(nil, nil) + result, err := svc.fetchURL(context.Background(), ts.URL) + if err != nil { + t.Fatalf("fetchURL failed: %v", err) + } + if !containsString(result, "truncated at 64KB") { + t.Error("Expected result to be truncated") + } +} + +func TestFetchURL_RedirectLimit(t *testing.T) { + os.Setenv("PULSE_AI_ALLOW_LOOPBACK", "true") + defer os.Unsetenv("PULSE_AI_ALLOW_LOOPBACK") + + var ts *httptest.Server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, ts.URL, http.StatusFound) + })) + defer ts.Close() + + svc := NewService(nil, nil) + _, err := svc.fetchURL(context.Background(), ts.URL) + if err == nil || !containsString(err.Error(), "too many redirects") { + t.Errorf("Expected redirect limit error, got: %v", err) + } +}