package monitoring import ( "context" "fmt" "math" "sync" "sync/atomic" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" ) type stubProxyResponse struct { output string err error } type stubTemperatureProxy struct { mu sync.Mutex available bool responses []stubProxyResponse responseFunc func(call int) stubProxyResponse callCount int } func (s *stubTemperatureProxy) IsAvailable() bool { s.mu.Lock() defer s.mu.Unlock() return s.available } func (s *stubTemperatureProxy) GetTemperature(host string) (string, error) { s.mu.Lock() call := s.callCount s.callCount++ resp := stubProxyResponse{} switch { case call < len(s.responses): resp = s.responses[call] case s.responseFunc != nil: resp = s.responseFunc(call) case len(s.responses) > 0: resp = s.responses[len(s.responses)-1] } s.mu.Unlock() return resp.output, resp.err } func (s *stubTemperatureProxy) setAvailable(v bool) { s.mu.Lock() s.available = v s.mu.Unlock() } func TestParseSensorsJSON_NoTemperatureData(t *testing.T) { collector := &TemperatureCollector{} // Test with a chip that doesn't match any known CPU or NVMe patterns jsonStr := `{ "unknown-sensor-0": { "Adapter": "Unknown interface", "temp1": { "temp1_label": "temp1" } } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if temp.Available { t.Fatalf("expected temperature to be unavailable when no CPU or NVMe chips are detected") } if temp.HasCPU { t.Fatalf("expected HasCPU to be false when no CPU chip detected") } if temp.HasNVMe { t.Fatalf("expected HasNVMe to be false when no NVMe chip detected") } } func TestParseSensorsJSON_WithCpuAndNvmeData(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "coretemp-isa-0000": { "Package id 0": {"temp1_input": 45.5}, "Core 0": {"temp2_input": 43.0}, "Core 1": {"temp3_input": 44.2} }, "nvme-pci-0400": { "Composite": {"temp1_input": 38.75} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if !temp.Available { t.Fatalf("expected temperature to be available when readings are present") } if temp.CPUPackage != 45.5 { t.Fatalf("expected cpu package temperature 45.5, got %.2f", temp.CPUPackage) } if temp.CPUMax <= 0 { t.Fatalf("expected cpu max temperature to be greater than zero, got %.2f", temp.CPUMax) } if len(temp.Cores) != 2 { t.Fatalf("expected two core temperatures, got %d", len(temp.Cores)) } if len(temp.NVMe) != 1 { t.Fatalf("expected one NVMe temperature, got %d", len(temp.NVMe)) } if temp.NVMe[0].Temp != 38.75 { t.Fatalf("expected NVMe temperature 38.75, got %.2f", temp.NVMe[0].Temp) } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true when CPU data present") } if !temp.HasNVMe { t.Fatalf("expected HasNVMe to be true when NVMe data present") } } func TestParseSensorsJSON_WithAmdTctlOnly(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "k10temp-pci-00c3": { "Tctl": {"temp1_input": 55.4} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if !temp.Available { t.Fatalf("expected temperature to be available when Tctl reading present") } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true when AMD Tctl is present") } if temp.CPUPackage != 55.4 { t.Fatalf("expected cpu package temperature 55.4, got %.2f", temp.CPUPackage) } if temp.CPUMax != 55.4 { t.Fatalf("expected cpu max temperature to follow Tctl value, got %.2f", temp.CPUMax) } } func TestParseSensorsJSON_RPiWrapper(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{"rpitemp-virtual":{"temp1":{"temp1_input":47.5}}}` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing wrapper output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true for wrapper output") } if temp.CPUPackage != 47.5 { t.Fatalf("expected cpu package temperature 47.5, got %.2f", temp.CPUPackage) } if !temp.Available { t.Fatalf("expected temperature to be available for wrapper output") } } func TestParseSensorsJSON_SMARTWithNullTemperature(t *testing.T) { collector := &TemperatureCollector{} lastUpdated := time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) jsonStr := fmt.Sprintf(`{ "sensors": { "coretemp-isa-0000": { "Package id 0": {"temp1_input": 55.0} } }, "smart": [ { "device": "/dev/sda", "serial": "S1", "wwn": "WWN1", "model": "Model1", "type": "sat", "temperature": 34, "lastUpdated": "%s", "standbySkipped": false }, { "device": "/dev/zd0", "temperature": null, "standbySkipped": true } ] }`, lastUpdated) temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing SMART wrapper output: %v", err) } if temp == nil || !temp.Available { t.Fatalf("expected temperature data to be available when SMART data present") } if !temp.HasSMART { t.Fatalf("expected HasSMART to be true when SMART data present") } if len(temp.SMART) != 2 { t.Fatalf("expected two SMART entries, got %d", len(temp.SMART)) } if temp.SMART[0].Temperature != 34 { t.Fatalf("expected first SMART temperature 34, got %d", temp.SMART[0].Temperature) } if temp.SMART[0].LastUpdated.IsZero() { t.Fatalf("expected first SMART entry to include parsed lastUpdated timestamp") } if temp.SMART[1].Temperature != 0 { t.Fatalf("expected standby SMART entry to default to temperature 0, got %d", temp.SMART[1].Temperature) } if !temp.SMART[1].StandbySkipped { t.Fatalf("expected standbySkipped to be true for second SMART entry") } } func TestShouldDisableProxy(t *testing.T) { collector := &TemperatureCollector{} if !collector.shouldDisableProxy(fmt.Errorf("plain")) { t.Fatalf("expected plain errors to disable proxy") } transportErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport} if !collector.shouldDisableProxy(transportErr) { t.Fatalf("expected transport errors to disable proxy") } sensorErr := &tempproxy.ProxyError{Type: tempproxy.ErrorTypeSensor} if collector.shouldDisableProxy(sensorErr) { t.Fatalf("sensor errors should not disable proxy") } } // TestParseSensorsJSON_NVMeOnly tests that NVMe-only systems don't show "No CPU sensor" func TestParseSensorsJSON_NVMeOnly(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "nvme-pci-0400": { "Composite": {"temp1_input": 42.5} }, "nvme-pci-0500": { "Composite": {"temp1_input": 38.0} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } // available should be true (any temperature data exists) if !temp.Available { t.Fatalf("expected temperature to be available when NVMe readings are present") } // hasCPU should be false (no CPU temperature data) if temp.HasCPU { t.Fatalf("expected HasCPU to be false when only NVMe data present") } // hasNVMe should be true if !temp.HasNVMe { t.Fatalf("expected HasNVMe to be true when NVMe data present") } // Verify NVMe data was parsed correctly if len(temp.NVMe) != 2 { t.Fatalf("expected two NVMe temperatures, got %d", len(temp.NVMe)) } // Check that both expected temperatures are present (order may vary) foundTemps := make(map[float64]bool) for _, nvme := range temp.NVMe { foundTemps[nvme.Temp] = true } if !foundTemps[42.5] { t.Fatalf("expected to find NVMe temperature 42.5") } if !foundTemps[38.0] { t.Fatalf("expected to find NVMe temperature 38.0") } } // TestParseSensorsJSON_ZeroTemperature tests that HasCPU is true even when sensor reports 0°C func TestParseSensorsJSON_ZeroTemperature(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "coretemp-isa-0000": { "Package id 0": {"temp1_input": 0.0}, "Core 0": {"temp2_input": 0.0} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } // hasCPU should be true because coretemp chip was detected, even though values are 0 if !temp.HasCPU { t.Fatalf("expected HasCPU to be true when CPU chip is detected (even with 0°C readings)") } // available should be true because we have a CPU sensor if !temp.Available { t.Fatalf("expected temperature to be available when CPU chip is detected") } // Values should be accepted (not filtered out) if temp.CPUPackage != 0.0 { t.Fatalf("expected CPUPackage to be 0.0, got %.2f", temp.CPUPackage) } if len(temp.Cores) != 1 { t.Fatalf("expected one core temperature, got %d", len(temp.Cores)) } if temp.Cores[0].Temp != 0.0 { t.Fatalf("expected core temperature to be 0.0, got %.2f", temp.Cores[0].Temp) } } 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) } 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) } } func TestParseSensorsJSON_PiPartialSensors(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "cpu_thermal-virtual-0": { "Adapter": "Virtual device", "temp1": {"temp1_input": 51.625} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing Pi sensors output: %v", err) } if !temp.Available { t.Fatalf("expected temperature to be available when cpu_thermal sensor present") } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true when cpu_thermal sensor present") } if temp.CPUPackage != 51.625 { t.Fatalf("expected cpu package temperature 51.625, got %.3f", temp.CPUPackage) } if temp.CPUMax != 51.625 { t.Fatalf("expected cpu max temperature 51.625, got %.3f", temp.CPUMax) } if len(temp.Cores) != 0 { t.Fatalf("expected no per-core temperatures, got %d entries", len(temp.Cores)) } } func TestParseSensorsJSON_CoretempAndRPiFallback(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "coretemp-isa-0000": { "Package id 0": {"temp1_input": 65.0}, "Core 0": {"temp2_input": 63.0}, "Core 1": {"temp3_input": 62.5} }, "cpu_thermal-virtual-0": { "temp1": {"temp1_input": 50.0} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing mixed sensors output: %v", err) } if temp.CPUPackage != 65.0 { t.Fatalf("expected cpu package temperature 65.0 from coretemp, got %.2f", temp.CPUPackage) } if temp.CPUMax < 63.0 { t.Fatalf("expected cpu max to reflect hottest core (>=63.0), got %.2f", temp.CPUMax) } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true when CPU sensors present") } if !temp.Available { t.Fatalf("expected temperature to be available when CPU sensors present") } } func TestTemperatureCollector_DisablesProxyAfterFailures(t *testing.T) { stub := &stubTemperatureProxy{ responses: []stubProxyResponse{ {err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transport failure 1"}}, {err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transport failure 2"}}, {err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transport failure 3"}}, }, } stub.setAvailable(true) collector := &TemperatureCollector{ proxyClient: stub, useProxy: true, } ctx := context.Background() for i := 0; i < proxyFailureThreshold; i++ { temp, err := collector.CollectTemperature(ctx, "https://node.example", "node") if err != nil { t.Fatalf("unexpected error on proxy failure %d: %v", i+1, err) } if temp.Available { t.Fatalf("expected temperature to be unavailable after proxy failure %d", i+1) } } if collector.useProxy { t.Fatalf("expected proxy to be disabled after %d failures", proxyFailureThreshold) } if collector.proxyFailures != 0 { t.Fatalf("expected proxy failure counter to reset after disable, got %d", collector.proxyFailures) } if collector.proxyCooldownUntil.IsZero() { t.Fatalf("expected proxy cooldown to be scheduled after disable") } if time.Until(collector.proxyCooldownUntil) <= 0 { t.Fatalf("expected proxy cooldown to be in the future, got %s", collector.proxyCooldownUntil) } } func TestTemperatureCollector_ProxyReenablesAfterCooldown(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(true) collector := &TemperatureCollector{ proxyClient: stub, useProxy: false, proxyCooldownUntil: time.Now().Add(-time.Minute), } if !collector.isProxyEnabled() { t.Fatalf("expected proxy to re-enable when available after cooldown") } if !collector.useProxy { t.Fatalf("expected useProxy to be true after proxy restored") } if !collector.proxyCooldownUntil.IsZero() { t.Fatalf("expected cooldown to reset after proxy restoration, got %s", collector.proxyCooldownUntil) } if collector.proxyFailures != 0 { t.Fatalf("expected proxy failure counter to reset after restoration, got %d", collector.proxyFailures) } } func TestTemperatureCollector_ProxyCooldownExtendsWhenUnavailable(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(false) collector := &TemperatureCollector{ proxyClient: stub, useProxy: false, proxyCooldownUntil: time.Now().Add(-time.Minute), } before := time.Now() if collector.isProxyEnabled() { t.Fatalf("expected proxy to remain disabled while unavailable") } if collector.useProxy { t.Fatalf("expected useProxy to remain false while proxy unavailable") } if !collector.proxyCooldownUntil.After(before) { t.Fatalf("expected cooldown to be pushed into the future, got %s", collector.proxyCooldownUntil) } } func TestTemperatureCollector_SuccessResetsFailureCount(t *testing.T) { successJSON := `{"coretemp-isa-0000":{"Package id 0":{"temp1_input": 45.0}}}` stub := &stubTemperatureProxy{ responses: []stubProxyResponse{ {err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transient failure"}}, {output: successJSON}, }, } stub.setAvailable(true) collector := &TemperatureCollector{ proxyClient: stub, useProxy: true, } ctx := context.Background() if temp, err := collector.CollectTemperature(ctx, "https://node.example", "node"); err != nil { t.Fatalf("unexpected error during proxy failure: %v", err) } else if temp.Available { t.Fatalf("expected unavailable temperature on proxy failure") } if collector.proxyFailures != 1 { t.Fatalf("expected proxy failure counter to increment to 1, got %d", collector.proxyFailures) } temp, err := collector.CollectTemperature(ctx, "https://node.example", "node") if err != nil { t.Fatalf("unexpected error on proxy success: %v", err) } if temp == nil || !temp.Available { t.Fatalf("expected valid temperature after proxy success") } if collector.proxyFailures != 0 { t.Fatalf("expected proxy failure counter reset after success, got %d", collector.proxyFailures) } if !collector.useProxy { t.Fatalf("expected proxy to remain enabled after success") } } func TestTemperatureCollector_ConcurrentCollectTemperature(t *testing.T) { successJSON := `{"coretemp-isa-0000":{"Package id 0":{"temp1_input": 55.0}}}` var callCounter int32 stub := &stubTemperatureProxy{ responseFunc: func(int) stubProxyResponse { n := atomic.AddInt32(&callCounter, 1) if n%2 == 1 { return stubProxyResponse{ err: &tempproxy.ProxyError{Type: tempproxy.ErrorTypeTransport, Message: "transient transport error"}, } } return stubProxyResponse{output: successJSON} }, } stub.setAvailable(true) collector := &TemperatureCollector{ proxyClient: stub, useProxy: true, } const goroutines = 16 const iterations = 32 var wg sync.WaitGroup wg.Add(goroutines) ctx := context.Background() for i := 0; i < goroutines; i++ { go func() { defer wg.Done() for j := 0; j < iterations; j++ { temp, err := collector.CollectTemperature(ctx, "https://node.example", "node") if err != nil { t.Errorf("collect temperature returned error: %v", err) return } if temp == nil { t.Errorf("expected non-nil temperature result") return } } }() } wg.Wait() if !collector.useProxy { t.Fatalf("expected proxy to remain enabled during concurrent collection") } if collector.proxyFailures >= proxyFailureThreshold { t.Fatalf("expected proxy failures to stay below disable threshold, got %d", collector.proxyFailures) } } func TestDisableLegacySSHOnAuthFailure(t *testing.T) { collector := &TemperatureCollector{} if !collector.disableLegacySSHOnAuthFailure(fmt.Errorf("ssh command failed: Permission denied (publickey)."), "node-1", "host-1") { t.Fatalf("expected authentication errors to be detected") } // legacySSHDisabled check removed as we no longer globally disable SSH // Repeated auth errors should still return true if !collector.disableLegacySSHOnAuthFailure(fmt.Errorf("permission denied"), "node-1", "host-1") { t.Fatalf("expected repeated authentication errors to be detected") } // Non-authentication errors should not trigger detection if collector.disableLegacySSHOnAuthFailure(fmt.Errorf("connection timed out"), "node-1", "host-1") { t.Fatalf("expected non-authentication errors to be ignored") } } // TestParseSensorsJSON_NCT6687SuperIO tests NCT6687 SuperIO chip detection func TestParseSensorsJSON_NCT6687SuperIO(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "nct6687-isa-0a20": { "CPUTIN": {"temp1_input": 48.5}, "SYSTIN": {"temp2_input": 35.0} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing NCT6687 sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if !temp.Available { t.Fatalf("expected temperature to be available when NCT6687 CPUTIN is present") } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true when NCT6687 chip is detected") } if temp.CPUPackage != 48.5 { t.Fatalf("expected cpu package temperature 48.5 from CPUTIN, got %.2f", temp.CPUPackage) } } // TestParseSensorsJSON_AmdChipletTemps tests AMD Tccd chiplet temperature detection func TestParseSensorsJSON_AmdChipletTemps(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "k10temp-pci-00c3": { "Tccd1": {"temp3_input": 62.5}, "Tccd2": {"temp4_input": 58.0} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing AMD chiplet sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if !temp.Available { t.Fatalf("expected temperature to be available when AMD chiplet temps are present") } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true when K10temp chip is detected") } // Should use highest chiplet temp as package temp if temp.CPUPackage != 62.5 { t.Fatalf("expected cpu package temperature to be highest chiplet temp (62.5), got %.2f", temp.CPUPackage) } // CPUMax should also be 62.5 if temp.CPUMax != 62.5 { t.Fatalf("expected cpu max temperature 62.5, got %.2f", temp.CPUMax) } } // TestParseSensorsJSON_AmdTctlAndChiplets tests AMD with both Tctl and chiplet temps func TestParseSensorsJSON_AmdTctlAndChiplets(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "k10temp-pci-00c3": { "Tctl": {"temp1_input": 65.0}, "Tccd1": {"temp3_input": 62.5}, "Tccd2": {"temp4_input": 58.0} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing AMD full sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if !temp.Available { t.Fatalf("expected temperature to be available") } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true") } // Tctl should take precedence over chiplet temps for package temperature if temp.CPUPackage != 65.0 { t.Fatalf("expected cpu package temperature from Tctl (65.0), got %.2f", temp.CPUPackage) } // CPUMax should be Tctl since it's highest if temp.CPUMax != 65.0 { t.Fatalf("expected cpu max temperature 65.0, got %.2f", temp.CPUMax) } } // TestParseSensorsJSON_MultipleSuperioCPUFields tests SuperIO chips with multiple CPU temp fields func TestParseSensorsJSON_MultipleSuperioCPUFields(t *testing.T) { collector := &TemperatureCollector{} jsonStr := `{ "nct6775-isa-0290": { "CPU Temperature": {"temp1_input": 52.0}, "SYSTIN": {"temp2_input": 38.0}, "AUXTIN0": {"temp3_input": 40.0} } }` temp, err := collector.parseSensorsJSON(jsonStr) if err != nil { t.Fatalf("unexpected error parsing NCT6775 sensors output: %v", err) } if temp == nil { t.Fatalf("expected temperature struct, got nil") } if !temp.Available { t.Fatalf("expected temperature to be available") } if !temp.HasCPU { t.Fatalf("expected HasCPU to be true") } if temp.CPUPackage != 52.0 { t.Fatalf("expected cpu package temperature from 'CPU Temperature' field (52.0), got %.2f", temp.CPUPackage) } } // ============================================================================= // Unit tests for utility functions // ============================================================================= func TestExtractTempInput(t *testing.T) { tests := []struct { name string sensorMap map[string]interface{} wantTemp float64 wantNaN bool }{ { name: "float64 temp1_input", sensorMap: map[string]interface{}{ "temp1_input": 45.5, }, wantTemp: 45.5, }, { name: "float64 temp2_input", sensorMap: map[string]interface{}{ "temp2_input": 72.3, }, wantTemp: 72.3, }, { name: "int value converted to float64", sensorMap: map[string]interface{}{ "temp1_input": 55, }, wantTemp: 55.0, }, { name: "string value parseable", sensorMap: map[string]interface{}{ "temp1_input": "62.5", }, wantTemp: 62.5, }, { name: "string value non-numeric", sensorMap: map[string]interface{}{ "temp1_input": "N/A", }, wantNaN: true, }, { name: "no _input suffix", sensorMap: map[string]interface{}{ "temp1": 45.5, "temp1_max": 100.0, }, wantNaN: true, }, { name: "empty map", sensorMap: map[string]interface{}{}, wantNaN: true, }, { name: "nil map", sensorMap: nil, wantNaN: true, }, { name: "zero temperature", sensorMap: map[string]interface{}{ "temp1_input": 0.0, }, wantTemp: 0.0, }, { name: "negative temperature", sensorMap: map[string]interface{}{ "temp1_input": -10.5, }, wantTemp: -10.5, }, { name: "mixed valid and invalid fields", sensorMap: map[string]interface{}{ "temp1": 45.0, "temp1_input": 50.0, "temp1_max": 100.0, }, wantTemp: 50.0, }, { name: "boolean value (invalid type)", sensorMap: map[string]interface{}{ "temp1_input": true, }, wantNaN: true, }, { name: "nil value", sensorMap: map[string]interface{}{ "temp1_input": nil, }, wantNaN: true, }, { name: "very high temperature", sensorMap: map[string]interface{}{ "temp1_input": 125.5, }, wantTemp: 125.5, }, { name: "fractional precision", sensorMap: map[string]interface{}{ "temp1_input": 45.123456789, }, wantTemp: 45.123456789, }, { name: "temp_crit_input also matches", sensorMap: map[string]interface{}{ "temp1_crit_input": 95.0, }, wantTemp: 95.0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractTempInput(tt.sensorMap) if tt.wantNaN { if !math.IsNaN(got) { t.Errorf("extractTempInput() = %v, want NaN", got) } return } if got != tt.wantTemp { t.Errorf("extractTempInput() = %v, want %v", got, tt.wantTemp) } }) } } func TestExtractCoreNumber(t *testing.T) { tests := []struct { name string want int }{ {"Core 0", 0}, {"Core 1", 1}, {"Core 10", 10}, {"Core 99", 99}, {"Core 127", 127}, {"Core", 0}, // missing number {"Core ", 0}, // trailing space, no number {"core 5", 5}, // lowercase {"CORE 7", 7}, // uppercase {"Core 12", 12}, // extra space (Fields handles this) {"", 0}, // empty string {" ", 0}, // whitespace only {"Core abc", 0}, // non-numeric {"Package id 0", 0}, // last part is "0" {"temp1", 0}, // no spaces {"Core 1000", 1000}, // large core number {"Prefix Core 5", 5}, // core not at start {"Core 0 extra", 0}, // text after number - "extra" is last field } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractCoreNumber(tt.name) if got != tt.want { t.Errorf("extractCoreNumber(%q) = %v, want %v", tt.name, got, tt.want) } }) } } func TestExtractHostname(t *testing.T) { tests := []struct { name string hostURL string want string }{ { name: "https with port", hostURL: "https://192.168.1.100:8006", want: "192.168.1.100", }, { name: "https without port", hostURL: "https://192.168.1.100", want: "192.168.1.100", }, { name: "http with port", hostURL: "http://192.168.1.100:8006", want: "192.168.1.100", }, { name: "http without port", hostURL: "http://192.168.1.100", want: "192.168.1.100", }, { name: "hostname with port", hostURL: "https://proxmox.local:8006", want: "proxmox.local", }, { name: "hostname without port", hostURL: "https://proxmox.local", want: "proxmox.local", }, { name: "bare IP", hostURL: "192.168.1.100", want: "192.168.1.100", }, { name: "bare IP with port", hostURL: "192.168.1.100:8006", want: "192.168.1.100", }, { name: "bare hostname", hostURL: "proxmox.local", want: "proxmox.local", }, { name: "bare hostname with port", hostURL: "proxmox.local:8006", want: "proxmox.local", }, { name: "with path", hostURL: "https://192.168.1.100:8006/api2/json", want: "192.168.1.100", }, { name: "empty string", hostURL: "", want: "", }, { name: "protocol only", hostURL: "https://", want: "", }, { name: "FQDN", hostURL: "https://pve1.example.com:8006", want: "pve1.example.com", }, { name: "localhost", hostURL: "http://localhost:8006", want: "localhost", }, { name: "127.0.0.1", hostURL: "https://127.0.0.1:8006", want: "127.0.0.1", }, { name: "uppercase protocol not stripped", hostURL: "HTTPS://192.168.1.100:8006", want: "HTTPS", // TrimPrefix is case-sensitive, so "HTTPS:" becomes hostname part }, { name: "trailing slash", hostURL: "https://192.168.1.100/", want: "192.168.1.100", }, { name: "query string", hostURL: "https://192.168.1.100:8006/api?key=value", want: "192.168.1.100", }, { name: "double protocol", hostURL: "https://https://192.168.1.100", want: "https", }, { name: "port only", hostURL: ":8006", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractHostname(tt.hostURL) if got != tt.want { t.Errorf("extractHostname(%q) = %q, want %q", tt.hostURL, got, tt.want) } }) } } func TestNormalizeSMARTEntries(t *testing.T) { tests := []struct { name string raw []smartEntryRaw want []models.DiskTemp }{ { name: "nil input", raw: nil, want: nil, }, { name: "empty slice", raw: []smartEntryRaw{}, want: nil, }, { name: "single entry with all fields", raw: []smartEntryRaw{ { Device: "/dev/sda", Serial: "WD-WMC1T0123456", WWN: "5 0014ee 2b1234567", Model: "WDC WD40EFRX-68N32N0", Type: "sata", Temperature: intPtr(38), LastUpdated: "2024-01-15T10:30:00Z", StandbySkipped: false, }, }, want: []models.DiskTemp{ { Device: "/dev/sda", Serial: "WD-WMC1T0123456", WWN: "5 0014ee 2b1234567", Model: "WDC WD40EFRX-68N32N0", Type: "sata", Temperature: 38, LastUpdated: mustParseTime("2024-01-15T10:30:00Z"), StandbySkipped: false, }, }, }, { name: "entry with nil temperature", raw: []smartEntryRaw{ { Device: "/dev/sdb", Temperature: nil, }, }, want: []models.DiskTemp{ { Device: "/dev/sdb", Temperature: 0, // nil becomes 0 }, }, }, { name: "entry with standby skipped", raw: []smartEntryRaw{ { Device: "/dev/sdc", StandbySkipped: true, Temperature: nil, }, }, want: []models.DiskTemp{ { Device: "/dev/sdc", StandbySkipped: true, Temperature: 0, }, }, }, { name: "empty device skipped", raw: []smartEntryRaw{ { Device: "", Temperature: intPtr(40), }, }, want: []models.DiskTemp{}, }, { name: "whitespace-only device skipped", raw: []smartEntryRaw{ { Device: " ", Temperature: intPtr(40), }, }, want: []models.DiskTemp{}, }, { name: "invalid timestamp ignored", raw: []smartEntryRaw{ { Device: "/dev/sda", LastUpdated: "not-a-timestamp", Temperature: intPtr(42), }, }, want: []models.DiskTemp{ { Device: "/dev/sda", Temperature: 42, LastUpdated: time.Time{}, // zero time }, }, }, { name: "empty timestamp", raw: []smartEntryRaw{ { Device: "/dev/sda", LastUpdated: "", Temperature: intPtr(42), }, }, want: []models.DiskTemp{ { Device: "/dev/sda", Temperature: 42, LastUpdated: time.Time{}, }, }, }, { name: "multiple entries", raw: []smartEntryRaw{ {Device: "/dev/sda", Temperature: intPtr(38), Type: "sata"}, {Device: "/dev/sdb", Temperature: intPtr(40), Type: "sata"}, {Device: "/dev/nvme0n1", Temperature: intPtr(45), Type: "nvme"}, }, want: []models.DiskTemp{ {Device: "/dev/sda", Temperature: 38, Type: "sata"}, {Device: "/dev/sdb", Temperature: 40, Type: "sata"}, {Device: "/dev/nvme0n1", Temperature: 45, Type: "nvme"}, }, }, { name: "whitespace trimmed from fields", raw: []smartEntryRaw{ { Device: " /dev/sda ", Serial: " ABC123 ", WWN: " 1234 ", Model: " Model X ", Type: " sata ", }, }, want: []models.DiskTemp{ { Device: "/dev/sda", Serial: "ABC123", WWN: "1234", Model: "Model X", Type: "sata", }, }, }, { name: "mixed valid and empty devices", raw: []smartEntryRaw{ {Device: "/dev/sda", Temperature: intPtr(38)}, {Device: "", Temperature: intPtr(40)}, {Device: "/dev/sdc", Temperature: intPtr(42)}, }, want: []models.DiskTemp{ {Device: "/dev/sda", Temperature: 38}, {Device: "/dev/sdc", Temperature: 42}, }, }, { name: "zero temperature", raw: []smartEntryRaw{ {Device: "/dev/sda", Temperature: intPtr(0)}, }, want: []models.DiskTemp{ {Device: "/dev/sda", Temperature: 0}, }, }, { name: "negative temperature", raw: []smartEntryRaw{ {Device: "/dev/sda", Temperature: intPtr(-10)}, }, want: []models.DiskTemp{ {Device: "/dev/sda", Temperature: -10}, }, }, { name: "high temperature", raw: []smartEntryRaw{ {Device: "/dev/sda", Temperature: intPtr(85)}, }, want: []models.DiskTemp{ {Device: "/dev/sda", Temperature: 85}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := normalizeSMARTEntries(tt.raw) if tt.want == nil { if got != nil { t.Errorf("normalizeSMARTEntries() = %v, want nil", got) } return } if len(got) != len(tt.want) { t.Fatalf("normalizeSMARTEntries() returned %d entries, want %d", len(got), len(tt.want)) } for i := range got { if got[i].Device != tt.want[i].Device { t.Errorf("entry[%d].Device = %q, want %q", i, got[i].Device, tt.want[i].Device) } if got[i].Serial != tt.want[i].Serial { t.Errorf("entry[%d].Serial = %q, want %q", i, got[i].Serial, tt.want[i].Serial) } if got[i].WWN != tt.want[i].WWN { t.Errorf("entry[%d].WWN = %q, want %q", i, got[i].WWN, tt.want[i].WWN) } if got[i].Model != tt.want[i].Model { t.Errorf("entry[%d].Model = %q, want %q", i, got[i].Model, tt.want[i].Model) } if got[i].Type != tt.want[i].Type { t.Errorf("entry[%d].Type = %q, want %q", i, got[i].Type, tt.want[i].Type) } if got[i].Temperature != tt.want[i].Temperature { t.Errorf("entry[%d].Temperature = %d, want %d", i, got[i].Temperature, tt.want[i].Temperature) } if !got[i].LastUpdated.Equal(tt.want[i].LastUpdated) { t.Errorf("entry[%d].LastUpdated = %v, want %v", i, got[i].LastUpdated, tt.want[i].LastUpdated) } if got[i].StandbySkipped != tt.want[i].StandbySkipped { t.Errorf("entry[%d].StandbySkipped = %v, want %v", i, got[i].StandbySkipped, tt.want[i].StandbySkipped) } } }) } } // Helper functions for test setup func intPtr(i int) *int { return &i } func mustParseTime(s string) time.Time { t, err := time.Parse(time.RFC3339, s) if err != nil { panic(err) } return t }