test: Add tests for handleProxyFailure, parseRPiTemperature, RecordResult

- handleProxyFailure: 86.7%→100% (6 cases for proxy disable threshold logic)
- parseRPiTemperature: 77.8%→100% (7 cases for RPi temp parsing edge cases)
- RecordResult: 95.8%→100% (negative staleness clamping case)
This commit is contained in:
rcourtman 2025-12-01 19:33:06 +00:00
parent 1f380eaaf3
commit c6a34c4412
2 changed files with 312 additions and 22 deletions

View file

@ -1195,3 +1195,37 @@ func TestDecrementPending_MultipleDecrements(t *testing.T) {
t.Errorf("pending = %v, want 0 after extra decrement", gotPending)
}
}
func TestRecordResult_FailureNegativeStalenessClampedToZero(t *testing.T) {
t.Parallel()
pm := newFullTestPollMetrics(t)
// Record a success with a future timestamp
futureTime := time.Now().Add(10 * time.Second)
pm.RecordResult(PollResult{
InstanceType: "pve",
InstanceName: "negative-staleness",
StartTime: futureTime.Add(-time.Second),
EndTime: futureTime,
Success: true,
})
// Now record a failure with an EndTime BEFORE the last success
// This creates a negative staleness calculation (EndTime - lastSuccess < 0)
pastTime := futureTime.Add(-5 * time.Second)
pm.RecordResult(PollResult{
InstanceType: "pve",
InstanceName: "negative-staleness",
StartTime: pastTime.Add(-time.Second),
EndTime: pastTime,
Success: false,
Error: errors.New("failed"),
})
// Staleness should be clamped to 0, not negative
gotStaleness := getGaugeVecValue(pm.staleness, "pve", "negative-staleness")
if gotStaleness != 0 {
t.Errorf("staleness = %v, want 0 for negative staleness calculation", gotStaleness)
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math"
"strings"
"sync"
"sync/atomic"
"testing"
@ -351,32 +352,107 @@ func TestParseSensorsJSON_ZeroTemperature(t *testing.T) {
}
func TestParseRPiTemperature(t *testing.T) {
collector := &TemperatureCollector{}
temp, err := collector.parseRPiTemperature("48720\n")
if err != nil {
t.Fatalf("unexpected error parsing RPi thermal zone output: %v", err)
tests := []struct {
name string
output string
wantErr bool
errContains string
wantTempC float64
}{
{
name: "empty output returns error",
output: "",
wantErr: true,
errContains: "empty RPi temperature output",
},
{
name: "whitespace-only output returns error",
output: " \t\n ",
wantErr: true,
errContains: "empty RPi temperature output",
},
{
name: "invalid non-numeric output returns error",
output: "not-a-number",
wantErr: true,
errContains: "failed to parse RPi temperature",
},
{
name: "valid millidegrees 45678 returns 45.678°C",
output: "45678",
wantErr: false,
wantTempC: 45.678,
},
{
name: "valid millidegrees with whitespace returns correct temp",
output: " 45678\n",
wantErr: false,
wantTempC: 45.678,
},
{
name: "zero value returns 0°C",
output: "0",
wantErr: false,
wantTempC: 0.0,
},
{
name: "negative value returns negative temp",
output: "-5000",
wantErr: false,
wantTempC: -5.0,
},
}
if !temp.Available {
t.Fatalf("expected temperature to be marked available")
}
if !temp.HasCPU {
t.Fatalf("expected HasCPU to be true for RPi thermal zone output")
}
expected := 48.72
if diff := temp.CPUPackage - expected; diff > 1e-6 || diff < -1e-6 {
t.Fatalf("expected cpu package temperature %.2f, got %.2f", expected, temp.CPUPackage)
}
if temp.CPUMax != temp.CPUPackage {
t.Fatalf("expected cpu max to match package temperature %.2f, got %.2f", temp.CPUPackage, temp.CPUMax)
}
if temp.LastUpdate.IsZero() {
t.Fatalf("expected LastUpdate to be set")
}
if elapsed := time.Since(temp.LastUpdate); elapsed > 2*time.Second {
t.Fatalf("expected LastUpdate to be recent, got %s", elapsed)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
collector := &TemperatureCollector{}
temp, err := collector.parseRPiTemperature(tt.output)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.errContains)
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if temp == nil {
t.Fatalf("expected temperature struct, got nil")
}
if !temp.Available {
t.Errorf("expected temperature to be marked available")
}
if !temp.HasCPU {
t.Errorf("expected HasCPU to be true")
}
if diff := temp.CPUPackage - tt.wantTempC; diff > 1e-9 || diff < -1e-9 {
t.Errorf("expected CPUPackage %.3f, got %.3f", tt.wantTempC, temp.CPUPackage)
}
if temp.CPUMax != temp.CPUPackage {
t.Errorf("expected CPUMax to equal CPUPackage (%.3f), got %.3f", temp.CPUPackage, temp.CPUMax)
}
if len(temp.Cores) != 0 {
t.Errorf("expected empty Cores slice, got %d entries", len(temp.Cores))
}
if len(temp.NVMe) != 0 {
t.Errorf("expected empty NVMe slice, got %d entries", len(temp.NVMe))
}
if temp.LastUpdate.IsZero() {
t.Errorf("expected LastUpdate to be set")
}
if elapsed := time.Since(temp.LastUpdate); elapsed > 2*time.Second {
t.Errorf("expected LastUpdate to be recent, got %s", elapsed)
}
})
}
}
func TestParseSensorsJSON_PiPartialSensors(t *testing.T) {
collector := &TemperatureCollector{}
@ -2103,6 +2179,186 @@ func TestIsProxyEnabled_ZeroCooldownProxyUnavailable(t *testing.T) {
}
}
// =============================================================================
// Tests for handleProxyFailure
// =============================================================================
func TestHandleProxyFailure_NilProxyClient(t *testing.T) {
tc := &TemperatureCollector{
proxyClient: nil,
proxyFailures: 2,
proxyHostStates: make(map[string]*proxyHostState),
}
// Should return early without panic
tc.handleProxyFailure("192.168.1.100", fmt.Errorf("some error"))
// State should remain unchanged
if tc.proxyFailures != 2 {
t.Errorf("expected proxyFailures to remain 2, got %d", tc.proxyFailures)
}
if len(tc.proxyHostStates) != 0 {
t.Errorf("expected proxyHostStates to remain empty, got %d entries", len(tc.proxyHostStates))
}
}
func TestHandleProxyFailure_ShouldDisableProxyFalse_CallsHandleProxyHostFailure(t *testing.T) {
stub := &stubTemperatureProxy{}
tc := &TemperatureCollector{
proxyClient: stub,
proxyFailures: 0,
useProxy: true,
proxyHostStates: make(map[string]*proxyHostState),
}
// ErrorTypeSensor does NOT trigger shouldDisableProxy (returns false)
sensorErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeSensor, Message: "sensor not found"}
tc.handleProxyFailure("192.168.1.100", sensorErr)
// proxyFailures should NOT be incremented (that's for global disable path)
if tc.proxyFailures != 0 {
t.Errorf("expected proxyFailures to remain 0, got %d", tc.proxyFailures)
}
// handleProxyHostFailure should have been called - check host state
tc.proxyMu.Lock()
state, exists := tc.proxyHostStates["192.168.1.100"]
tc.proxyMu.Unlock()
if !exists {
t.Fatal("expected host to be added to proxyHostStates")
}
if state.failures != 1 {
t.Errorf("expected host failures to be 1, got %d", state.failures)
}
if state.lastError != "sensor not found" {
t.Errorf("expected lastError to be 'sensor not found', got %q", state.lastError)
}
}
func TestHandleProxyFailure_ShouldDisableProxyTrue_IncrementsFailures(t *testing.T) {
stub := &stubTemperatureProxy{}
tc := &TemperatureCollector{
proxyClient: stub,
proxyFailures: 0,
useProxy: true,
proxyHostStates: make(map[string]*proxyHostState),
}
// ErrorTypeTransport triggers shouldDisableProxy (returns true)
transportErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "connection refused"}
tc.handleProxyFailure("192.168.1.100", transportErr)
if tc.proxyFailures != 1 {
t.Errorf("expected proxyFailures to be 1, got %d", tc.proxyFailures)
}
// Proxy should still be enabled (threshold not reached)
if !tc.useProxy {
t.Error("expected useProxy to remain true (threshold not reached)")
}
// handleProxyHostFailure should NOT have been called
tc.proxyMu.Lock()
hostStateCount := len(tc.proxyHostStates)
tc.proxyMu.Unlock()
if hostStateCount != 0 {
t.Errorf("expected proxyHostStates to remain empty, got %d entries", hostStateCount)
}
}
func TestHandleProxyFailure_ReachesThreshold_DisablesProxy(t *testing.T) {
stub := &stubTemperatureProxy{}
tc := &TemperatureCollector{
proxyClient: stub,
proxyFailures: proxyFailureThreshold - 1, // one failure away from threshold
useProxy: true,
proxyHostStates: make(map[string]*proxyHostState),
}
transportErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "connection refused"}
before := time.Now()
tc.handleProxyFailure("192.168.1.100", transportErr)
// Proxy should now be disabled
if tc.useProxy {
t.Error("expected useProxy to be false after reaching threshold")
}
// proxyFailures should be reset to 0
if tc.proxyFailures != 0 {
t.Errorf("expected proxyFailures to be reset to 0, got %d", tc.proxyFailures)
}
// proxyCooldownUntil should be set in the future
if !tc.proxyCooldownUntil.After(before) {
t.Errorf("expected proxyCooldownUntil to be in the future, got %s", tc.proxyCooldownUntil)
}
}
func TestHandleProxyFailure_ReachesThreshold_UseProxyFalse_NoDoubleDisable(t *testing.T) {
stub := &stubTemperatureProxy{}
originalCooldown := time.Now().Add(10 * time.Minute)
tc := &TemperatureCollector{
proxyClient: stub,
proxyFailures: proxyFailureThreshold - 1, // one failure away from threshold
useProxy: false, // already disabled
proxyCooldownUntil: originalCooldown,
proxyHostStates: make(map[string]*proxyHostState),
}
transportErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "connection refused"}
tc.handleProxyFailure("192.168.1.100", transportErr)
// proxyFailures should increment (we still track failures)
if tc.proxyFailures != proxyFailureThreshold {
t.Errorf("expected proxyFailures to be %d, got %d", proxyFailureThreshold, tc.proxyFailures)
}
// useProxy should remain false
if tc.useProxy {
t.Error("expected useProxy to remain false")
}
// proxyCooldownUntil should NOT be changed (no double-disable)
if !tc.proxyCooldownUntil.Equal(originalCooldown) {
t.Errorf("expected proxyCooldownUntil to remain %s, got %s", originalCooldown, tc.proxyCooldownUntil)
}
}
func TestHandleProxyFailure_PlainError_TriggersDisablePath(t *testing.T) {
stub := &stubTemperatureProxy{}
tc := &TemperatureCollector{
proxyClient: stub,
proxyFailures: 0,
useProxy: true,
proxyHostStates: make(map[string]*proxyHostState),
}
// Plain errors (not ProxyError) should trigger shouldDisableProxy (returns true)
plainErr := fmt.Errorf("connection refused")
tc.handleProxyFailure("192.168.1.100", plainErr)
if tc.proxyFailures != 1 {
t.Errorf("expected proxyFailures to be 1, got %d", tc.proxyFailures)
}
// handleProxyHostFailure should NOT be called
tc.proxyMu.Lock()
hostStateCount := len(tc.proxyHostStates)
tc.proxyMu.Unlock()
if hostStateCount != 0 {
t.Errorf("expected proxyHostStates to remain empty, got %d entries", hostStateCount)
}
}
// Helper functions for test setup
func intPtr(i int) *int {