mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
test: Add tests for handleProxyHostFailure, recordNodeSnapshot, evaluateHostAgents
- handleProxyHostFailure: 76.5%→100% (9 cases for per-host failure tracking) - recordNodeSnapshot: 75%→100% (6 cases for diagnostic snapshot storage) - evaluateHostAgents: 87%→100% (10 cases for host health evaluation)
This commit is contained in:
@@ -758,6 +758,213 @@ func TestLogNodeMemorySource(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordNodeSnapshot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
monitor *Monitor
|
||||
instance string
|
||||
node string
|
||||
snapshot NodeMemorySnapshot
|
||||
wantPanic bool
|
||||
wantMapLen int
|
||||
wantInstance string
|
||||
wantNode string
|
||||
checkRetrievedAt string // "set" (auto-set), "preserved" (kept from input), "skip" (don't check)
|
||||
inputRetrievedAt time.Time
|
||||
}{
|
||||
{
|
||||
name: "nil Monitor returns early without panic",
|
||||
monitor: nil,
|
||||
instance: "pve1",
|
||||
node: "node1",
|
||||
snapshot: NodeMemorySnapshot{},
|
||||
wantPanic: false,
|
||||
checkRetrievedAt: "skip",
|
||||
},
|
||||
{
|
||||
name: "nil nodeSnapshots map gets initialized",
|
||||
monitor: &Monitor{
|
||||
nodeSnapshots: nil,
|
||||
guestSnapshots: make(map[string]GuestMemorySnapshot),
|
||||
},
|
||||
instance: "pve1",
|
||||
node: "node1",
|
||||
snapshot: NodeMemorySnapshot{MemorySource: "test"},
|
||||
wantMapLen: 1,
|
||||
wantInstance: "pve1",
|
||||
wantNode: "node1",
|
||||
checkRetrievedAt: "set",
|
||||
},
|
||||
{
|
||||
name: "Instance and Node are set from parameters",
|
||||
monitor: &Monitor{
|
||||
nodeSnapshots: make(map[string]NodeMemorySnapshot),
|
||||
guestSnapshots: make(map[string]GuestMemorySnapshot),
|
||||
},
|
||||
instance: "my-instance",
|
||||
node: "my-node",
|
||||
snapshot: NodeMemorySnapshot{
|
||||
Instance: "wrong-instance",
|
||||
Node: "wrong-node",
|
||||
},
|
||||
wantMapLen: 1,
|
||||
wantInstance: "my-instance",
|
||||
wantNode: "my-node",
|
||||
checkRetrievedAt: "set",
|
||||
},
|
||||
{
|
||||
name: "zero RetrievedAt gets set to current time",
|
||||
monitor: &Monitor{
|
||||
nodeSnapshots: make(map[string]NodeMemorySnapshot),
|
||||
guestSnapshots: make(map[string]GuestMemorySnapshot),
|
||||
},
|
||||
instance: "pve1",
|
||||
node: "node1",
|
||||
snapshot: NodeMemorySnapshot{},
|
||||
wantMapLen: 1,
|
||||
wantInstance: "pve1",
|
||||
wantNode: "node1",
|
||||
checkRetrievedAt: "set",
|
||||
},
|
||||
{
|
||||
name: "non-zero RetrievedAt is preserved",
|
||||
monitor: &Monitor{
|
||||
nodeSnapshots: make(map[string]NodeMemorySnapshot),
|
||||
guestSnapshots: make(map[string]GuestMemorySnapshot),
|
||||
},
|
||||
instance: "pve1",
|
||||
node: "node1",
|
||||
snapshot: NodeMemorySnapshot{RetrievedAt: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)},
|
||||
wantMapLen: 1,
|
||||
wantInstance: "pve1",
|
||||
wantNode: "node1",
|
||||
checkRetrievedAt: "preserved",
|
||||
inputRetrievedAt: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "snapshot is stored with correct key",
|
||||
monitor: &Monitor{
|
||||
nodeSnapshots: make(map[string]NodeMemorySnapshot),
|
||||
guestSnapshots: make(map[string]GuestMemorySnapshot),
|
||||
},
|
||||
instance: "pve1",
|
||||
node: "node1",
|
||||
snapshot: NodeMemorySnapshot{MemorySource: "rrd-available"},
|
||||
wantMapLen: 1,
|
||||
wantInstance: "pve1",
|
||||
wantNode: "node1",
|
||||
checkRetrievedAt: "set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
beforeRecord := time.Now()
|
||||
|
||||
// Call recordNodeSnapshot - should not panic
|
||||
tc.monitor.recordNodeSnapshot(tc.instance, tc.node, tc.snapshot)
|
||||
|
||||
afterRecord := time.Now()
|
||||
|
||||
// For nil monitor, we just verify no panic occurred
|
||||
if tc.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify map was initialized and has expected length
|
||||
if tc.monitor.nodeSnapshots == nil {
|
||||
t.Fatal("nodeSnapshots should have been initialized")
|
||||
}
|
||||
if len(tc.monitor.nodeSnapshots) != tc.wantMapLen {
|
||||
t.Errorf("nodeSnapshots length = %d, want %d", len(tc.monitor.nodeSnapshots), tc.wantMapLen)
|
||||
}
|
||||
|
||||
// Verify snapshot was stored with correct key
|
||||
key := makeNodeSnapshotKey(tc.instance, tc.node)
|
||||
stored, ok := tc.monitor.nodeSnapshots[key]
|
||||
if !ok {
|
||||
t.Fatalf("snapshot not found with key %q", key)
|
||||
}
|
||||
|
||||
// Verify Instance and Node were set from parameters
|
||||
if stored.Instance != tc.wantInstance {
|
||||
t.Errorf("Instance = %q, want %q", stored.Instance, tc.wantInstance)
|
||||
}
|
||||
if stored.Node != tc.wantNode {
|
||||
t.Errorf("Node = %q, want %q", stored.Node, tc.wantNode)
|
||||
}
|
||||
|
||||
// Verify RetrievedAt handling
|
||||
switch tc.checkRetrievedAt {
|
||||
case "set":
|
||||
if stored.RetrievedAt.Before(beforeRecord) || stored.RetrievedAt.After(afterRecord) {
|
||||
t.Errorf("RetrievedAt = %v, want between %v and %v", stored.RetrievedAt, beforeRecord, afterRecord)
|
||||
}
|
||||
case "preserved":
|
||||
if !stored.RetrievedAt.Equal(tc.inputRetrievedAt) {
|
||||
t.Errorf("RetrievedAt = %v, want %v", stored.RetrievedAt, tc.inputRetrievedAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordNodeSnapshot_MultipleSnapshots(t *testing.T) {
|
||||
t.Run("records multiple nodes with different keys", func(t *testing.T) {
|
||||
m := &Monitor{
|
||||
nodeSnapshots: make(map[string]NodeMemorySnapshot),
|
||||
guestSnapshots: make(map[string]GuestMemorySnapshot),
|
||||
}
|
||||
|
||||
m.recordNodeSnapshot("pve1", "node1", NodeMemorySnapshot{MemorySource: "src1"})
|
||||
m.recordNodeSnapshot("pve1", "node2", NodeMemorySnapshot{MemorySource: "src2"})
|
||||
m.recordNodeSnapshot("pve2", "node1", NodeMemorySnapshot{MemorySource: "src3"})
|
||||
|
||||
if len(m.nodeSnapshots) != 3 {
|
||||
t.Fatalf("Expected 3 node snapshots, got %d", len(m.nodeSnapshots))
|
||||
}
|
||||
|
||||
// Verify each one exists with correct data
|
||||
key1 := makeNodeSnapshotKey("pve1", "node1")
|
||||
key2 := makeNodeSnapshotKey("pve1", "node2")
|
||||
key3 := makeNodeSnapshotKey("pve2", "node1")
|
||||
|
||||
if m.nodeSnapshots[key1].MemorySource != "src1" {
|
||||
t.Errorf("Snapshot 1 MemorySource = %q, want %q", m.nodeSnapshots[key1].MemorySource, "src1")
|
||||
}
|
||||
if m.nodeSnapshots[key2].MemorySource != "src2" {
|
||||
t.Errorf("Snapshot 2 MemorySource = %q, want %q", m.nodeSnapshots[key2].MemorySource, "src2")
|
||||
}
|
||||
if m.nodeSnapshots[key3].MemorySource != "src3" {
|
||||
t.Errorf("Snapshot 3 MemorySource = %q, want %q", m.nodeSnapshots[key3].MemorySource, "src3")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overwrites existing snapshot with same key", func(t *testing.T) {
|
||||
m := &Monitor{
|
||||
nodeSnapshots: make(map[string]NodeMemorySnapshot),
|
||||
guestSnapshots: make(map[string]GuestMemorySnapshot),
|
||||
}
|
||||
|
||||
m.recordNodeSnapshot("pve1", "node1", NodeMemorySnapshot{
|
||||
MemorySource: "old-source",
|
||||
})
|
||||
|
||||
m.recordNodeSnapshot("pve1", "node1", NodeMemorySnapshot{
|
||||
MemorySource: "new-source",
|
||||
})
|
||||
|
||||
if len(m.nodeSnapshots) != 1 {
|
||||
t.Fatalf("Expected 1 node snapshot after overwrite, got %d", len(m.nodeSnapshots))
|
||||
}
|
||||
|
||||
key := makeNodeSnapshotKey("pve1", "node1")
|
||||
if m.nodeSnapshots[key].MemorySource != "new-source" {
|
||||
t.Errorf("MemorySource = %q, want %q", m.nodeSnapshots[key].MemorySource, "new-source")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDiagnosticSnapshots(t *testing.T) {
|
||||
t.Run("nil Monitor returns empty set with non-nil slices", func(t *testing.T) {
|
||||
var m *Monitor
|
||||
|
||||
@@ -197,3 +197,321 @@ func TestRemoveHostAgentUnbindsToken(t *testing.T) {
|
||||
t.Fatalf("expected token binding to be cleared after host removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsEmptyHostsList(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
// No hosts in state - should complete without error or state changes
|
||||
monitor.evaluateHostAgents(time.Now())
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
if len(snapshot.Hosts) != 0 {
|
||||
t.Errorf("expected 0 hosts, got %d", len(snapshot.Hosts))
|
||||
}
|
||||
if len(snapshot.ConnectionHealth) != 0 {
|
||||
t.Errorf("expected 0 connection health entries, got %d", len(snapshot.ConnectionHealth))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsZeroIntervalUsesDefault(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
hostID := "host-zero-interval"
|
||||
// IntervalSeconds = 0, LastSeen = now, should use default interval (30s)
|
||||
// Default window = 30s * 4 = 120s, but minimum is 30s, so window = 30s
|
||||
// With LastSeen = now, the host should be healthy
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "zero-interval.local",
|
||||
Status: "unknown",
|
||||
IntervalSeconds: 0, // Zero interval - should use default
|
||||
LastSeen: time.Now(),
|
||||
})
|
||||
|
||||
monitor.evaluateHostAgents(time.Now())
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || !healthy {
|
||||
t.Fatalf("expected connection health true for zero-interval host with recent LastSeen, got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "online" {
|
||||
t.Errorf("expected host status online, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsNegativeIntervalUsesDefault(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
hostID := "host-negative-interval"
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "negative-interval.local",
|
||||
Status: "unknown",
|
||||
IntervalSeconds: -10, // Negative interval - should use default
|
||||
LastSeen: time.Now(),
|
||||
})
|
||||
|
||||
monitor.evaluateHostAgents(time.Now())
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || !healthy {
|
||||
t.Fatalf("expected connection health true for negative-interval host with recent LastSeen, got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsWindowClampedToMinimum(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
hostID := "host-min-window"
|
||||
// IntervalSeconds = 1, so window = 1s * 4 = 4s, but minimum is 30s
|
||||
// Host last seen 25s ago should still be healthy (within 30s minimum window)
|
||||
now := time.Now()
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "min-window.local",
|
||||
Status: "unknown",
|
||||
IntervalSeconds: 1, // Very small interval
|
||||
LastSeen: now.Add(-25 * time.Second),
|
||||
})
|
||||
|
||||
monitor.evaluateHostAgents(now)
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || !healthy {
|
||||
t.Fatalf("expected connection health true (window clamped to minimum 30s), got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "online" {
|
||||
t.Errorf("expected host status online, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsWindowClampedToMaximum(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
hostID := "host-max-window"
|
||||
// IntervalSeconds = 300 (5 min), so window = 300s * 4 = 1200s (20 min)
|
||||
// But maximum is 10 min = 600s
|
||||
// Host last seen 11 minutes ago should be unhealthy (outside 10 min max window)
|
||||
now := time.Now()
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "max-window.local",
|
||||
Status: "online",
|
||||
IntervalSeconds: 300, // 5 minute interval
|
||||
LastSeen: now.Add(-11 * time.Minute),
|
||||
})
|
||||
|
||||
monitor.evaluateHostAgents(now)
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || healthy {
|
||||
t.Fatalf("expected connection health false (window clamped to maximum 10m), got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "offline" {
|
||||
t.Errorf("expected host status offline, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsRecentLastSeenIsHealthy(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
hostID := "host-recent"
|
||||
now := time.Now()
|
||||
// IntervalSeconds = 30, window = 30s * 4 = 120s (clamped to min 30s is not needed)
|
||||
// LastSeen = 10s ago, should be healthy
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "recent.local",
|
||||
Status: "unknown",
|
||||
IntervalSeconds: 30,
|
||||
LastSeen: now.Add(-10 * time.Second),
|
||||
})
|
||||
|
||||
monitor.evaluateHostAgents(now)
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || !healthy {
|
||||
t.Fatalf("expected connection health true for recent LastSeen, got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "online" {
|
||||
t.Errorf("expected host status online, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsZeroLastSeenIsUnhealthy(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
hostID := "host-zero-lastseen"
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "zero-lastseen.local",
|
||||
Status: "online",
|
||||
IntervalSeconds: 30,
|
||||
LastSeen: time.Time{}, // Zero time
|
||||
})
|
||||
|
||||
monitor.evaluateHostAgents(time.Now())
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || healthy {
|
||||
t.Fatalf("expected connection health false for zero LastSeen, got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "offline" {
|
||||
t.Errorf("expected host status offline for zero LastSeen, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsOldLastSeenIsUnhealthy(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
config: &config.Config{},
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
hostID := "host-old-lastseen"
|
||||
now := time.Now()
|
||||
// IntervalSeconds = 30, window = 30s * 4 = 120s
|
||||
// LastSeen = 5 minutes ago, should be unhealthy
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "old-lastseen.local",
|
||||
Status: "online",
|
||||
IntervalSeconds: 30,
|
||||
LastSeen: now.Add(-5 * time.Minute),
|
||||
})
|
||||
|
||||
monitor.evaluateHostAgents(now)
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || healthy {
|
||||
t.Fatalf("expected connection health false for old LastSeen, got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "offline" {
|
||||
t.Errorf("expected host status offline for old LastSeen, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsNilAlertManagerOnline(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: nil, // No alert manager
|
||||
config: &config.Config{},
|
||||
}
|
||||
|
||||
hostID := "host-nil-am-online"
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "nil-am-online.local",
|
||||
Status: "unknown",
|
||||
IntervalSeconds: 30,
|
||||
LastSeen: time.Now(),
|
||||
})
|
||||
|
||||
// Should not panic with nil alertManager
|
||||
monitor.evaluateHostAgents(time.Now())
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || !healthy {
|
||||
t.Fatalf("expected connection health true, got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "online" {
|
||||
t.Errorf("expected host status online, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHostAgentsNilAlertManagerOffline(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: nil, // No alert manager
|
||||
config: &config.Config{},
|
||||
}
|
||||
|
||||
hostID := "host-nil-am-offline"
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: hostID,
|
||||
Hostname: "nil-am-offline.local",
|
||||
Status: "online",
|
||||
IntervalSeconds: 30,
|
||||
LastSeen: time.Time{}, // Zero time - unhealthy
|
||||
})
|
||||
|
||||
// Should not panic with nil alertManager
|
||||
monitor.evaluateHostAgents(time.Now())
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
connKey := hostConnectionPrefix + hostID
|
||||
if healthy, ok := snapshot.ConnectionHealth[connKey]; !ok || healthy {
|
||||
t.Fatalf("expected connection health false, got %v (exists=%v)", healthy, ok)
|
||||
}
|
||||
|
||||
for _, host := range snapshot.Hosts {
|
||||
if host.ID == hostID && host.Status != "offline" {
|
||||
t.Errorf("expected host status offline, got %q", host.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2359,6 +2359,246 @@ func TestHandleProxyFailure_PlainError_TriggersDisablePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests for handleProxyHostFailure
|
||||
// =============================================================================
|
||||
|
||||
func TestHandleProxyHostFailure_EmptyHost(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: make(map[string]*proxyHostState),
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure("", fmt.Errorf("some error"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
if len(tc.proxyHostStates) != 0 {
|
||||
t.Errorf("expected no state change for empty host, got %d entries", len(tc.proxyHostStates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_WhitespaceOnlyHost(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: make(map[string]*proxyHostState),
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure(" ", fmt.Errorf("some error"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
if len(tc.proxyHostStates) != 0 {
|
||||
t.Errorf("expected no state change for whitespace-only host, got %d entries", len(tc.proxyHostStates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_FirstFailureCreatesState(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: make(map[string]*proxyHostState),
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure("192.168.1.100", fmt.Errorf("connection refused"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
state, exists := tc.proxyHostStates["192.168.1.100"]
|
||||
if !exists {
|
||||
t.Fatal("expected host state to be created")
|
||||
}
|
||||
if state.failures != 1 {
|
||||
t.Errorf("expected failures to be 1, got %d", state.failures)
|
||||
}
|
||||
if state.lastError != "connection refused" {
|
||||
t.Errorf("expected lastError to be 'connection refused', got %q", state.lastError)
|
||||
}
|
||||
if !state.cooldownUntil.IsZero() {
|
||||
t.Errorf("expected cooldownUntil to be zero (threshold not reached), got %s", state.cooldownUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_SubsequentFailuresIncrement(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: map[string]*proxyHostState{
|
||||
"192.168.1.100": {
|
||||
failures: 1,
|
||||
lastError: "first error",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure("192.168.1.100", fmt.Errorf("second error"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
state := tc.proxyHostStates["192.168.1.100"]
|
||||
if state.failures != 2 {
|
||||
t.Errorf("expected failures to be 2, got %d", state.failures)
|
||||
}
|
||||
if state.lastError != "second error" {
|
||||
t.Errorf("expected lastError to be 'second error', got %q", state.lastError)
|
||||
}
|
||||
if !state.cooldownUntil.IsZero() {
|
||||
t.Errorf("expected cooldownUntil to remain zero (threshold not reached), got %s", state.cooldownUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_ReachesThresholdSetsCooldown(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: map[string]*proxyHostState{
|
||||
"192.168.1.100": {
|
||||
failures: proxyFailureThreshold - 1, // one failure away
|
||||
lastError: "previous error",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
tc.handleProxyHostFailure("192.168.1.100", fmt.Errorf("final error"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
state := tc.proxyHostStates["192.168.1.100"]
|
||||
// failures should be reset to 0 after reaching threshold
|
||||
if state.failures != 0 {
|
||||
t.Errorf("expected failures to be reset to 0 after reaching threshold, got %d", state.failures)
|
||||
}
|
||||
if state.lastError != "final error" {
|
||||
t.Errorf("expected lastError to be 'final error', got %q", state.lastError)
|
||||
}
|
||||
// cooldownUntil should be set in the future
|
||||
if !state.cooldownUntil.After(before) {
|
||||
t.Errorf("expected cooldownUntil to be set in the future, got %s", state.cooldownUntil)
|
||||
}
|
||||
// cooldownUntil should be approximately proxyRetryInterval from now
|
||||
expectedMin := before.Add(proxyRetryInterval - time.Second)
|
||||
if state.cooldownUntil.Before(expectedMin) {
|
||||
t.Errorf("expected cooldownUntil to be at least %s, got %s", expectedMin, state.cooldownUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_LastErrorStored(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantLastErr string
|
||||
}{
|
||||
{
|
||||
name: "simple error message",
|
||||
err: fmt.Errorf("connection refused"),
|
||||
wantLastErr: "connection refused",
|
||||
},
|
||||
{
|
||||
name: "error with whitespace is trimmed",
|
||||
err: fmt.Errorf(" timeout waiting for response "),
|
||||
wantLastErr: "timeout waiting for response",
|
||||
},
|
||||
{
|
||||
name: "empty error message",
|
||||
err: fmt.Errorf(""),
|
||||
wantLastErr: "",
|
||||
},
|
||||
{
|
||||
name: "multiline error gets first part after trim",
|
||||
err: fmt.Errorf("network error\ndetails here"),
|
||||
wantLastErr: "network error\ndetails here",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: make(map[string]*proxyHostState),
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure("192.168.1.100", tt.err)
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
state := tc.proxyHostStates["192.168.1.100"]
|
||||
tc.proxyMu.Unlock()
|
||||
|
||||
if state.lastError != tt.wantLastErr {
|
||||
t.Errorf("expected lastError to be %q, got %q", tt.wantLastErr, state.lastError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_NilStateInMap(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: map[string]*proxyHostState{
|
||||
"192.168.1.100": nil, // nil state in map
|
||||
},
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure("192.168.1.100", fmt.Errorf("some error"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
state := tc.proxyHostStates["192.168.1.100"]
|
||||
if state == nil {
|
||||
t.Fatal("expected new state to be created for nil entry")
|
||||
}
|
||||
if state.failures != 1 {
|
||||
t.Errorf("expected failures to be 1, got %d", state.failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_TrimsHostWhitespace(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: make(map[string]*proxyHostState),
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure(" 192.168.1.100 ", fmt.Errorf("some error"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
// State should be stored under trimmed key
|
||||
if _, exists := tc.proxyHostStates["192.168.1.100"]; !exists {
|
||||
t.Error("expected state to be stored under trimmed host key")
|
||||
}
|
||||
if _, exists := tc.proxyHostStates[" 192.168.1.100 "]; exists {
|
||||
t.Error("state should not be stored under untrimmed host key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProxyHostFailure_MultipleHostsIndependent(t *testing.T) {
|
||||
tc := &TemperatureCollector{
|
||||
proxyHostStates: make(map[string]*proxyHostState),
|
||||
}
|
||||
|
||||
// First host gets multiple failures
|
||||
tc.handleProxyHostFailure("192.168.1.100", fmt.Errorf("error 1"))
|
||||
tc.handleProxyHostFailure("192.168.1.100", fmt.Errorf("error 2"))
|
||||
|
||||
// Second host gets one failure
|
||||
tc.handleProxyHostFailure("192.168.1.101", fmt.Errorf("different error"))
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
state1 := tc.proxyHostStates["192.168.1.100"]
|
||||
state2 := tc.proxyHostStates["192.168.1.101"]
|
||||
|
||||
if state1.failures != 2 {
|
||||
t.Errorf("expected host 1 failures to be 2, got %d", state1.failures)
|
||||
}
|
||||
if state2.failures != 1 {
|
||||
t.Errorf("expected host 2 failures to be 1, got %d", state2.failures)
|
||||
}
|
||||
if state1.lastError != "error 2" {
|
||||
t.Errorf("expected host 1 lastError to be 'error 2', got %q", state1.lastError)
|
||||
}
|
||||
if state2.lastError != "different error" {
|
||||
t.Errorf("expected host 2 lastError to be 'different error', got %q", state2.lastError)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for test setup
|
||||
|
||||
func intPtr(i int) *int {
|
||||
|
||||
Reference in New Issue
Block a user