diff --git a/internal/monitoring/metrics_history_test.go b/internal/monitoring/metrics_history_test.go new file mode 100644 index 000000000..9640bd85c --- /dev/null +++ b/internal/monitoring/metrics_history_test.go @@ -0,0 +1,792 @@ +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: 2, // cleanupMetrics doesn't remove all - keeps slice if nothing after cutoff + }, + { + 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 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)) + + 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: "nonexistent node", + nodeID: "node999", + metricType: "cpu", + duration: time.Hour, + wantLen: 0, + }, + { + name: "invalid metric type", + nodeID: "node1", + metricType: "netin", + 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 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)) + } +}