Pulse/internal/ai/service_test.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- Add persistent volume mounts for Go/npm caches (faster rebuilds)
- Add shell config with helpful aliases and custom prompt
- Add comprehensive devcontainer documentation
- Add pre-commit hooks for Go formatting and linting
- Use go-version-file in CI workflows instead of hardcoded versions
- Simplify docker compose commands with --wait flag
- Add gitignore entries for devcontainer auth files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:29:15 +00:00

834 lines
21 KiB
Go

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/models"
"github.com/rcourtman/pulse-go-rewrite/internal/resources"
)
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)
}
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)
}
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 {
return strings.Contains(s, substr)
}