Pulse/internal/ai/tools/adapters_test.go
rcourtman 798f6a8deb Refactor: Update AI tools and tests for multi-tenancy
- Refactored tool execution to handle tenant-scoped contexts
- Added new tests for infrastructure, control, and kubernetes tools
- Improved test coverage for agentic chat and approval store
2026-01-22 16:43:08 +00:00

517 lines
15 KiB
Go

package tools
import (
"errors"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
type fakeStateGetter struct {
state models.StateSnapshot
}
func (f fakeStateGetter) GetState() models.StateSnapshot {
return f.state
}
type fakeAlertManager struct {
alerts []alerts.Alert
}
func (f fakeAlertManager) GetActiveAlerts() []alerts.Alert {
return f.alerts
}
type fakeMetricsSource struct {
allGuest map[string]map[string][]RawMetricPoint
guest map[string]map[string][]RawMetricPoint
node map[string]map[string][]RawMetricPoint
}
func (f *fakeMetricsSource) GetGuestMetrics(guestID string, metricType string, _ time.Duration) []RawMetricPoint {
if f.guest == nil {
return nil
}
if byMetric, ok := f.guest[guestID]; ok {
return byMetric[metricType]
}
return nil
}
func (f *fakeMetricsSource) GetNodeMetrics(nodeID string, metricType string, _ time.Duration) []RawMetricPoint {
if f.node == nil {
return nil
}
if byMetric, ok := f.node[nodeID]; ok {
return byMetric[metricType]
}
return nil
}
func (f *fakeMetricsSource) GetAllGuestMetrics(guestID string, _ time.Duration) map[string][]RawMetricPoint {
if f.allGuest == nil {
return nil
}
return f.allGuest[guestID]
}
type fakeBaselineSource struct {
mean float64
stddev float64
ok bool
all map[string]map[string]BaselineData
}
func (f *fakeBaselineSource) GetBaseline(_ string, _ string) (float64, float64, int, bool) {
return f.mean, f.stddev, 10, f.ok
}
func (f *fakeBaselineSource) GetAllBaselines() map[string]map[string]BaselineData {
return f.all
}
type fakePatternSource struct {
patterns []PatternData
predictions []PredictionData
}
func (f *fakePatternSource) GetPatterns() []PatternData {
return f.patterns
}
func (f *fakePatternSource) GetPredictions() []PredictionData {
return f.predictions
}
type fakeFindingsManager struct {
resolveArgs []string
dismissArgs []string
resolveErr error
dismissErr error
}
func (f *fakeFindingsManager) ResolveFinding(findingID, note string) error {
f.resolveArgs = []string{findingID, note}
return f.resolveErr
}
func (f *fakeFindingsManager) DismissFinding(findingID, reason, note string) error {
f.dismissArgs = []string{findingID, reason, note}
return f.dismissErr
}
type fakeMetadataUpdater struct {
resourceArgs []string
err error
}
func (f *fakeMetadataUpdater) SetResourceURL(resourceType, resourceID, url string) error {
f.resourceArgs = []string{resourceType, resourceID, url}
return f.err
}
type fakeUpdatesMonitor struct {
state models.StateSnapshot
checkStatus models.DockerHostCommandStatus
updateStatus models.DockerHostCommandStatus
checkErr error
updateErr error
}
func (f *fakeUpdatesMonitor) GetState() models.StateSnapshot {
return f.state
}
func (f *fakeUpdatesMonitor) QueueDockerCheckUpdatesCommand(_ string) (models.DockerHostCommandStatus, error) {
return f.checkStatus, f.checkErr
}
func (f *fakeUpdatesMonitor) QueueDockerContainerUpdateCommand(_ string, _ string, _ string) (models.DockerHostCommandStatus, error) {
return f.updateStatus, f.updateErr
}
type fakeUpdatesConfig struct {
enabled bool
}
func (f *fakeUpdatesConfig) IsDockerUpdateActionsEnabled() bool {
return f.enabled
}
func TestAlertManagerMCPAdapter(t *testing.T) {
if NewAlertManagerMCPAdapter(nil) != nil {
t.Fatal("expected nil adapter for nil manager")
}
ts := time.Now()
manager := fakeAlertManager{
alerts: []alerts.Alert{
{
ID: "a1",
ResourceID: "vm-1",
ResourceName: "vm1",
Type: "cpu",
Level: alerts.AlertLevelWarning,
Value: 80,
Threshold: 70,
StartTime: ts,
Message: "high cpu",
},
},
}
adapter := NewAlertManagerMCPAdapter(manager)
got := adapter.GetActiveAlerts()
if len(got) != 1 {
t.Fatalf("expected 1 alert, got %d", len(got))
}
if got[0].Severity != "warning" || got[0].ResourceName != "vm1" || got[0].Message != "high cpu" {
t.Fatalf("unexpected alert mapping: %+v", got[0])
}
}
func TestStorageBackupDiskAdapters(t *testing.T) {
state := models.StateSnapshot{
Storage: []models.Storage{{ID: "s1"}},
CephClusters: []models.CephCluster{{ID: "c1"}},
Backups: models.Backups{PVE: models.PVEBackups{
BackupTasks: []models.BackupTask{{ID: "task1"}},
}},
PBSInstances: []models.PBSInstance{{ID: "pbs1"}},
Hosts: []models.Host{{ID: "h1"}},
}
if NewStorageMCPAdapter(nil) != nil {
t.Fatal("expected nil storage adapter for nil state")
}
emptyStorage := (&StorageMCPAdapter{}).GetStorage()
if emptyStorage != nil {
t.Fatal("expected nil storage when state getter missing")
}
storageAdapter := NewStorageMCPAdapter(fakeStateGetter{state: state})
if len(storageAdapter.GetStorage()) != 1 {
t.Fatal("expected storage data")
}
if len(storageAdapter.GetCephClusters()) != 1 {
t.Fatal("expected ceph data")
}
if NewBackupMCPAdapter(nil) != nil {
t.Fatal("expected nil backup adapter for nil state")
}
backupAdapter := NewBackupMCPAdapter(fakeStateGetter{state: state})
if len(backupAdapter.GetBackups().PVE.BackupTasks) != 1 {
t.Fatal("expected backup tasks")
}
if len(backupAdapter.GetPBSInstances()) != 1 {
t.Fatal("expected pbs instances")
}
if NewDiskHealthMCPAdapter(nil) != nil {
t.Fatal("expected nil disk health adapter for nil state")
}
diskAdapter := NewDiskHealthMCPAdapter(fakeStateGetter{state: state})
if len(diskAdapter.GetHosts()) != 1 {
t.Fatal("expected hosts")
}
}
func TestMetricsHistoryMCPAdapter(t *testing.T) {
now := time.Now()
points := map[string][]RawMetricPoint{
"cpu": {{Value: 10, Timestamp: now}},
"memory": {{Value: 20, Timestamp: now}},
}
source := &fakeMetricsSource{
allGuest: map[string]map[string][]RawMetricPoint{
"100": points,
},
}
adapter := NewMetricsHistoryMCPAdapter(fakeStateGetter{}, source)
got, err := adapter.GetResourceMetrics("100", time.Hour)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 || got[0].CPU != 10 || got[0].Memory != 20 {
t.Fatalf("unexpected merged metrics: %+v", got)
}
// Node fallback when guest metrics empty
source = &fakeMetricsSource{
allGuest: map[string]map[string][]RawMetricPoint{},
node: map[string]map[string][]RawMetricPoint{
"node1": {
"cpu": {{Value: 5, Timestamp: now}},
"memory": {{Value: 15, Timestamp: now}},
},
},
}
adapter = NewMetricsHistoryMCPAdapter(fakeStateGetter{}, source)
got, err = adapter.GetResourceMetrics("node1", time.Hour)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 || got[0].CPU != 5 || got[0].Memory != 15 {
t.Fatalf("unexpected node metrics: %+v", got)
}
adapter = &MetricsHistoryMCPAdapter{}
empty, err := adapter.GetResourceMetrics("missing", time.Hour)
if err != nil || empty != nil {
t.Fatal("expected nil metrics when source missing")
}
}
func TestMetricsSummaryAndHelpers(t *testing.T) {
now := time.Now()
source := &fakeMetricsSource{
guest: map[string]map[string][]RawMetricPoint{
"100": {
"cpu": {{Value: 10, Timestamp: now}, {Value: 20, Timestamp: now.Add(time.Minute)}},
"memory": {{Value: 30, Timestamp: now}},
},
},
node: map[string]map[string][]RawMetricPoint{
"node1": {
"cpu": {{Value: 0, Timestamp: now}, {Value: 10, Timestamp: now.Add(time.Minute)}},
"memory": {{Value: 5, Timestamp: now}},
},
},
}
state := models.StateSnapshot{
VMs: []models.VM{{VMID: 100, Name: "vm1"}},
Containers: []models.Container{{VMID: 200, Name: "ct1"}},
Nodes: []models.Node{{ID: "node1", Name: "node-1"}},
}
adapter := NewMetricsHistoryMCPAdapter(fakeStateGetter{state: state}, source)
summary, err := adapter.GetAllMetricsSummary(time.Hour)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(summary) != 2 {
t.Fatalf("expected summaries for vm and node, got %d", len(summary))
}
if summary["100"].ResourceName != "vm1" || summary["node1"].ResourceName != "node-1" {
t.Fatalf("unexpected summary names: %+v", summary)
}
merged := mergeMetricsByTimestamp(map[string][]RawMetricPoint{
"cpu": {{Value: 1, Timestamp: now}},
"memory": {{Value: 2, Timestamp: now}},
"disk": {{Value: 3, Timestamp: now.Add(time.Minute)}},
})
if len(merged) != 2 {
t.Fatalf("expected 2 merged points, got %d", len(merged))
}
avg, max := computeStats([]RawMetricPoint{{Value: 1}, {Value: 3}})
if avg != 2 || max != 3 {
t.Fatalf("unexpected stats avg=%v max=%v", avg, max)
}
if computeTrend([]RawMetricPoint{{Value: 1}}) != "stable" {
t.Fatal("expected stable trend for short series")
}
if computeTrend([]RawMetricPoint{{Value: 0}, {Value: 0}, {Value: 0}, {Value: 10}}) != "growing" {
t.Fatal("expected growing trend")
}
if computeTrend([]RawMetricPoint{{Value: 10}, {Value: 10}, {Value: 10}, {Value: 0}}) != "declining" {
t.Fatal("expected declining trend")
}
if computeTrend([]RawMetricPoint{{Value: 1}, {Value: 2}, {Value: 2}, {Value: 2}}) != "stable" {
t.Fatal("expected stable trend within threshold")
}
}
func TestBaselineMCPAdapter(t *testing.T) {
if NewBaselineMCPAdapter(nil) != nil {
t.Fatal("expected nil baseline adapter for nil source")
}
adapter := NewBaselineMCPAdapter(&fakeBaselineSource{mean: 10, stddev: 2, ok: true})
baseline := adapter.GetBaseline("vm1", "cpu")
if baseline == nil || baseline.Min != 6 || baseline.Max != 14 {
t.Fatalf("unexpected baseline: %+v", baseline)
}
adapter = &BaselineMCPAdapter{}
if adapter.GetBaseline("vm1", "cpu") != nil {
t.Fatal("expected nil baseline when source missing")
}
adapter = NewBaselineMCPAdapter(&fakeBaselineSource{ok: false})
if adapter.GetBaseline("vm1", "cpu") != nil {
t.Fatal("expected nil baseline when not found")
}
adapter = NewBaselineMCPAdapter(&fakeBaselineSource{all: nil})
if adapter.GetAllBaselines() != nil {
t.Fatal("expected nil baselines when source returns nil")
}
all := map[string]map[string]BaselineData{
"100": {"cpu": {Mean: 5, StdDev: 1}},
}
adapter = NewBaselineMCPAdapter(&fakeBaselineSource{all: all})
allBaselines := adapter.GetAllBaselines()
if allBaselines["100"]["cpu"].Min != 3 || allBaselines["100"]["cpu"].Max != 7 {
t.Fatalf("unexpected all baselines: %+v", allBaselines)
}
}
func TestPatternMCPAdapter(t *testing.T) {
state := models.StateSnapshot{
VMs: []models.VM{{VMID: 100, Name: "vm1"}},
Nodes: []models.Node{{ID: "node1", Name: "node-1"}},
Containers: []models.Container{{VMID: 200, Name: "ct1"}},
}
source := &fakePatternSource{
patterns: []PatternData{
{ResourceID: "100", PatternType: "cpu", Description: "spike"},
{ResourceID: "node1", PatternType: "disk", Description: "trend"},
},
predictions: []PredictionData{
{ResourceID: "200", IssueType: "memory", Recommendation: "scale"},
},
}
adapter := NewPatternMCPAdapter(source, fakeStateGetter{state: state})
patterns := adapter.GetPatterns()
if len(patterns) != 2 || patterns[0].ResourceName != "vm1" || patterns[1].ResourceName != "node-1" {
t.Fatalf("unexpected patterns: %+v", patterns)
}
predictions := adapter.GetPredictions()
if len(predictions) != 1 || predictions[0].ResourceName != "ct1" {
t.Fatalf("unexpected predictions: %+v", predictions)
}
adapter = NewPatternMCPAdapter(source, nil)
patterns = adapter.GetPatterns()
if patterns[0].ResourceName != "100" {
t.Fatal("expected resource ID when state missing")
}
}
func TestFindingsAndMetadataAdapters(t *testing.T) {
manager := &fakeFindingsManager{resolveErr: errors.New("resolve"), dismissErr: errors.New("dismiss")}
adapter := NewFindingsManagerMCPAdapter(manager)
if err := adapter.ResolveFinding("f1", "note"); err == nil {
t.Fatal("expected resolve error")
}
if err := adapter.DismissFinding("f1", "reason", "note"); err == nil {
t.Fatal("expected dismiss error")
}
if len(manager.resolveArgs) != 2 || len(manager.dismissArgs) != 3 {
t.Fatal("expected args to be captured")
}
adapter = &FindingsManagerMCPAdapter{}
if err := adapter.ResolveFinding("f1", "note"); err == nil {
t.Fatal("expected error when manager missing")
}
updater := &fakeMetadataUpdater{err: errors.New("update")}
meta := NewMetadataUpdaterMCPAdapter(updater)
if err := meta.SetResourceURL("vm", "1", "http://x"); err == nil {
t.Fatal("expected update error")
}
if len(updater.resourceArgs) != 3 {
t.Fatal("expected resource args captured")
}
meta = &MetadataUpdaterMCPAdapter{}
if err := meta.SetResourceURL("vm", "1", "http://x"); err == nil {
t.Fatal("expected error when metadata updater missing")
}
}
func TestUpdatesMCPAdapter(t *testing.T) {
if NewUpdatesMCPAdapter(nil, nil) != nil {
t.Fatal("expected nil updates adapter for nil monitor")
}
now := time.Now()
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{
ID: "host1",
Hostname: "h1",
DisplayName: "Host 1",
Containers: []models.DockerContainer{
{
ID: "c1",
Name: "/nginx",
UpdateStatus: &models.DockerContainerUpdateStatus{
UpdateAvailable: true,
CurrentDigest: "old",
LatestDigest: "new",
LastChecked: now,
},
},
},
},
{
ID: "host2",
Hostname: "h2",
DisplayName: "Host 2",
Containers: []models.DockerContainer{
{
ID: "c2",
Name: "redis",
UpdateStatus: &models.DockerContainerUpdateStatus{
Error: "rate limited",
},
},
},
},
},
}
monitor := &fakeUpdatesMonitor{state: state}
adapter := NewUpdatesMCPAdapter(monitor, &fakeUpdatesConfig{enabled: false})
updates := adapter.GetPendingUpdates("host1")
if len(updates) != 1 || updates[0].ContainerName != "nginx" {
t.Fatalf("unexpected updates: %+v", updates)
}
if adapter.IsUpdateActionsEnabled() {
t.Fatal("expected updates disabled")
}
if (&UpdatesMCPAdapter{}).IsUpdateActionsEnabled() != true {
t.Fatal("expected updates enabled by default")
}
monitor.checkErr = errors.New("check")
if _, err := adapter.TriggerUpdateCheck("host1"); err == nil {
t.Fatal("expected check error")
}
monitor.checkErr = nil
monitor.checkStatus = models.DockerHostCommandStatus{ID: "cmd1", Type: "check", Status: "queued"}
status, err := adapter.TriggerUpdateCheck("host1")
if err != nil || status.ID != "cmd1" {
t.Fatalf("unexpected status: %+v err=%v", status, err)
}
monitor.updateErr = errors.New("update")
if _, err := adapter.UpdateContainer("host1", "c1", "nginx"); err == nil {
t.Fatal("expected update error")
}
monitor.updateErr = nil
monitor.updateStatus = models.DockerHostCommandStatus{ID: "cmd2", Type: "update", Status: "queued"}
status, err = adapter.UpdateContainer("host1", "c1", "nginx")
if err != nil || status.ID != "cmd2" {
t.Fatalf("unexpected update status: %+v err=%v", status, err)
}
if trimContainerName("/redis") != "redis" || trimContainerName("plain") != "plain" {
t.Fatal("unexpected trim result")
}
}