diff --git a/internal/ai/chat/service_tooling_test.go b/internal/ai/chat/service_tooling_test.go index 6d438be1be..f971717ffc 100644 --- a/internal/ai/chat/service_tooling_test.go +++ b/internal/ai/chat/service_tooling_test.go @@ -2,14 +2,17 @@ package chat import ( "context" + "encoding/json" "strings" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/recovery" ur "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -48,6 +51,41 @@ func (f *fakeCanonicalReadState) K8sDeployments() []*ur.K8sDeploymentView { func (f *fakeCanonicalReadState) Workloads() []*ur.WorkloadView { return nil } func (f *fakeCanonicalReadState) Infrastructure() []*ur.InfrastructureView { return nil } +type fakeRecoveryPointsProvider struct { + points []recovery.RecoveryPoint +} + +func (f *fakeRecoveryPointsProvider) ListPoints(_ context.Context, opts recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error) { + filtered := make([]recovery.RecoveryPoint, 0, len(f.points)) + for _, point := range f.points { + if opts.Provider != "" && point.Provider != opts.Provider { + continue + } + if opts.Kind != "" && point.Kind != opts.Kind { + continue + } + filtered = append(filtered, point) + } + + total := len(filtered) + if opts.Limit <= 0 { + return filtered, total, nil + } + page := opts.Page + if page <= 0 { + page = 1 + } + start := (page - 1) * opts.Limit + if start >= total { + return []recovery.RecoveryPoint{}, total, nil + } + end := start + opts.Limit + if end > total { + end = total + } + return filtered[start:end], total, nil +} + func toolNameSet(list []providers.Tool) map[string]bool { set := make(map[string]bool, len(list)) for _, tool := range list { @@ -227,6 +265,125 @@ func TestExecuteMCPTool_ErrorsAndSuccess(t *testing.T) { } } +func TestExecuteMCPTool_PulseStorageSnapshotsToleratesMalformedRecoveryMetadata(t *testing.T) { + completedAt := time.Date(2026, 2, 24, 10, 30, 0, 0, time.UTC) + svc := &Service{ + executor: tools.NewPulseToolExecutor(tools.ExecutorConfig{ + StateProvider: fakeStateProvider{}, + ReadState: &fakeCanonicalReadState{}, + RecoveryPointsProvider: &fakeRecoveryPointsProvider{points: []recovery.RecoveryPoint{ + { + ID: "pve-snapshot:snap-100-before-upgrade", + Provider: recovery.ProviderProxmoxPVE, + Kind: recovery.KindSnapshot, + Mode: recovery.ModeLocal, + Outcome: recovery.OutcomeSuccess, + SubjectRef: &recovery.ExternalRef{ + Type: "vm", + Name: "100", + ID: "100", + Namespace: "pve1", + Class: "node1", + }, + Display: &recovery.RecoveryPointDisplay{ + SubjectLabel: "100", + ItemType: "vm", + ClusterLabel: "pve1", + NodeHostLabel: "node1", + EntityIDLabel: "100", + DetailsSummary: "before-upgrade", + }, + CompletedAt: &completedAt, + }, + }}, + }), + } + + output, err := svc.ExecuteMCPTool(context.Background(), "pulse_storage", map[string]interface{}{ + "type": "snapshots", + "guest_id": "100", + "instance": "pve1", + }) + if err != nil { + t.Fatalf("ExecuteMCPTool(pulse_storage snapshots): %v", err) + } + + var resp tools.SnapshotsResponse + if err := json.Unmarshal([]byte(output), &resp); err != nil { + t.Fatalf("unmarshal snapshots response: %v\noutput=%s", err, output) + } + if resp.Total != 1 || resp.Filtered != 1 || len(resp.Snapshots) != 1 { + t.Fatalf("unexpected snapshots counts: total=%d filtered=%d len=%d", resp.Total, resp.Filtered, len(resp.Snapshots)) + } + if resp.Snapshots[0].VMID != 100 || resp.Snapshots[0].Instance != "pve1" || resp.Snapshots[0].Node != "node1" { + t.Fatalf("unexpected snapshot identity: %#v", resp.Snapshots[0]) + } + if resp.Snapshots[0].Type != "vm" || resp.Snapshots[0].SnapshotName != "before-upgrade" { + t.Fatalf("unexpected snapshot canonical fields: %#v", resp.Snapshots[0]) + } +} + +func TestExecuteMCPTool_PulseStorageBackupTasksToleratesMalformedRecoveryMetadata(t *testing.T) { + startedAt := time.Date(2026, 2, 24, 11, 0, 0, 0, time.UTC) + completedAt := time.Date(2026, 2, 24, 11, 15, 0, 0, time.UTC) + svc := &Service{ + executor: tools.NewPulseToolExecutor(tools.ExecutorConfig{ + StateProvider: fakeStateProvider{}, + ReadState: &fakeCanonicalReadState{}, + RecoveryPointsProvider: &fakeRecoveryPointsProvider{points: []recovery.RecoveryPoint{ + { + ID: "pve-task:task-101-backup", + Provider: recovery.ProviderProxmoxPVE, + Kind: recovery.KindBackup, + Mode: recovery.ModeLocal, + Outcome: recovery.OutcomeSuccess, + SubjectRef: &recovery.ExternalRef{ + Type: "vm", + Name: "101", + ID: "101", + Namespace: "pve1", + Class: "node1", + }, + Display: &recovery.RecoveryPointDisplay{ + SubjectLabel: "101", + ItemType: "vm", + ClusterLabel: "pve1", + NodeHostLabel: "node1", + EntityIDLabel: "101", + DetailsSummary: "completed successfully", + }, + StartedAt: &startedAt, + CompletedAt: &completedAt, + }, + }}, + }), + } + + output, err := svc.ExecuteMCPTool(context.Background(), "pulse_storage", map[string]interface{}{ + "type": "backup_tasks", + "guest_id": "101", + "instance": "pve1", + "status": "OK", + }) + if err != nil { + t.Fatalf("ExecuteMCPTool(pulse_storage backup_tasks): %v", err) + } + + var resp tools.BackupTasksListResponse + if err := json.Unmarshal([]byte(output), &resp); err != nil { + t.Fatalf("unmarshal backup tasks response: %v\noutput=%s", err, output) + } + if resp.Total != 1 || resp.Filtered != 1 || len(resp.Tasks) != 1 { + t.Fatalf("unexpected backup task counts: total=%d filtered=%d len=%d", resp.Total, resp.Filtered, len(resp.Tasks)) + } + if resp.Tasks[0].VMID != 101 || resp.Tasks[0].Instance != "pve1" || resp.Tasks[0].Node != "node1" { + t.Fatalf("unexpected backup task identity: %#v", resp.Tasks[0]) + } + if resp.Tasks[0].Type != "vm" || resp.Tasks[0].Status != "OK" { + t.Fatalf("unexpected backup task canonical fields: %#v", resp.Tasks[0]) + } +} + func TestService_StartInitializesActionAuditStore(t *testing.T) { svc := NewService(Config{ AIConfig: &config.AIConfig{