diff --git a/internal/monitoring/circuit_breaker_test.go b/internal/monitoring/circuit_breaker_test.go index d264dc9..fedcd2d 100644 --- a/internal/monitoring/circuit_breaker_test.go +++ b/internal/monitoring/circuit_breaker_test.go @@ -364,3 +364,107 @@ func TestCircuitBreaker_ConcurrentAccess(t *testing.T) { <-done } } + +func TestCircuitBreaker_StateDetails(t *testing.T) { + t.Run("closed state", func(t *testing.T) { + cb := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second) + beforeCreate := time.Now().Add(-time.Millisecond) + + state, failures, retryAt, since, lastTransition := cb.stateDetails() + + if state != "closed" { + t.Errorf("state = %s, want closed", state) + } + if failures != 0 { + t.Errorf("failures = %d, want 0", failures) + } + if !retryAt.IsZero() { + t.Errorf("retryAt = %v, want zero time", retryAt) + } + if since.Before(beforeCreate) { + t.Errorf("since = %v, should be after %v", since, beforeCreate) + } + if lastTransition.Before(beforeCreate) { + t.Errorf("lastTransition = %v, should be after %v", lastTransition, beforeCreate) + } + }) + + t.Run("open state", func(t *testing.T) { + cb := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second) + now := time.Now() + + // Trip the breaker to open state + for i := 0; i < 3; i++ { + cb.recordFailure(now) + } + + state, failures, retryAt, since, lastTransition := cb.stateDetails() + + if state != "open" { + t.Errorf("state = %s, want open", state) + } + if failures != 3 { + t.Errorf("failures = %d, want 3", failures) + } + // retryInterval after 3 failures is 5s << 3 = 40s + expectedRetryAt := now.Add(40 * time.Second) + if retryAt.Sub(expectedRetryAt).Abs() > time.Millisecond { + t.Errorf("retryAt = %v, want %v", retryAt, expectedRetryAt) + } + // since should be set to when breaker was tripped + if since.Sub(now).Abs() > time.Millisecond { + t.Errorf("since = %v, want ~%v", since, now) + } + if lastTransition.Sub(now).Abs() > time.Millisecond { + t.Errorf("lastTransition = %v, want ~%v", lastTransition, now) + } + }) + + t.Run("half_open state", func(t *testing.T) { + cb := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second) + now := time.Now() + + // Trip the breaker + for i := 0; i < 3; i++ { + cb.recordFailure(now) + } + + // Transition to half-open (retry interval is 5s << 3 = 40s) + halfOpenTime := now.Add(41 * time.Second) + cb.allow(halfOpenTime) + + state, failures, retryAt, since, lastTransition := cb.stateDetails() + + if state != "half_open" { + t.Errorf("state = %s, want half_open", state) + } + if failures != 3 { + t.Errorf("failures = %d, want 3", failures) + } + // retryAt should be lastAttempt + halfOpenWindow (30s) + expectedRetryAt := halfOpenTime.Add(30 * time.Second) + if retryAt.Sub(expectedRetryAt).Abs() > time.Millisecond { + t.Errorf("retryAt = %v, want %v", retryAt, expectedRetryAt) + } + // since should be when we transitioned to half-open + if since.Sub(halfOpenTime).Abs() > time.Millisecond { + t.Errorf("since = %v, want ~%v", since, halfOpenTime) + } + if lastTransition.Sub(halfOpenTime).Abs() > time.Millisecond { + t.Errorf("lastTransition = %v, want ~%v", lastTransition, halfOpenTime) + } + }) + + t.Run("unknown state (invalid internal state)", func(t *testing.T) { + cb := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second) + + // Directly set state to an invalid value to trigger default case + cb.state = breakerState(99) + + state, _, _, _, _ := cb.stateDetails() + + if state != "unknown" { + t.Errorf("state = %s, want unknown", state) + } + }) +} diff --git a/internal/monitoring/metrics_history_test.go b/internal/monitoring/metrics_history_test.go index afc235d..0d118ef 100644 --- a/internal/monitoring/metrics_history_test.go +++ b/internal/monitoring/metrics_history_test.go @@ -692,6 +692,7 @@ func TestGetNodeMetrics(t *testing.T) { 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 @@ -721,6 +722,13 @@ func TestGetNodeMetrics(t *testing.T) { duration: time.Hour, wantLen: 1, }, + { + name: "get disk", + nodeID: "node1", + metricType: "disk", + duration: time.Hour, + wantLen: 1, + }, { name: "nonexistent node", nodeID: "node999", @@ -735,6 +743,20 @@ func TestGetNodeMetrics(t *testing.T) { 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 { @@ -747,6 +769,136 @@ func TestGetNodeMetrics(t *testing.T) { } } +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) diff --git a/internal/monitoring/monitor_test.go b/internal/monitoring/monitor_test.go index 4202d54..c47f2e2 100644 --- a/internal/monitoring/monitor_test.go +++ b/internal/monitoring/monitor_test.go @@ -1008,6 +1008,60 @@ func TestLookupClusterEndpointLabel(t *testing.T) { t.Errorf("expected 'first.lan', got %q", result) } }) + + t.Run("falls back to IP when Host empty and NodeName whitespace-only", func(t *testing.T) { + // EqualFold(" ", " ") returns true, TrimSpace(" ") returns "" + // This tests the IP fallback path (lines 295-297) + instance := &config.PVEInstance{ + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: " ", Host: "", IP: "10.0.0.100"}, + }, + } + result := lookupClusterEndpointLabel(instance, " ") + if result != "10.0.0.100" { + t.Errorf("expected '10.0.0.100', got %q", result) + } + }) + + t.Run("returns empty when all fields empty for matching endpoint", func(t *testing.T) { + // Match on empty NodeName, all label fields empty + instance := &config.PVEInstance{ + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: "", Host: "", IP: ""}, + }, + } + result := lookupClusterEndpointLabel(instance, "") + if result != "" { + t.Errorf("expected empty string, got %q", result) + } + }) + + t.Run("IP fallback with whitespace in IP field", func(t *testing.T) { + instance := &config.PVEInstance{ + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: "", Host: "", IP: " 192.168.1.50 "}, + }, + } + result := lookupClusterEndpointLabel(instance, "") + if result != "192.168.1.50" { + t.Errorf("expected '192.168.1.50', got %q", result) + } + }) + + t.Run("falls back to IP when Host is IP and NodeName is empty string", func(t *testing.T) { + instance := &config.PVEInstance{ + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: "", Host: "https://172.16.0.1:8006", IP: "10.20.30.40"}, + }, + } + result := lookupClusterEndpointLabel(instance, "") + // Host normalizes to "172.16.0.1" which is an IP, so skip it + // NodeName is empty after trim, so skip it + // Fall back to IP: "10.20.30.40" + if result != "10.20.30.40" { + t.Errorf("expected '10.20.30.40', got %q", result) + } + }) } func TestExtractSnapshotName(t *testing.T) {