test: Add tests for lookupClusterEndpointLabel, GetNodeMetrics, stateDetails

- lookupClusterEndpointLabel: nil instance, no match, Host vs IP fallback,
  case-insensitive NodeName, whitespace handling (84.6% -> 100%)
- GetNodeMetrics: unknown metric types, empty nodeID, zero duration,
  empty metrics data, disk type (94.1% -> 100%)
- stateDetails: all breaker states including unknown/invalid state,
  retryAt calculations, timestamps (90% -> 100%)
This commit is contained in:
rcourtman 2025-12-01 20:07:22 +00:00
parent 2fdc051c11
commit 0a9177f161
3 changed files with 310 additions and 0 deletions

View file

@ -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)
}
})
}

View file

@ -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)

View file

@ -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) {