From 90e1b8e546859d938470090458a76fe9cf69f8dd Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 07:07:18 +0000 Subject: [PATCH] Add unit tests for RateTracker.CalculateRates (monitoring) --- internal/monitoring/ratetracker_test.go | 521 ++++++++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 internal/monitoring/ratetracker_test.go diff --git a/internal/monitoring/ratetracker_test.go b/internal/monitoring/ratetracker_test.go new file mode 100644 index 0000000..123ada5 --- /dev/null +++ b/internal/monitoring/ratetracker_test.go @@ -0,0 +1,521 @@ +package monitoring + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/types" +) + +func TestCalculateRates(t *testing.T) { + tests := []struct { + name string + guestID string + previous map[string]types.IOMetrics + lastRates map[string]RateCache + current types.IOMetrics + wantDiskReadRate float64 + wantDiskWriteRate float64 + wantNetInRate float64 + wantNetOutRate float64 + }{ + { + name: "first call for guest returns -1 for all rates", + guestID: "vm-100", + previous: map[string]types.IOMetrics{}, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Now()}, + wantDiskReadRate: -1, + wantDiskWriteRate: -1, + wantNetInRate: -1, + wantNetOutRate: -1, + }, + { + name: "stale data with cached rates returns cached rates", + guestID: "vm-101", + previous: map[string]types.IOMetrics{ + "vm-101": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Now().Add(-5 * time.Second)}, + }, + lastRates: map[string]RateCache{ + "vm-101": {DiskReadRate: 100, DiskWriteRate: 200, NetInRate: 300, NetOutRate: 400}, + }, + current: types.IOMetrics{DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Now()}, + wantDiskReadRate: 100, + wantDiskWriteRate: 200, + wantNetInRate: 300, + wantNetOutRate: 400, + }, + { + name: "stale data without cached rates returns zeros", + guestID: "vm-102", + previous: map[string]types.IOMetrics{ + "vm-102": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Now().Add(-5 * time.Second)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Now()}, + wantDiskReadRate: 0, + wantDiskWriteRate: 0, + wantNetInRate: 0, + wantNetOutRate: 0, + }, + { + name: "zero time difference with cached rates returns cached rates", + guestID: "vm-103", + previous: map[string]types.IOMetrics{ + "vm-103": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{ + "vm-103": {DiskReadRate: 50, DiskWriteRate: 100, NetInRate: 150, NetOutRate: 200}, + }, + current: types.IOMetrics{DiskRead: 2000, DiskWrite: 3000, NetworkIn: 4000, NetworkOut: 5000, Timestamp: time.Unix(1000, 0)}, + wantDiskReadRate: 50, + wantDiskWriteRate: 100, + wantNetInRate: 150, + wantNetOutRate: 200, + }, + { + name: "zero time difference without cached rates returns zeros", + guestID: "vm-104", + previous: map[string]types.IOMetrics{ + "vm-104": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 2000, DiskWrite: 3000, NetworkIn: 4000, NetworkOut: 5000, Timestamp: time.Unix(1000, 0)}, + wantDiskReadRate: 0, + wantDiskWriteRate: 0, + wantNetInRate: 0, + wantNetOutRate: 0, + }, + { + name: "negative time difference with cached rates returns cached rates", + guestID: "vm-105", + previous: map[string]types.IOMetrics{ + "vm-105": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(2000, 0)}, + }, + lastRates: map[string]RateCache{ + "vm-105": {DiskReadRate: 75, DiskWriteRate: 125, NetInRate: 175, NetOutRate: 225}, + }, + current: types.IOMetrics{DiskRead: 2000, DiskWrite: 3000, NetworkIn: 4000, NetworkOut: 5000, Timestamp: time.Unix(1000, 0)}, + wantDiskReadRate: 75, + wantDiskWriteRate: 125, + wantNetInRate: 175, + wantNetOutRate: 225, + }, + { + name: "normal rate calculation", + guestID: "vm-106", + previous: map[string]types.IOMetrics{ + "vm-106": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 6000, DiskWrite: 12000, NetworkIn: 18000, NetworkOut: 24000, Timestamp: time.Unix(1010, 0)}, + wantDiskReadRate: 500, // (6000-1000)/10 = 500 + wantDiskWriteRate: 1000, // (12000-2000)/10 = 1000 + wantNetInRate: 1500, // (18000-3000)/10 = 1500 + wantNetOutRate: 2000, // (24000-4000)/10 = 2000 + }, + { + name: "counter rollover on disk read returns zero for that metric", + guestID: "vm-107", + previous: map[string]types.IOMetrics{ + "vm-107": {DiskRead: 5000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 1000, DiskWrite: 12000, NetworkIn: 18000, NetworkOut: 24000, Timestamp: time.Unix(1010, 0)}, + wantDiskReadRate: 0, // counter decreased, return 0 + wantDiskWriteRate: 1000, // (12000-2000)/10 = 1000 + wantNetInRate: 1500, // (18000-3000)/10 = 1500 + wantNetOutRate: 2000, // (24000-4000)/10 = 2000 + }, + { + name: "counter rollover on disk write returns zero for that metric", + guestID: "vm-108", + previous: map[string]types.IOMetrics{ + "vm-108": {DiskRead: 1000, DiskWrite: 12000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 6000, DiskWrite: 2000, NetworkIn: 18000, NetworkOut: 24000, Timestamp: time.Unix(1010, 0)}, + wantDiskReadRate: 500, // (6000-1000)/10 = 500 + wantDiskWriteRate: 0, // counter decreased, return 0 + wantNetInRate: 1500, + wantNetOutRate: 2000, + }, + { + name: "counter rollover on network in returns zero for that metric", + guestID: "vm-109", + previous: map[string]types.IOMetrics{ + "vm-109": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 18000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 6000, DiskWrite: 12000, NetworkIn: 3000, NetworkOut: 24000, Timestamp: time.Unix(1010, 0)}, + wantDiskReadRate: 500, + wantDiskWriteRate: 1000, + wantNetInRate: 0, // counter decreased, return 0 + wantNetOutRate: 2000, + }, + { + name: "counter rollover on network out returns zero for that metric", + guestID: "vm-110", + previous: map[string]types.IOMetrics{ + "vm-110": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 24000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 6000, DiskWrite: 12000, NetworkIn: 18000, NetworkOut: 4000, Timestamp: time.Unix(1010, 0)}, + wantDiskReadRate: 500, + wantDiskWriteRate: 1000, + wantNetInRate: 1500, + wantNetOutRate: 0, // counter decreased, return 0 + }, + { + name: "all counters rollover returns zeros", + guestID: "vm-111", + previous: map[string]types.IOMetrics{ + "vm-111": {DiskRead: 6000, DiskWrite: 12000, NetworkIn: 18000, NetworkOut: 24000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1010, 0)}, + wantDiskReadRate: 0, + wantDiskWriteRate: 0, + wantNetInRate: 0, + wantNetOutRate: 0, + }, + { + name: "mixed scenario: disk read stale, others changed", + guestID: "vm-112", + previous: map[string]types.IOMetrics{ + "vm-112": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 1000, DiskWrite: 12000, NetworkIn: 18000, NetworkOut: 24000, Timestamp: time.Unix(1010, 0)}, + wantDiskReadRate: 0, // no change: (1000-1000)/10 = 0 + wantDiskWriteRate: 1000, + wantNetInRate: 1500, + wantNetOutRate: 2000, + }, + { + name: "zero values with time elapsed", + guestID: "vm-113", + previous: map[string]types.IOMetrics{ + "vm-113": {DiskRead: 0, DiskWrite: 0, NetworkIn: 0, NetworkOut: 0, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 5000, DiskWrite: 10000, NetworkIn: 15000, NetworkOut: 20000, Timestamp: time.Unix(1005, 0)}, + wantDiskReadRate: 1000, // 5000/5 = 1000 + wantDiskWriteRate: 2000, // 10000/5 = 2000 + wantNetInRate: 3000, // 15000/5 = 3000 + wantNetOutRate: 4000, // 20000/5 = 4000 + }, + { + name: "fractional time difference", + guestID: "vm-114", + previous: map[string]types.IOMetrics{ + "vm-114": {DiskRead: 1000, DiskWrite: 2000, NetworkIn: 3000, NetworkOut: 4000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 1500, DiskWrite: 2500, NetworkIn: 3500, NetworkOut: 4500, Timestamp: time.Unix(1000, 500000000)}, + wantDiskReadRate: 1000, // 500/0.5 = 1000 + wantDiskWriteRate: 1000, // 500/0.5 = 1000 + wantNetInRate: 1000, // 500/0.5 = 1000 + wantNetOutRate: 1000, // 500/0.5 = 1000 + }, + { + name: "large values", + guestID: "vm-115", + previous: map[string]types.IOMetrics{ + "vm-115": {DiskRead: 1000000000, DiskWrite: 2000000000, NetworkIn: 3000000000, NetworkOut: 4000000000, Timestamp: time.Unix(1000, 0)}, + }, + lastRates: map[string]RateCache{}, + current: types.IOMetrics{DiskRead: 1100000000, DiskWrite: 2200000000, NetworkIn: 3300000000, NetworkOut: 4400000000, Timestamp: time.Unix(1100, 0)}, + wantDiskReadRate: 1000000, // 100000000/100 = 1000000 + wantDiskWriteRate: 2000000, // 200000000/100 = 2000000 + wantNetInRate: 3000000, // 300000000/100 = 3000000 + wantNetOutRate: 4000000, // 400000000/100 = 4000000 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := &RateTracker{ + previous: tt.previous, + lastRates: tt.lastRates, + } + + gotDiskRead, gotDiskWrite, gotNetIn, gotNetOut := rt.CalculateRates(tt.guestID, tt.current) + + if gotDiskRead != tt.wantDiskReadRate { + t.Errorf("DiskReadRate = %v, want %v", gotDiskRead, tt.wantDiskReadRate) + } + if gotDiskWrite != tt.wantDiskWriteRate { + t.Errorf("DiskWriteRate = %v, want %v", gotDiskWrite, tt.wantDiskWriteRate) + } + if gotNetIn != tt.wantNetInRate { + t.Errorf("NetInRate = %v, want %v", gotNetIn, tt.wantNetInRate) + } + if gotNetOut != tt.wantNetOutRate { + t.Errorf("NetOutRate = %v, want %v", gotNetOut, tt.wantNetOutRate) + } + }) + } +} + +func TestCalculateRates_MultipleGuestsTrackedIndependently(t *testing.T) { + rt := NewRateTracker() + baseTime := time.Unix(1000, 0) + + // First call for guest A - should return -1 for all + metricsA1 := types.IOMetrics{ + DiskRead: 1000, + DiskWrite: 2000, + NetworkIn: 3000, + NetworkOut: 4000, + Timestamp: baseTime, + } + diskReadA1, diskWriteA1, netInA1, netOutA1 := rt.CalculateRates("vm-100", metricsA1) + if diskReadA1 != -1 || diskWriteA1 != -1 || netInA1 != -1 || netOutA1 != -1 { + t.Errorf("first call for vm-100: got (%v, %v, %v, %v), want (-1, -1, -1, -1)", + diskReadA1, diskWriteA1, netInA1, netOutA1) + } + + // First call for guest B - should also return -1 for all + metricsB1 := types.IOMetrics{ + DiskRead: 5000, + DiskWrite: 6000, + NetworkIn: 7000, + NetworkOut: 8000, + Timestamp: baseTime, + } + diskReadB1, diskWriteB1, netInB1, netOutB1 := rt.CalculateRates("vm-200", metricsB1) + if diskReadB1 != -1 || diskWriteB1 != -1 || netInB1 != -1 || netOutB1 != -1 { + t.Errorf("first call for vm-200: got (%v, %v, %v, %v), want (-1, -1, -1, -1)", + diskReadB1, diskWriteB1, netInB1, netOutB1) + } + + // Second call for guest A - should calculate rates + metricsA2 := types.IOMetrics{ + DiskRead: 11000, + DiskWrite: 22000, + NetworkIn: 33000, + NetworkOut: 44000, + Timestamp: baseTime.Add(10 * time.Second), + } + diskReadA2, diskWriteA2, netInA2, netOutA2 := rt.CalculateRates("vm-100", metricsA2) + // (11000-1000)/10 = 1000, (22000-2000)/10 = 2000, etc. + if diskReadA2 != 1000 || diskWriteA2 != 2000 || netInA2 != 3000 || netOutA2 != 4000 { + t.Errorf("second call for vm-100: got (%v, %v, %v, %v), want (1000, 2000, 3000, 4000)", + diskReadA2, diskWriteA2, netInA2, netOutA2) + } + + // Second call for guest B - should calculate different rates + metricsB2 := types.IOMetrics{ + DiskRead: 10000, + DiskWrite: 16000, + NetworkIn: 22000, + NetworkOut: 28000, + Timestamp: baseTime.Add(5 * time.Second), + } + diskReadB2, diskWriteB2, netInB2, netOutB2 := rt.CalculateRates("vm-200", metricsB2) + // (10000-5000)/5 = 1000, (16000-6000)/5 = 2000, etc. + if diskReadB2 != 1000 || diskWriteB2 != 2000 || netInB2 != 3000 || netOutB2 != 4000 { + t.Errorf("second call for vm-200: got (%v, %v, %v, %v), want (1000, 2000, 3000, 4000)", + diskReadB2, diskWriteB2, netInB2, netOutB2) + } + + // Third call for guest A - should use new previous values, not affected by guest B + metricsA3 := types.IOMetrics{ + DiskRead: 21000, + DiskWrite: 42000, + NetworkIn: 63000, + NetworkOut: 84000, + Timestamp: baseTime.Add(20 * time.Second), + } + diskReadA3, diskWriteA3, netInA3, netOutA3 := rt.CalculateRates("vm-100", metricsA3) + // (21000-11000)/10 = 1000, (42000-22000)/10 = 2000, etc. + if diskReadA3 != 1000 || diskWriteA3 != 2000 || netInA3 != 3000 || netOutA3 != 4000 { + t.Errorf("third call for vm-100: got (%v, %v, %v, %v), want (1000, 2000, 3000, 4000)", + diskReadA3, diskWriteA3, netInA3, netOutA3) + } +} + +func TestCalculateRates_CachesRates(t *testing.T) { + rt := NewRateTracker() + baseTime := time.Unix(1000, 0) + + // First call + metrics1 := types.IOMetrics{ + DiskRead: 1000, + DiskWrite: 2000, + NetworkIn: 3000, + NetworkOut: 4000, + Timestamp: baseTime, + } + rt.CalculateRates("vm-100", metrics1) + + // Second call with changed values - should cache the calculated rates + metrics2 := types.IOMetrics{ + DiskRead: 11000, + DiskWrite: 22000, + NetworkIn: 33000, + NetworkOut: 44000, + Timestamp: baseTime.Add(10 * time.Second), + } + diskRead2, diskWrite2, netIn2, netOut2 := rt.CalculateRates("vm-100", metrics2) + + // Verify rates are cached + cachedRates, exists := rt.lastRates["vm-100"] + if !exists { + t.Fatal("expected rates to be cached for vm-100") + } + if cachedRates.DiskReadRate != diskRead2 { + t.Errorf("cached DiskReadRate = %v, want %v", cachedRates.DiskReadRate, diskRead2) + } + if cachedRates.DiskWriteRate != diskWrite2 { + t.Errorf("cached DiskWriteRate = %v, want %v", cachedRates.DiskWriteRate, diskWrite2) + } + if cachedRates.NetInRate != netIn2 { + t.Errorf("cached NetInRate = %v, want %v", cachedRates.NetInRate, netIn2) + } + if cachedRates.NetOutRate != netOut2 { + t.Errorf("cached NetOutRate = %v, want %v", cachedRates.NetOutRate, netOut2) + } + + // Third call with stale data - should return cached rates + metrics3 := types.IOMetrics{ + DiskRead: 11000, // same as metrics2 + DiskWrite: 22000, + NetworkIn: 33000, + NetworkOut: 44000, + Timestamp: baseTime.Add(15 * time.Second), // different time but same values + } + diskRead3, diskWrite3, netIn3, netOut3 := rt.CalculateRates("vm-100", metrics3) + if diskRead3 != diskRead2 || diskWrite3 != diskWrite2 || netIn3 != netIn2 || netOut3 != netOut2 { + t.Errorf("stale data call: got (%v, %v, %v, %v), want cached (%v, %v, %v, %v)", + diskRead3, diskWrite3, netIn3, netOut3, diskRead2, diskWrite2, netIn2, netOut2) + } +} + +func TestCalculateRates_UpdatesPreviousMetrics(t *testing.T) { + rt := NewRateTracker() + baseTime := time.Unix(1000, 0) + + // First call + metrics1 := types.IOMetrics{ + DiskRead: 1000, + DiskWrite: 2000, + NetworkIn: 3000, + NetworkOut: 4000, + Timestamp: baseTime, + } + rt.CalculateRates("vm-100", metrics1) + + // Verify previous metrics are stored + prev, exists := rt.previous["vm-100"] + if !exists { + t.Fatal("expected previous metrics to be stored for vm-100") + } + if prev.DiskRead != metrics1.DiskRead { + t.Errorf("previous DiskRead = %v, want %v", prev.DiskRead, metrics1.DiskRead) + } + + // Second call with changed values + metrics2 := types.IOMetrics{ + DiskRead: 11000, + DiskWrite: 22000, + NetworkIn: 33000, + NetworkOut: 44000, + Timestamp: baseTime.Add(10 * time.Second), + } + rt.CalculateRates("vm-100", metrics2) + + // Verify previous metrics are updated + prev2, exists := rt.previous["vm-100"] + if !exists { + t.Fatal("expected previous metrics to still be stored for vm-100") + } + if prev2.DiskRead != metrics2.DiskRead { + t.Errorf("updated previous DiskRead = %v, want %v", prev2.DiskRead, metrics2.DiskRead) + } + if prev2.DiskWrite != metrics2.DiskWrite { + t.Errorf("updated previous DiskWrite = %v, want %v", prev2.DiskWrite, metrics2.DiskWrite) + } + if prev2.NetworkIn != metrics2.NetworkIn { + t.Errorf("updated previous NetworkIn = %v, want %v", prev2.NetworkIn, metrics2.NetworkIn) + } + if prev2.NetworkOut != metrics2.NetworkOut { + t.Errorf("updated previous NetworkOut = %v, want %v", prev2.NetworkOut, metrics2.NetworkOut) + } + if !prev2.Timestamp.Equal(metrics2.Timestamp) { + t.Errorf("updated previous Timestamp = %v, want %v", prev2.Timestamp, metrics2.Timestamp) + } +} + +func TestCalculateRates_DoesNotUpdatePreviousOnStaleData(t *testing.T) { + rt := NewRateTracker() + baseTime := time.Unix(1000, 0) + + // First call + metrics1 := types.IOMetrics{ + DiskRead: 1000, + DiskWrite: 2000, + NetworkIn: 3000, + NetworkOut: 4000, + Timestamp: baseTime, + } + rt.CalculateRates("vm-100", metrics1) + + // Second call with stale data (same values, different timestamp) + metrics2 := types.IOMetrics{ + DiskRead: 1000, // same + DiskWrite: 2000, // same + NetworkIn: 3000, // same + NetworkOut: 4000, // same + Timestamp: baseTime.Add(10 * time.Second), // different time + } + rt.CalculateRates("vm-100", metrics2) + + // Previous should still be metrics1, not metrics2 + prev, exists := rt.previous["vm-100"] + if !exists { + t.Fatal("expected previous metrics to be stored for vm-100") + } + if !prev.Timestamp.Equal(metrics1.Timestamp) { + t.Errorf("previous should not be updated on stale data: got timestamp %v, want %v", prev.Timestamp, metrics1.Timestamp) + } +} + +func TestClear(t *testing.T) { + rt := NewRateTracker() + baseTime := time.Unix(1000, 0) + + // Add some data + metrics := types.IOMetrics{ + DiskRead: 1000, + DiskWrite: 2000, + NetworkIn: 3000, + NetworkOut: 4000, + Timestamp: baseTime, + } + rt.CalculateRates("vm-100", metrics) + rt.CalculateRates("vm-200", metrics) + + // Verify data exists + if len(rt.previous) != 2 { + t.Fatalf("expected 2 entries in previous, got %d", len(rt.previous)) + } + + // Clear + rt.Clear() + + // Verify all data is cleared + if len(rt.previous) != 0 { + t.Errorf("expected previous to be empty after Clear, got %d entries", len(rt.previous)) + } + if len(rt.lastRates) != 0 { + t.Errorf("expected lastRates to be empty after Clear, got %d entries", len(rt.lastRates)) + } + + // Verify next call after clear returns -1 (like first call) + diskRead, diskWrite, netIn, netOut := rt.CalculateRates("vm-100", metrics) + if diskRead != -1 || diskWrite != -1 || netIn != -1 || netOut != -1 { + t.Errorf("after Clear, first call should return -1s: got (%v, %v, %v, %v)", diskRead, diskWrite, netIn, netOut) + } +}