mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
993 lines
27 KiB
Go
993 lines
27 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
|
)
|
|
|
|
// mockStateProvider implements StateProvider for testing.
|
|
type mockStateProvider struct {
|
|
state models.StateSnapshot
|
|
}
|
|
|
|
func (m *mockStateProvider) ReadSnapshot() models.StateSnapshot {
|
|
return m.state
|
|
}
|
|
|
|
// mockAlertAnalyzer satisfies aicontracts.AlertAnalyzer for testing.
|
|
type mockAlertAnalyzer struct{ enabled bool }
|
|
|
|
func (m *mockAlertAnalyzer) OnAlertFired(aicontracts.AlertPayload) {}
|
|
func (m *mockAlertAnalyzer) SetEnabled(e bool) { m.enabled = e }
|
|
func (m *mockAlertAnalyzer) IsEnabled() bool { return m.enabled }
|
|
func (m *mockAlertAnalyzer) Start() {}
|
|
func (m *mockAlertAnalyzer) Stop() {}
|
|
|
|
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 TestExecuteResponse_UsesCanonicalEmptyCollections(t *testing.T) {
|
|
payload, err := json.Marshal(EmptyExecuteResponse())
|
|
if err != nil {
|
|
t.Fatalf("marshal empty execute response: %v", err)
|
|
}
|
|
if !strings.Contains(string(payload), `"tool_calls":[]`) {
|
|
t.Fatalf("expected empty execute response to retain tool_calls array, got %s", payload)
|
|
}
|
|
if !strings.Contains(string(payload), `"pending_approvals":[]`) {
|
|
t.Fatalf("expected empty execute response to retain pending_approvals array, got %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestChatMessage_UsesCanonicalEmptyCollections(t *testing.T) {
|
|
payload, err := json.Marshal(EmptyChatMessage())
|
|
if err != nil {
|
|
t.Fatalf("marshal empty chat message: %v", err)
|
|
}
|
|
if !strings.Contains(string(payload), `"tool_calls":[]`) {
|
|
t.Fatalf("expected empty chat message to retain tool_calls array, got %s", payload)
|
|
}
|
|
|
|
payload, err = json.Marshal(EmptyChatToolCall())
|
|
if err != nil {
|
|
t.Fatalf("marshal empty chat tool call: %v", err)
|
|
}
|
|
if !strings.Contains(string(payload), `"input":{}`) {
|
|
t.Fatalf("expected empty chat tool call to retain input object, got %s", payload)
|
|
}
|
|
|
|
payload, err = json.Marshal(ChatMessage{
|
|
ToolCalls: []ChatToolCall{{
|
|
ID: "call-1",
|
|
Name: "diagnose",
|
|
}},
|
|
}.NormalizeCollections())
|
|
if err != nil {
|
|
t.Fatalf("marshal normalized chat message with tool call: %v", err)
|
|
}
|
|
if !strings.Contains(string(payload), `"input":{}`) {
|
|
t.Fatalf("expected normalized chat message tool call to retain input object, got %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestService_SetStateProvider(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
svc.SetAlertAnalyzerFactory(func(deps aicontracts.AlertAnalyzerDeps) aicontracts.AlertAnalyzer {
|
|
return &mockAlertAnalyzer{}
|
|
})
|
|
|
|
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 (factory was set)
|
|
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 TestExtractAlertIdentifierPrefersCanonicalIdentifier(t *testing.T) {
|
|
ctx := map[string]interface{}{
|
|
"alertIdentifier": "instance:node:100::metric/cpu",
|
|
}
|
|
|
|
if got := extractAlertIdentifier(ctx); got != "instance:node:100::metric/cpu" {
|
|
t.Fatalf("extractAlertIdentifier canonical preference = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractAlertIdentifierRequiresCanonicalIdentifier(t *testing.T) {
|
|
if got := extractAlertIdentifier(map[string]interface{}{"alertId": "legacy-alert-id"}); got != "" {
|
|
t.Fatalf("extractAlertIdentifier should ignore alertId, got %q", got)
|
|
}
|
|
if got := extractAlertIdentifier(map[string]interface{}{"alert_id": "legacy-alert-id-2"}); got != "" {
|
|
t.Fatalf("extractAlertIdentifier should ignore alert_id, got %q", got)
|
|
}
|
|
if got := extractAlertIdentifier(nil); got != "" {
|
|
t.Fatalf("extractAlertIdentifier nil context = %q", got)
|
|
}
|
|
}
|
|
|
|
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_LookupGuestsByVMID(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
|
|
snapshot := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node/pve-1", Name: "pve-1", Instance: "pve-1", Status: "online"},
|
|
},
|
|
VMs: []models.VM{
|
|
{ID: "vm-100", VMID: 100, Name: "web-server", Node: "pve-1", Instance: "pve-1"},
|
|
{ID: "vm-101", VMID: 101, Name: "database", Node: "pve-1", Instance: "pve-1"},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "ct-200", VMID: 200, Name: "nginx", Node: "pve-1", Instance: "pve-1"},
|
|
},
|
|
}
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
registry.IngestSnapshot(snapshot)
|
|
svc.SetReadState(registry)
|
|
|
|
// 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 != "system-container" {
|
|
t.Errorf("Expected type 'system-container', 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_NoReadState(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
|
|
guests := svc.lookupGuestsByVMID(100, "")
|
|
if guests != nil {
|
|
t.Error("Expected nil guests without ReadState")
|
|
}
|
|
}
|
|
|
|
func TestService_LookupNodeForVMID(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
|
|
snapshot := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node/pve-1", Name: "pve-1", Instance: "pve-1", Status: "online"},
|
|
},
|
|
VMs: []models.VM{
|
|
{ID: "vm-100", VMID: 100, Name: "web-server", Node: "pve-1", Instance: "pve-1"},
|
|
},
|
|
}
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
registry.IngestSnapshot(snapshot)
|
|
svc.SetReadState(registry)
|
|
|
|
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 != "vm" {
|
|
t.Errorf("Expected type 'vm', got '%s'", guestType)
|
|
}
|
|
}
|
|
|
|
func TestService_SetReadState_InitializesPatrolWithoutStateProvider(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
|
|
snapshot := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node/pve-1", Name: "pve-1", Instance: "pve-1", Status: "online"},
|
|
},
|
|
}
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
registry.IngestSnapshot(snapshot)
|
|
|
|
svc.SetReadState(registry)
|
|
|
|
patrol := svc.GetPatrolService()
|
|
if patrol == nil {
|
|
t.Fatal("expected patrol service to initialize from read state")
|
|
}
|
|
if patrol.hasPatrolRuntimeInputs() != true {
|
|
t.Fatal("expected patrol runtime inputs to be available from read state")
|
|
}
|
|
if svc.GetStateProvider() != nil {
|
|
t.Fatal("expected state provider to remain nil")
|
|
}
|
|
}
|
|
|
|
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", "approval-1")
|
|
|
|
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", "approval-1")
|
|
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)
|
|
}
|
|
if data.ApprovalID != "approval-1" {
|
|
t.Errorf("Expected approval ID 'approval-1', got '%s'", data.ApprovalID)
|
|
}
|
|
|
|
// 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_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}
|
|
if svc.resourceExportStore != nil {
|
|
_ = svc.resourceExportStore.Close()
|
|
}
|
|
svc.resourceExportStore = unifiedresources.NewMemoryStore()
|
|
svc.resourceExportStoreOrgID = svc.orgID
|
|
|
|
resource := unifiedresources.Resource{
|
|
ID: "node-1",
|
|
Name: "test-node",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
}
|
|
svc.unifiedResourceProvider = &mockUnifiedResourceProvider{
|
|
getAllFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{resource}
|
|
},
|
|
getInfrastructureFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{resource}
|
|
},
|
|
getStatsFunc: func() unifiedresources.ResourceStats {
|
|
return unifiedresources.ResourceStats{
|
|
Total: 1,
|
|
ByType: map[unifiedresources.ResourceType]int{
|
|
unifiedresources.ResourceTypeAgent: 1,
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
audits, err := svc.resourceExportStore.GetExportAudits(time.Time{}, 10)
|
|
if err != nil {
|
|
t.Fatalf("GetExportAudits failed: %v", err)
|
|
}
|
|
if len(audits) != 1 {
|
|
t.Fatalf("expected 1 export audit, got %d", len(audits))
|
|
}
|
|
if audits[0].Destination != "anthropic:test-model" {
|
|
t.Fatalf("expected destination anthropic:test-model, got %q", audits[0].Destination)
|
|
}
|
|
if audits[0].EnvelopeHash == "" {
|
|
t.Fatal("expected export envelope hash to be recorded")
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
|
|
mockURP := &mockUnifiedResourceProvider{
|
|
getStatsFunc: func() unifiedresources.ResourceStats {
|
|
return unifiedresources.ResourceStats{Total: 1}
|
|
},
|
|
}
|
|
svc.SetUnifiedResourceProvider(mockURP)
|
|
|
|
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_Reload_NoPersistence(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
|
|
err := svc.Reload()
|
|
if err == nil {
|
|
t.Fatal("expected Reload to fail when config persistence is unavailable")
|
|
}
|
|
if !strings.Contains(err.Error(), "config persistence unavailable") {
|
|
t.Fatalf("unexpected error: %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_TestConnection_NoPersistence(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
|
|
err := svc.TestConnection(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected TestConnection to fail when config persistence is unavailable")
|
|
}
|
|
if !strings.Contains(err.Error(), "config persistence unavailable") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestService_ListModelsWithCache_NoPersistence(t *testing.T) {
|
|
svc := NewService(nil, nil)
|
|
|
|
models, cached, err := svc.ListModelsWithCache(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected ListModelsWithCache to fail when config persistence is unavailable")
|
|
}
|
|
if !strings.Contains(err.Error(), "config persistence unavailable") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if models != nil {
|
|
t.Fatalf("expected nil models, got %#v", models)
|
|
}
|
|
if cached {
|
|
t.Fatal("expected cached=false on persistence failure")
|
|
}
|
|
}
|
|
|
|
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{ControlLevel: config.ControlLevelAutonomous}
|
|
if !svc.IsAutonomous() {
|
|
t.Error("Expected true")
|
|
}
|
|
|
|
svc.cfg.ControlLevel = config.ControlLevelReadOnly
|
|
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)
|
|
}
|