Pulse/internal/ai/service_test.go
rcourtman cb960a04e3 Add comprehensive AI test coverage
- Add integration tests for Ollama provider (17 tests against real API)
- Add unit tests for baseline, correlation, patterns, memory, knowledge, cost packages
- Add context formatter and builder tests
- Add factory tests for provider initialization
- Add Makefile targets: test-integration, test-all
- Clean up test theatre (removed struct field tests)

Integration tests require Ollama at OLLAMA_URL (default: 192.168.0.124:11434)
Run with: make test-integration
2025-12-16 12:33:06 +00:00

468 lines
11 KiB
Go

package ai
import (
"context"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestNewService(t *testing.T) {
svc := NewService(nil, nil)
if svc == nil {
t.Fatal("Expected non-nil service")
}
// Should not be enabled without config
if svc.IsEnabled() {
t.Error("Expected service to not be enabled without config")
}
// Cost store should be initialized
if svc.costStore == nil {
t.Error("Expected cost store to be initialized")
}
}
func TestService_IsEnabled(t *testing.T) {
svc := NewService(nil, nil)
// Without config
if svc.IsEnabled() {
t.Error("Expected not enabled without config")
}
// Should remain not enabled since we can't create a real provider in unit tests
}
func TestService_GetConfig_Nil(t *testing.T) {
svc := NewService(nil, nil)
cfg := svc.GetConfig()
if cfg != nil {
t.Error("Expected nil config before loading")
}
}
func TestService_GetAIConfig(t *testing.T) {
svc := NewService(nil, nil)
cfg := svc.GetAIConfig()
if cfg != nil {
t.Error("Expected nil AI config before loading")
}
}
func TestService_GetPatrolService_Initial(t *testing.T) {
svc := NewService(nil, nil)
patrol := svc.GetPatrolService()
if patrol != nil {
t.Error("Expected nil patrol service before state provider is set")
}
}
func TestService_GetAlertTriggeredAnalyzer_Initial(t *testing.T) {
svc := NewService(nil, nil)
analyzer := svc.GetAlertTriggeredAnalyzer()
if analyzer != nil {
t.Error("Expected nil analyzer before state provider is set")
}
}
func TestService_SetStateProvider(t *testing.T) {
svc := NewService(nil, nil)
stateProvider := &mockStateProvider{
state: models.StateSnapshot{
Nodes: []models.Node{
{ID: "node-1", Name: "test-node"},
},
},
}
svc.SetStateProvider(stateProvider)
// Patrol service should now be initialized
patrol := svc.GetPatrolService()
if patrol == nil {
t.Error("Expected patrol service to be initialized after setting state provider")
}
// Alert triggered analyzer should now be initialized
analyzer := svc.GetAlertTriggeredAnalyzer()
if analyzer == nil {
t.Error("Expected alert analyzer to be initialized after setting state provider")
}
}
func TestService_GetCostSummary_NoStore(t *testing.T) {
svc := NewService(nil, nil)
svc.costStore = nil
summary := svc.GetCostSummary(30)
// Should return an empty summary
if summary.Days != 30 {
t.Errorf("Expected days 30, got %d", summary.Days)
}
}
func TestService_GetCostSummary_WithStore(t *testing.T) {
svc := NewService(nil, nil)
summary := svc.GetCostSummary(7)
// Should return a valid summary
if summary.Days != 7 {
t.Errorf("Expected days 7, got %d", summary.Days)
}
}
func TestService_ListCostEvents_NoStore(t *testing.T) {
svc := NewService(nil, nil)
svc.costStore = nil
events := svc.ListCostEvents(7)
if events != nil {
t.Error("Expected nil events when no store")
}
}
func TestService_ClearCostHistory_NoStore(t *testing.T) {
svc := NewService(nil, nil)
svc.costStore = nil
err := svc.ClearCostHistory()
if err != nil {
t.Errorf("Expected no error when no store, got %v", err)
}
}
func TestService_AcquireExecutionSlot(t *testing.T) {
svc := NewService(nil, nil)
ctx := context.Background()
// Acquire a chat slot
release, err := svc.acquireExecutionSlot(ctx, "chat")
if err != nil {
t.Fatalf("Failed to acquire chat slot: %v", err)
}
if release == nil {
t.Fatal("Expected non-nil release function")
}
release()
// Acquire a patrol slot
release, err = svc.acquireExecutionSlot(ctx, "patrol")
if err != nil {
t.Fatalf("Failed to acquire patrol slot: %v", err)
}
release()
// Empty use case should default to chat
release, err = svc.acquireExecutionSlot(ctx, "")
if err != nil {
t.Fatalf("Failed to acquire slot with empty use case: %v", err)
}
release()
}
// Note: TestService_AcquireExecutionSlot_Canceled removed - the slot acquisition
// doesn't immediately check context cancel in the select since slots are available
func TestService_EnforceBudget_NoBudget(t *testing.T) {
svc := NewService(nil, nil)
// No budget set - should pass
err := svc.enforceBudget("chat")
if err != nil {
t.Errorf("Expected no error without budget, got %v", err)
}
}
func TestService_StartPatrol_NoPatrol(t *testing.T) {
svc := NewService(nil, nil)
// Should not panic when patrol is nil
svc.StartPatrol(context.Background())
}
func TestService_StopPatrol_NoPatrol(t *testing.T) {
svc := NewService(nil, nil)
// Should not panic when patrol is nil
svc.StopPatrol()
}
func TestService_ReconfigurePatrol_NoPatrol(t *testing.T) {
svc := NewService(nil, nil)
// Should not panic when patrol is nil
svc.ReconfigurePatrol()
}
func TestService_LookupGuestsByVMID(t *testing.T) {
svc := NewService(nil, nil)
stateProvider := &mockStateProvider{
state: models.StateSnapshot{
VMs: []models.VM{
{ID: "vm-100", VMID: 100, Name: "web-server", Node: "pve-1"},
{ID: "vm-101", VMID: 101, Name: "database", Node: "pve-1"},
},
Containers: []models.Container{
{ID: "ct-200", VMID: 200, Name: "nginx", Node: "pve-1"},
},
},
}
svc.SetStateProvider(stateProvider)
// Test finding a VM
guests := svc.lookupGuestsByVMID(100, "")
if len(guests) != 1 {
t.Errorf("Expected 1 guest for VMID 100, got %d", len(guests))
}
if len(guests) > 0 && guests[0].Name != "web-server" {
t.Errorf("Expected name 'web-server', got '%s'", guests[0].Name)
}
// Test finding a container
guests = svc.lookupGuestsByVMID(200, "")
if len(guests) != 1 {
t.Errorf("Expected 1 guest for VMID 200, got %d", len(guests))
}
if len(guests) > 0 && guests[0].Type != "lxc" {
t.Errorf("Expected type 'lxc', got '%s'", guests[0].Type)
}
// Test not found
guests = svc.lookupGuestsByVMID(999, "")
if len(guests) != 0 {
t.Errorf("Expected 0 guests for VMID 999, got %d", len(guests))
}
}
func TestService_LookupGuestsByVMID_NoStateProvider(t *testing.T) {
svc := NewService(nil, nil)
guests := svc.lookupGuestsByVMID(100, "")
if guests != nil {
t.Error("Expected nil guests without state provider")
}
}
func TestService_LookupNodeForVMID(t *testing.T) {
svc := NewService(nil, nil)
stateProvider := &mockStateProvider{
state: models.StateSnapshot{
VMs: []models.VM{
{ID: "vm-100", VMID: 100, Name: "web-server", Node: "pve-1"},
},
},
}
svc.SetStateProvider(stateProvider)
node, name, guestType := svc.lookupNodeForVMID(100)
if node != "pve-1" {
t.Errorf("Expected node 'pve-1', got '%s'", node)
}
if name != "web-server" {
t.Errorf("Expected name 'web-server', got '%s'", name)
}
if guestType != "qemu" {
t.Errorf("Expected type 'qemu', got '%s'", guestType)
}
}
func TestExtractVMIDFromCommand(t *testing.T) {
tests := []struct {
name string
command string
expectedVMID int
expectedOwner bool
expectedFound bool
}{
{
name: "pct exec",
command: "pct exec 100 -- ls",
expectedVMID: 100,
expectedOwner: true,
expectedFound: true,
},
{
name: "pct enter",
command: "pct enter 101",
expectedVMID: 101,
expectedOwner: true,
expectedFound: true,
},
{
name: "qm start",
command: "qm start 200",
expectedVMID: 200,
expectedOwner: true,
expectedFound: true,
},
{
name: "qm guest exec",
command: "qm guest exec 201 ls",
expectedVMID: 201,
expectedOwner: true,
expectedFound: true,
},
{
name: "vzdump",
command: "vzdump 100",
expectedVMID: 100,
expectedOwner: false, // vzdump is cluster-aware
expectedFound: true,
},
{
name: "no vmid command",
command: "ls -la",
expectedVMID: 0,
expectedOwner: false,
expectedFound: false,
},
{
name: "empty command",
command: "",
expectedVMID: 0,
expectedOwner: false,
expectedFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vmid, owner, found := extractVMIDFromCommand(tt.command)
if vmid != tt.expectedVMID {
t.Errorf("Expected VMID %d, got %d", tt.expectedVMID, vmid)
}
if owner != tt.expectedOwner {
t.Errorf("Expected owner %v, got %v", tt.expectedOwner, owner)
}
if found != tt.expectedFound {
t.Errorf("Expected found %v, got %v", tt.expectedFound, found)
}
})
}
}
func TestFormatApprovalNeededToolResult(t *testing.T) {
result := formatApprovalNeededToolResult("rm -rf /", "tool-123", "Dangerous command")
if result == "" {
t.Error("Expected non-empty result")
}
// Should contain APPROVAL_REQUIRED prefix
if !hasPrefix(result, "APPROVAL_REQUIRED:") {
t.Error("Expected APPROVAL_REQUIRED prefix")
}
// Should contain the command
if !containsString(result, "rm -rf /") {
t.Error("Expected result to contain command")
}
}
func TestFormatPolicyBlockedToolResult(t *testing.T) {
result := formatPolicyBlockedToolResult("rm -rf /", "Command not allowed by policy")
if result == "" {
t.Error("Expected non-empty result")
}
// Should contain POLICY_BLOCKED prefix
if !hasPrefix(result, "POLICY_BLOCKED:") {
t.Error("Expected POLICY_BLOCKED prefix")
}
}
func TestParseApprovalNeededMarker(t *testing.T) {
// Valid approval needed response
validResult := formatApprovalNeededToolResult("rm -rf /", "tool-123", "test")
data, found := parseApprovalNeededMarker(validResult)
if !found {
t.Error("Expected to parse approval needed marker")
}
if data.Command != "rm -rf /" {
t.Errorf("Expected command 'rm -rf /', got '%s'", data.Command)
}
if data.ToolID != "tool-123" {
t.Errorf("Expected tool ID 'tool-123', got '%s'", data.ToolID)
}
// Invalid input
_, found = parseApprovalNeededMarker("not an approval marker")
if found {
t.Error("Expected not found for invalid input")
}
// Empty input
_, found = parseApprovalNeededMarker("")
if found {
t.Error("Expected not found for empty input")
}
// Only prefix without JSON
_, found = parseApprovalNeededMarker("APPROVAL_REQUIRED:")
if found {
t.Error("Expected not found for prefix without JSON")
}
}
// Note: GetDebugContext tests removed - they require persistence to be properly
// initialized and are better suited for integration tests
func TestService_SetProviders(t *testing.T) {
svc := NewService(nil, nil)
// Set up state provider first (needed for patrol service)
stateProvider := &mockStateProvider{}
svc.SetStateProvider(stateProvider)
// Test SetPatrolThresholdProvider
thresholdProvider := &mockThresholdProvider{
nodeCPU: 90,
nodeMemory: 85,
}
svc.SetPatrolThresholdProvider(thresholdProvider)
// No direct way to verify, but shouldn't panic
// Test SetChangeDetector with nil
svc.SetChangeDetector(nil)
// Test SetRemediationLog with nil
svc.SetRemediationLog(nil)
// Test SetPatternDetector with nil
svc.SetPatternDetector(nil)
// Test SetCorrelationDetector with nil
svc.SetCorrelationDetector(nil)
}
// Helper functions
func hasPrefix(s, prefix string) bool {
if len(s) < len(prefix) {
return false
}
return s[:len(prefix)] == 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
}