package monitoring import ( "testing" "time" ) func TestNewMetricsHistory(t *testing.T) { tests := []struct { name string maxDataPoints int retentionTime time.Duration }{ { name: "standard values", maxDataPoints: 100, retentionTime: time.Hour, }, { name: "zero max points", maxDataPoints: 0, retentionTime: time.Minute, }, { name: "zero retention", maxDataPoints: 50, retentionTime: 0, }, { name: "large values", maxDataPoints: 10000, retentionTime: 24 * time.Hour, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mh := NewMetricsHistory(tt.maxDataPoints, tt.retentionTime) if mh == nil { t.Fatal("NewMetricsHistory returned nil") } if mh.maxDataPoints != tt.maxDataPoints { t.Errorf("maxDataPoints = %d, want %d", mh.maxDataPoints, tt.maxDataPoints) } if mh.retentionTime != tt.retentionTime { t.Errorf("retentionTime = %v, want %v", mh.retentionTime, tt.retentionTime) } if mh.guestMetrics == nil { t.Error("guestMetrics map not initialized") } if mh.nodeMetrics == nil { t.Error("nodeMetrics map not initialized") } if mh.storageMetrics == nil { t.Error("storageMetrics map not initialized") } }) } } func TestAppendMetric(t *testing.T) { now := time.Now() tests := []struct { name string maxDataPoints int retentionTime time.Duration existing []MetricPoint newPoint MetricPoint wantLen int wantFirst float64 // value of first point after append wantLast float64 // value of last point after append }{ { name: "append to empty slice", maxDataPoints: 10, retentionTime: time.Hour, existing: []MetricPoint{}, newPoint: MetricPoint{Value: 50.0, Timestamp: now}, wantLen: 1, wantFirst: 50.0, wantLast: 50.0, }, { name: "append within limits", maxDataPoints: 10, retentionTime: time.Hour, existing: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-30 * time.Minute)}, {Value: 20.0, Timestamp: now.Add(-20 * time.Minute)}, }, newPoint: MetricPoint{Value: 30.0, Timestamp: now}, wantLen: 3, wantFirst: 10.0, wantLast: 30.0, }, { name: "exceed max data points", maxDataPoints: 3, retentionTime: time.Hour, existing: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-30 * time.Minute)}, {Value: 20.0, Timestamp: now.Add(-20 * time.Minute)}, {Value: 30.0, Timestamp: now.Add(-10 * time.Minute)}, }, newPoint: MetricPoint{Value: 40.0, Timestamp: now}, wantLen: 3, wantFirst: 20.0, // oldest point dropped wantLast: 40.0, }, { name: "old points beyond retention removed", maxDataPoints: 100, retentionTime: 30 * time.Minute, existing: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-2 * time.Hour)}, // beyond retention {Value: 20.0, Timestamp: now.Add(-90 * time.Minute)}, // beyond retention {Value: 30.0, Timestamp: now.Add(-20 * time.Minute)}, // within retention }, newPoint: MetricPoint{Value: 40.0, Timestamp: now}, wantLen: 2, wantFirst: 30.0, // old points removed wantLast: 40.0, }, { name: "all old points removed except new", maxDataPoints: 100, retentionTime: 10 * time.Minute, existing: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-2 * time.Hour)}, {Value: 20.0, Timestamp: now.Add(-1 * time.Hour)}, }, newPoint: MetricPoint{Value: 30.0, Timestamp: now}, wantLen: 1, wantFirst: 30.0, wantLast: 30.0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mh := NewMetricsHistory(tt.maxDataPoints, tt.retentionTime) result := mh.appendMetric(tt.existing, tt.newPoint) if len(result) != tt.wantLen { t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen) } if len(result) > 0 { if result[0].Value != tt.wantFirst { t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst) } if result[len(result)-1].Value != tt.wantLast { t.Errorf("last value = %v, want %v", result[len(result)-1].Value, tt.wantLast) } } }) } } func TestCleanupMetrics(t *testing.T) { now := time.Now() tests := []struct { name string metrics []MetricPoint cutoffTime time.Time wantLen int wantFirst float64 }{ { name: "empty slice", metrics: []MetricPoint{}, cutoffTime: now.Add(-time.Hour), wantLen: 0, }, { name: "no points to remove", metrics: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-30 * time.Minute)}, {Value: 20.0, Timestamp: now.Add(-20 * time.Minute)}, {Value: 30.0, Timestamp: now.Add(-10 * time.Minute)}, }, cutoffTime: now.Add(-time.Hour), wantLen: 3, wantFirst: 10.0, }, { name: "remove some old points", metrics: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-2 * time.Hour)}, {Value: 20.0, Timestamp: now.Add(-90 * time.Minute)}, {Value: 30.0, Timestamp: now.Add(-30 * time.Minute)}, {Value: 40.0, Timestamp: now.Add(-10 * time.Minute)}, }, cutoffTime: now.Add(-time.Hour), wantLen: 2, wantFirst: 30.0, }, { name: "remove all points", metrics: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-3 * time.Hour)}, {Value: 20.0, Timestamp: now.Add(-2 * time.Hour)}, }, cutoffTime: now.Add(-time.Hour), wantLen: 0, }, { name: "boundary - point exactly at cutoff", metrics: []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-time.Hour)}, // exactly at cutoff (not after) {Value: 20.0, Timestamp: now.Add(-30 * time.Minute)}, }, cutoffTime: now.Add(-time.Hour), wantLen: 1, wantFirst: 20.0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) result := mh.cleanupMetrics(tt.metrics, tt.cutoffTime) if len(result) != tt.wantLen { t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen) } if len(result) > 0 && tt.wantFirst != 0 { if result[0].Value != tt.wantFirst { t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst) } } }) } } func TestAddGuestMetric(t *testing.T) { now := time.Now() tests := []struct { name string guestID string metricType string value float64 }{ {name: "cpu metric", guestID: "vm-100", metricType: "cpu", value: 75.5}, {name: "memory metric", guestID: "vm-100", metricType: "memory", value: 80.0}, {name: "disk metric", guestID: "ct-101", metricType: "disk", value: 50.0}, {name: "diskread metric", guestID: "vm-102", metricType: "diskread", value: 100.5}, {name: "diskwrite metric", guestID: "vm-102", metricType: "diskwrite", value: 50.25}, {name: "netin metric", guestID: "vm-103", metricType: "netin", value: 1000.0}, {name: "netout metric", guestID: "vm-103", metricType: "netout", value: 500.0}, {name: "unknown metric type", guestID: "vm-104", metricType: "unknown", value: 99.9}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) mh.AddGuestMetric(tt.guestID, tt.metricType, tt.value, now) // Verify guest was created mh.mu.RLock() defer mh.mu.RUnlock() metrics, exists := mh.guestMetrics[tt.guestID] if !exists { t.Fatal("guest metrics not created") } // Verify correct metric type was populated var slice []MetricPoint switch tt.metricType { case "cpu": slice = metrics.CPU case "memory": slice = metrics.Memory case "disk": slice = metrics.Disk case "diskread": slice = metrics.DiskRead case "diskwrite": slice = metrics.DiskWrite case "netin": slice = metrics.NetworkIn case "netout": slice = metrics.NetworkOut default: // Unknown types should not populate any slice if len(metrics.CPU)+len(metrics.Memory)+len(metrics.Disk)+ len(metrics.DiskRead)+len(metrics.DiskWrite)+ len(metrics.NetworkIn)+len(metrics.NetworkOut) != 0 { t.Error("unknown metric type populated a slice") } return } if len(slice) != 1 { t.Errorf("slice length = %d, want 1", len(slice)) } else if slice[0].Value != tt.value { t.Errorf("value = %v, want %v", slice[0].Value, tt.value) } }) } } func TestAddNodeMetric(t *testing.T) { now := time.Now() tests := []struct { name string nodeID string metricType string value float64 shouldAdd bool }{ {name: "cpu metric", nodeID: "node1", metricType: "cpu", value: 45.5, shouldAdd: true}, {name: "memory metric", nodeID: "node1", metricType: "memory", value: 60.0, shouldAdd: true}, {name: "disk metric", nodeID: "node2", metricType: "disk", value: 70.0, shouldAdd: true}, {name: "unknown metric type", nodeID: "node3", metricType: "netin", value: 100.0, shouldAdd: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) mh.AddNodeMetric(tt.nodeID, tt.metricType, tt.value, now) mh.mu.RLock() defer mh.mu.RUnlock() metrics, exists := mh.nodeMetrics[tt.nodeID] if !exists { t.Fatal("node metrics not created") } var slice []MetricPoint switch tt.metricType { case "cpu": slice = metrics.CPU case "memory": slice = metrics.Memory case "disk": slice = metrics.Disk default: if tt.shouldAdd { t.Error("expected metric to be added") } return } if tt.shouldAdd { if len(slice) != 1 { t.Errorf("slice length = %d, want 1", len(slice)) } else if slice[0].Value != tt.value { t.Errorf("value = %v, want %v", slice[0].Value, tt.value) } } }) } } func TestAddStorageMetric(t *testing.T) { now := time.Now() tests := []struct { name string storageID string metricType string value float64 shouldAdd bool }{ {name: "usage metric", storageID: "local-lvm", metricType: "usage", value: 45.5, shouldAdd: true}, {name: "used metric", storageID: "local-lvm", metricType: "used", value: 100e9, shouldAdd: true}, {name: "total metric", storageID: "ceph-pool", metricType: "total", value: 500e9, shouldAdd: true}, {name: "avail metric", storageID: "ceph-pool", metricType: "avail", value: 400e9, shouldAdd: true}, {name: "unknown metric type", storageID: "nfs-share", metricType: "iops", value: 1000.0, shouldAdd: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) mh.AddStorageMetric(tt.storageID, tt.metricType, tt.value, now) mh.mu.RLock() defer mh.mu.RUnlock() metrics, exists := mh.storageMetrics[tt.storageID] if !exists { t.Fatal("storage metrics not created") } var slice []MetricPoint switch tt.metricType { case "usage": slice = metrics.Usage case "used": slice = metrics.Used case "total": slice = metrics.Total case "avail": slice = metrics.Avail default: if tt.shouldAdd { t.Error("expected metric to be added") } return } if tt.shouldAdd { if len(slice) != 1 { t.Errorf("slice length = %d, want 1", len(slice)) } else if slice[0].Value != tt.value { t.Errorf("value = %v, want %v", slice[0].Value, tt.value) } } }) } } func TestGetGuestMetrics(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, time.Hour) // Add test data mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-50*time.Minute)) mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-40*time.Minute)) mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-30*time.Minute)) mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-20*time.Minute)) mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-10*time.Minute)) mh.AddGuestMetric("vm-100", "memory", 80.0, now.Add(-5*time.Minute)) tests := []struct { name string guestID string metricType string duration time.Duration wantLen int wantFirst float64 }{ { name: "get all cpu within hour", guestID: "vm-100", metricType: "cpu", duration: time.Hour, wantLen: 5, wantFirst: 10.0, }, { name: "get recent cpu only", guestID: "vm-100", metricType: "cpu", duration: 25 * time.Minute, wantLen: 2, wantFirst: 40.0, }, { name: "get memory", guestID: "vm-100", metricType: "memory", duration: time.Hour, wantLen: 1, wantFirst: 80.0, }, { name: "nonexistent guest", guestID: "vm-999", metricType: "cpu", duration: time.Hour, wantLen: 0, }, { name: "nonexistent metric type", guestID: "vm-100", metricType: "invalid", duration: time.Hour, wantLen: 0, }, { name: "zero duration returns nothing", guestID: "vm-100", metricType: "cpu", duration: 0, wantLen: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := mh.GetGuestMetrics(tt.guestID, tt.metricType, tt.duration) if len(result) != tt.wantLen { t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen) } if len(result) > 0 && tt.wantFirst != 0 { if result[0].Value != tt.wantFirst { t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst) } } }) } } func TestGetGuestMetrics_AllMetricTypes(t *testing.T) { now := time.Now() tests := []struct { name string metricType string value float64 }{ {name: "cpu metric", metricType: "cpu", value: 50.5}, {name: "memory metric", metricType: "memory", value: 70.0}, {name: "disk metric", metricType: "disk", value: 45.0}, {name: "diskread metric", metricType: "diskread", value: 1024.0}, {name: "diskwrite metric", metricType: "diskwrite", value: 512.0}, {name: "netin metric", metricType: "netin", value: 2048.0}, {name: "netout metric", metricType: "netout", value: 1536.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) mh.AddGuestMetric("vm-test", tt.metricType, tt.value, now.Add(-5*time.Minute)) result := mh.GetGuestMetrics("vm-test", tt.metricType, time.Hour) if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if result[0].Value != tt.value { t.Errorf("value = %v, want %v", result[0].Value, tt.value) } }) } } func TestGetGuestMetrics_GuestNotFound(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) // Add data for one guest mh.AddGuestMetric("vm-100", "cpu", 50.0, time.Now()) // Query non-existent guest result := mh.GetGuestMetrics("vm-nonexistent", "cpu", time.Hour) if len(result) != 0 { t.Errorf("expected empty slice for non-existent guest, got %d elements", len(result)) } if result == nil { t.Error("expected empty slice, got nil") } } func TestGetGuestMetrics_UnknownMetricType(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, time.Hour) // Add data for the guest mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-5*time.Minute)) mh.AddGuestMetric("vm-100", "memory", 70.0, now.Add(-5*time.Minute)) unknownTypes := []string{"invalid", "unknown", "foo", "bar", "CPU", "Memory", ""} for _, metricType := range unknownTypes { t.Run("type_"+metricType, func(t *testing.T) { result := mh.GetGuestMetrics("vm-100", metricType, time.Hour) if len(result) != 0 { t.Errorf("expected empty slice for unknown metric type %q, got %d elements", metricType, len(result)) } if result == nil { t.Errorf("expected empty slice for unknown metric type %q, got nil", metricType) } }) } } func TestGetGuestMetrics_DurationFiltering(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, 2*time.Hour) // Add points at different times mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-90*time.Minute)) // old mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-60*time.Minute)) // old mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-30*time.Minute)) // recent mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-15*time.Minute)) // recent mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-5*time.Minute)) // recent tests := []struct { name string duration time.Duration wantLen int wantFirst float64 wantLast float64 }{ { name: "all points within 2 hours", duration: 2 * time.Hour, wantLen: 5, wantFirst: 10.0, wantLast: 50.0, }, { name: "points within 45 minutes", duration: 45 * time.Minute, wantLen: 3, wantFirst: 30.0, wantLast: 50.0, }, { name: "points within 20 minutes", duration: 20 * time.Minute, wantLen: 2, wantFirst: 40.0, wantLast: 50.0, }, { name: "points within 10 minutes", duration: 10 * time.Minute, wantLen: 1, wantFirst: 50.0, wantLast: 50.0, }, { name: "zero duration excludes all", duration: 0, wantLen: 0, }, { name: "very short duration excludes all", duration: 1 * time.Minute, wantLen: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := mh.GetGuestMetrics("vm-100", "cpu", tt.duration) if len(result) != tt.wantLen { t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen) } if len(result) > 0 { if result[0].Value != tt.wantFirst { t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst) } if result[len(result)-1].Value != tt.wantLast { t.Errorf("last value = %v, want %v", result[len(result)-1].Value, tt.wantLast) } } }) } } func TestGetGuestMetrics_EmptyMetricsData(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) // Directly populate an empty guest metrics entry mh.mu.Lock() mh.guestMetrics["vm-empty"] = &GuestMetrics{ CPU: []MetricPoint{}, Memory: []MetricPoint{}, Disk: []MetricPoint{}, DiskRead: []MetricPoint{}, DiskWrite: []MetricPoint{}, NetworkIn: []MetricPoint{}, NetworkOut: []MetricPoint{}, } mh.mu.Unlock() metricTypes := []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"} for _, metricType := range metricTypes { t.Run(metricType, func(t *testing.T) { result := mh.GetGuestMetrics("vm-empty", metricType, time.Hour) if len(result) != 0 { t.Errorf("expected empty slice for empty %s metrics, got %d elements", metricType, len(result)) } if result == nil { t.Errorf("expected empty slice for empty %s metrics, got nil", metricType) } }) } } func TestGetNodeMetrics(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, time.Hour) // Add test data mh.AddNodeMetric("node1", "cpu", 25.0, now.Add(-45*time.Minute)) mh.AddNodeMetric("node1", "cpu", 35.0, now.Add(-30*time.Minute)) mh.AddNodeMetric("node1", "cpu", 45.0, now.Add(-15*time.Minute)) mh.AddNodeMetric("node1", "memory", 60.0, now.Add(-10*time.Minute)) mh.AddNodeMetric("node1", "disk", 75.0, now.Add(-5*time.Minute)) tests := []struct { name string nodeID string metricType string duration time.Duration wantLen int }{ { name: "get all cpu", nodeID: "node1", metricType: "cpu", duration: time.Hour, wantLen: 3, }, { name: "get recent cpu", nodeID: "node1", metricType: "cpu", duration: 20 * time.Minute, wantLen: 1, }, { name: "get memory", nodeID: "node1", metricType: "memory", duration: time.Hour, wantLen: 1, }, { name: "get disk", nodeID: "node1", metricType: "disk", duration: time.Hour, wantLen: 1, }, { name: "nonexistent node", nodeID: "node999", metricType: "cpu", duration: time.Hour, wantLen: 0, }, { name: "invalid metric type", nodeID: "node1", metricType: "netin", duration: time.Hour, wantLen: 0, }, { name: "zero duration returns nothing", nodeID: "node1", metricType: "cpu", duration: 0, wantLen: 0, }, { name: "empty nodeID", nodeID: "", metricType: "cpu", duration: time.Hour, wantLen: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := mh.GetNodeMetrics(tt.nodeID, tt.metricType, tt.duration) if len(result) != tt.wantLen { t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen) } }) } } func TestGetNodeMetrics_UnknownMetricTypes(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, time.Hour) // Add data for the node mh.AddNodeMetric("node1", "cpu", 50.0, now.Add(-5*time.Minute)) mh.AddNodeMetric("node1", "memory", 70.0, now.Add(-5*time.Minute)) mh.AddNodeMetric("node1", "disk", 40.0, now.Add(-5*time.Minute)) unknownTypes := []string{"invalid", "unknown", "netin", "netout", "diskread", "CPU", "Memory", "Disk", ""} for _, metricType := range unknownTypes { t.Run("type_"+metricType, func(t *testing.T) { result := mh.GetNodeMetrics("node1", metricType, time.Hour) if len(result) != 0 { t.Errorf("expected empty slice for unknown metric type %q, got %d elements", metricType, len(result)) } if result == nil { t.Errorf("expected empty slice for unknown metric type %q, got nil", metricType) } }) } } func TestGetNodeMetrics_EmptyMetricsData(t *testing.T) { mh := NewMetricsHistory(100, time.Hour) // Directly populate an empty node metrics entry (nodes use GuestMetrics type) mh.mu.Lock() mh.nodeMetrics["node-empty"] = &GuestMetrics{ CPU: []MetricPoint{}, Memory: []MetricPoint{}, Disk: []MetricPoint{}, } mh.mu.Unlock() metricTypes := []string{"cpu", "memory", "disk"} for _, metricType := range metricTypes { t.Run(metricType, func(t *testing.T) { result := mh.GetNodeMetrics("node-empty", metricType, time.Hour) if len(result) != 0 { t.Errorf("expected empty slice for empty %s metrics, got %d elements", metricType, len(result)) } if result == nil { t.Errorf("expected empty slice for empty %s metrics, got nil", metricType) } }) } } func TestGetNodeMetrics_DurationFiltering(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, 2*time.Hour) // Add points at different times mh.AddNodeMetric("node1", "cpu", 10.0, now.Add(-90*time.Minute)) // old mh.AddNodeMetric("node1", "cpu", 20.0, now.Add(-60*time.Minute)) // old mh.AddNodeMetric("node1", "cpu", 30.0, now.Add(-30*time.Minute)) // recent mh.AddNodeMetric("node1", "cpu", 40.0, now.Add(-15*time.Minute)) // recent mh.AddNodeMetric("node1", "cpu", 50.0, now.Add(-5*time.Minute)) // recent tests := []struct { name string duration time.Duration wantLen int wantFirst float64 wantLast float64 }{ { name: "all points within 2 hours", duration: 2 * time.Hour, wantLen: 5, wantFirst: 10.0, wantLast: 50.0, }, { name: "points within 45 minutes", duration: 45 * time.Minute, wantLen: 3, wantFirst: 30.0, wantLast: 50.0, }, { name: "points within 20 minutes", duration: 20 * time.Minute, wantLen: 2, wantFirst: 40.0, wantLast: 50.0, }, { name: "points within 10 minutes", duration: 10 * time.Minute, wantLen: 1, wantFirst: 50.0, wantLast: 50.0, }, { name: "zero duration excludes all", duration: 0, wantLen: 0, }, { name: "very short duration excludes all", duration: 1 * time.Minute, wantLen: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := mh.GetNodeMetrics("node1", "cpu", tt.duration) if len(result) != tt.wantLen { t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen) } if len(result) > 0 { if result[0].Value != tt.wantFirst { t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst) } if result[len(result)-1].Value != tt.wantLast { t.Errorf("last value = %v, want %v", result[len(result)-1].Value, tt.wantLast) } } }) } } func TestGetAllGuestMetrics(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, time.Hour) // Add test data for multiple metric types mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-30*time.Minute)) mh.AddGuestMetric("vm-100", "memory", 70.0, now.Add(-25*time.Minute)) mh.AddGuestMetric("vm-100", "disk", 40.0, now.Add(-20*time.Minute)) mh.AddGuestMetric("vm-100", "diskread", 100.0, now.Add(-15*time.Minute)) mh.AddGuestMetric("vm-100", "diskwrite", 50.0, now.Add(-10*time.Minute)) mh.AddGuestMetric("vm-100", "netin", 1000.0, now.Add(-5*time.Minute)) mh.AddGuestMetric("vm-100", "netout", 500.0, now.Add(-1*time.Minute)) tests := []struct { name string guestID string duration time.Duration wantKeys []string }{ { name: "get all metrics within hour", guestID: "vm-100", duration: time.Hour, wantKeys: []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"}, }, { name: "nonexistent guest", guestID: "vm-999", duration: time.Hour, wantKeys: []string{}, }, { name: "short duration filters out old", guestID: "vm-100", duration: 10 * time.Minute, wantKeys: []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"}, // keys exist but may be empty }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := mh.GetAllGuestMetrics(tt.guestID, tt.duration) if tt.guestID == "vm-999" { if len(result) != 0 { t.Errorf("expected empty result for nonexistent guest") } return } // Check that expected keys exist for _, key := range tt.wantKeys { if _, exists := result[key]; !exists { t.Errorf("missing key: %s", key) } } }) } } func TestGetAllStorageMetrics(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, time.Hour) // Add test data mh.AddStorageMetric("local-lvm", "usage", 45.0, now.Add(-30*time.Minute)) mh.AddStorageMetric("local-lvm", "used", 100e9, now.Add(-25*time.Minute)) mh.AddStorageMetric("local-lvm", "total", 200e9, now.Add(-20*time.Minute)) mh.AddStorageMetric("local-lvm", "avail", 100e9, now.Add(-15*time.Minute)) tests := []struct { name string storageID string duration time.Duration wantKeys []string }{ { name: "get all metrics", storageID: "local-lvm", duration: time.Hour, wantKeys: []string{"usage", "used", "total", "avail"}, }, { name: "nonexistent storage", storageID: "nonexistent", duration: time.Hour, wantKeys: []string{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := mh.GetAllStorageMetrics(tt.storageID, tt.duration) if len(tt.wantKeys) == 0 { if len(result) != 0 { t.Errorf("expected empty result") } return } for _, key := range tt.wantKeys { if _, exists := result[key]; !exists { t.Errorf("missing key: %s", key) } } }) } } func TestCleanup(t *testing.T) { now := time.Now() retentionTime := 30 * time.Minute mh := NewMetricsHistory(100, retentionTime) // Add old data (beyond retention) mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-2*time.Hour)) mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-90*time.Minute)) // Add recent data (within retention) mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-20*time.Minute)) mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-10*time.Minute)) // Add node metrics mh.AddNodeMetric("node1", "cpu", 50.0, now.Add(-2*time.Hour)) mh.AddNodeMetric("node1", "cpu", 60.0, now.Add(-10*time.Minute)) // Add storage metrics mh.AddStorageMetric("local", "usage", 70.0, now.Add(-2*time.Hour)) mh.AddStorageMetric("local", "usage", 80.0, now.Add(-10*time.Minute)) // Run cleanup mh.Cleanup() // Verify old data was removed mh.mu.RLock() defer mh.mu.RUnlock() // Check guest metrics guestMetrics := mh.guestMetrics["vm-100"] if len(guestMetrics.CPU) != 2 { t.Errorf("guest CPU metrics count = %d, want 2", len(guestMetrics.CPU)) } // Check node metrics nodeMetrics := mh.nodeMetrics["node1"] if len(nodeMetrics.CPU) != 1 { t.Errorf("node CPU metrics count = %d, want 1", len(nodeMetrics.CPU)) } // Check storage metrics storageMetrics := mh.storageMetrics["local"] if len(storageMetrics.Usage) != 1 { t.Errorf("storage usage metrics count = %d, want 1", len(storageMetrics.Usage)) } } func TestMultipleGuestsIsolation(t *testing.T) { now := time.Now() mh := NewMetricsHistory(100, time.Hour) // Add metrics for different guests mh.AddGuestMetric("vm-100", "cpu", 50.0, now) mh.AddGuestMetric("vm-101", "cpu", 60.0, now) mh.AddGuestMetric("vm-102", "cpu", 70.0, now) // Verify isolation result100 := mh.GetGuestMetrics("vm-100", "cpu", time.Hour) result101 := mh.GetGuestMetrics("vm-101", "cpu", time.Hour) result102 := mh.GetGuestMetrics("vm-102", "cpu", time.Hour) if len(result100) != 1 || result100[0].Value != 50.0 { t.Errorf("vm-100 metrics incorrect") } if len(result101) != 1 || result101[0].Value != 60.0 { t.Errorf("vm-101 metrics incorrect") } if len(result102) != 1 || result102[0].Value != 70.0 { t.Errorf("vm-102 metrics incorrect") } } func TestMaxDataPointsEnforced(t *testing.T) { now := time.Now() maxPoints := 5 mh := NewMetricsHistory(maxPoints, time.Hour) // Add more points than maxDataPoints for i := 0; i < 10; i++ { mh.AddGuestMetric("vm-100", "cpu", float64(i*10), now.Add(-time.Duration(10-i)*time.Minute)) } result := mh.GetGuestMetrics("vm-100", "cpu", time.Hour) if len(result) != maxPoints { t.Errorf("got %d points, want %d", len(result), maxPoints) } // Verify we have the most recent points (values 50-90) if result[0].Value != 50.0 { t.Errorf("first value = %v, want 50.0 (most recent kept)", result[0].Value) } if result[len(result)-1].Value != 90.0 { t.Errorf("last value = %v, want 90.0", result[len(result)-1].Value) } } func TestRetentionTimeEnforced(t *testing.T) { now := time.Now() retentionTime := 30 * time.Minute mh := NewMetricsHistory(100, retentionTime) // Add points at various times mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-2*time.Hour)) // beyond retention mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-1*time.Hour)) // beyond retention mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-45*time.Minute)) // beyond retention mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-20*time.Minute)) // within retention mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-10*time.Minute)) // within retention mh.AddGuestMetric("vm-100", "cpu", 60.0, now) // within retention // appendMetric removes old points on each add result := mh.GetGuestMetrics("vm-100", "cpu", time.Hour) // Due to appendMetric behavior, only recent points should remain if len(result) > 3 { t.Errorf("got %d points, expected <= 3 recent points after retention enforcement", len(result)) } } func TestCleanupRemovesStaleEntries(t *testing.T) { now := time.Now() retentionTime := 30 * time.Minute mh := NewMetricsHistory(100, retentionTime) // Add data for three guests: // - "active-guest" has recent data // - "stale-guest" has only old data (will be removed) // - "mixed-guest" has both old and recent data mh.AddGuestMetric("active-guest", "cpu", 50.0, now.Add(-10*time.Minute)) mh.AddGuestMetric("active-guest", "memory", 60.0, now.Add(-10*time.Minute)) mh.AddGuestMetric("stale-guest", "cpu", 30.0, now.Add(-2*time.Hour)) mh.AddGuestMetric("stale-guest", "memory", 40.0, now.Add(-2*time.Hour)) mh.AddGuestMetric("mixed-guest", "cpu", 10.0, now.Add(-2*time.Hour)) // old, will be removed mh.AddGuestMetric("mixed-guest", "cpu", 70.0, now.Add(-5*time.Minute)) // Add similar for nodes and storage mh.AddNodeMetric("active-node", "cpu", 50.0, now.Add(-10*time.Minute)) mh.AddNodeMetric("stale-node", "cpu", 30.0, now.Add(-2*time.Hour)) mh.AddStorageMetric("active-storage", "usage", 50.0, now.Add(-10*time.Minute)) mh.AddStorageMetric("stale-storage", "usage", 30.0, now.Add(-2*time.Hour)) // Run cleanup mh.Cleanup() // Verify stale entries were removed mh.mu.RLock() defer mh.mu.RUnlock() // active-guest should still exist if _, exists := mh.guestMetrics["active-guest"]; !exists { t.Error("active-guest should still exist after cleanup") } // stale-guest should be removed (all data expired) if _, exists := mh.guestMetrics["stale-guest"]; exists { t.Error("stale-guest should be removed after cleanup") } // mixed-guest should still exist (has recent data) if _, exists := mh.guestMetrics["mixed-guest"]; !exists { t.Error("mixed-guest should still exist after cleanup") } // Verify node entries if _, exists := mh.nodeMetrics["active-node"]; !exists { t.Error("active-node should still exist after cleanup") } if _, exists := mh.nodeMetrics["stale-node"]; exists { t.Error("stale-node should be removed after cleanup") } // Verify storage entries if _, exists := mh.storageMetrics["active-storage"]; !exists { t.Error("active-storage should still exist after cleanup") } if _, exists := mh.storageMetrics["stale-storage"]; exists { t.Error("stale-storage should be removed after cleanup") } } func TestCleanupMetricsReturnsNilForExpiredData(t *testing.T) { retentionTime := 30 * time.Minute mh := NewMetricsHistory(100, retentionTime) now := time.Now() cutoff := now.Add(-retentionTime) // Create slice with only old data oldData := []MetricPoint{ {Value: 10.0, Timestamp: now.Add(-2 * time.Hour)}, {Value: 20.0, Timestamp: now.Add(-1 * time.Hour)}, } result := mh.cleanupMetrics(oldData, cutoff) // Should return nil (not empty slice) to release backing array if result != nil { t.Errorf("cleanupMetrics should return nil for fully expired data, got slice with len=%d cap=%d", len(result), cap(result)) } }