package monitoring import ( "context" "fmt" "math" "strings" "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) { 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, }, } 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{} 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) // Only fail every 5th call to avoid hitting the consecutive failure threshold (3) // due to concurrent execution ordering. n%2 was causing flaky failures where // 3 failures could happen before a success reset the counter. if n%5 == 0 { 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) { tests := []struct { name string err error expected bool }{ { name: "nil error returns false", err: nil, expected: false, }, { name: "non-auth error connection refused returns false", err: fmt.Errorf("connection refused"), expected: false, }, { name: "non-auth error connection timed out returns false", err: fmt.Errorf("connection timed out"), expected: false, }, { name: "non-auth error network unreachable returns false", err: fmt.Errorf("network unreachable"), expected: false, }, { name: "permission denied returns true", err: fmt.Errorf("permission denied"), expected: true, }, { name: "authentication failed returns true", err: fmt.Errorf("authentication failed"), expected: true, }, { name: "publickey returns true", err: fmt.Errorf("publickey"), expected: true, }, { name: "case insensitive PERMISSION DENIED returns true", err: fmt.Errorf("PERMISSION DENIED"), expected: true, }, { name: "case insensitive Authentication Failed returns true", err: fmt.Errorf("Authentication Failed"), expected: true, }, { name: "case insensitive PUBLICKEY returns true", err: fmt.Errorf("PUBLICKEY"), expected: true, }, { name: "mixed case Permission Denied returns true", err: fmt.Errorf("Permission Denied"), expected: true, }, { name: "embedded permission denied in message returns true", err: fmt.Errorf("ssh command failed: Permission denied (publickey)."), expected: true, }, { name: "embedded authentication failed in message returns true", err: fmt.Errorf("ssh: authentication failed: no supported methods remain"), expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { collector := &TemperatureCollector{} result := collector.disableLegacySSHOnAuthFailure(tt.err, "test-node", "test-host") if result != tt.expected { t.Errorf("disableLegacySSHOnAuthFailure() = %v, want %v", result, tt.expected) } }) } } // 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) } } // TestParseSensorsJSON_CoreTempExceedsPackage tests that CPUMax is updated when a core temp exceeds package temp func TestParseSensorsJSON_CoreTempExceedsPackage(t *testing.T) { collector := &TemperatureCollector{} // Core 1 has a higher temp than Package id 0 jsonStr := `{ "coretemp-isa-0000": { "Package id 0": {"temp1_input": 45.0}, "Core 0": {"temp2_input": 42.0}, "Core 1": {"temp3_input": 52.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") } if !temp.Available { t.Fatalf("expected temperature to be available") } if temp.CPUPackage != 45.0 { t.Fatalf("expected cpu package temperature 45.0, got %.2f", temp.CPUPackage) } // CPUMax should be the highest core temp (52.0), not the package temp (45.0) if temp.CPUMax != 52.0 { t.Fatalf("expected cpu max temperature to be highest core temp (52.0), got %.2f", temp.CPUMax) } if len(temp.Cores) != 2 { t.Fatalf("expected two core temperatures, got %d", len(temp.Cores)) } } // ============================================================================= // 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) } } }) } } // ============================================================================= // Tests for shouldSkipProxyHost // ============================================================================= func TestShouldSkipProxyHost_EmptyHost(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: make(map[string]*proxyHostState), } if tc.shouldSkipProxyHost("") { t.Error("expected empty host to return false") } } func TestShouldSkipProxyHost_WhitespaceOnlyHost(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: make(map[string]*proxyHostState), } if tc.shouldSkipProxyHost(" ") { t.Error("expected whitespace-only host (trimmed to empty) to return false") } if tc.shouldSkipProxyHost("\t\n") { t.Error("expected tab/newline host (trimmed to empty) to return false") } } func TestShouldSkipProxyHost_NotInMap(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: make(map[string]*proxyHostState), } if tc.shouldSkipProxyHost("192.168.1.100") { t.Error("expected host not in map to return false") } } func TestShouldSkipProxyHost_NilState(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": nil, }, } if tc.shouldSkipProxyHost("192.168.1.100") { t.Error("expected host with nil state to return false") } } func TestShouldSkipProxyHost_ZeroCooldownUntil(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": { failures: 2, cooldownUntil: time.Time{}, // zero value }, }, } if tc.shouldSkipProxyHost("192.168.1.100") { t.Error("expected host with zero cooldownUntil to return false") } // Verify the host was cleaned up from the map tc.proxyMu.Lock() _, exists := tc.proxyHostStates["192.168.1.100"] tc.proxyMu.Unlock() if exists { t.Error("expected host with zero cooldownUntil to be deleted from map") } } func TestShouldSkipProxyHost_ExpiredCooldown(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": { failures: 2, cooldownUntil: time.Now().Add(-time.Minute), // expired lastError: "some error", }, }, } if tc.shouldSkipProxyHost("192.168.1.100") { t.Error("expected host with expired cooldown to return false") } // Verify the state was reset and host was deleted tc.proxyMu.Lock() _, exists := tc.proxyHostStates["192.168.1.100"] tc.proxyMu.Unlock() if exists { t.Error("expected host with expired cooldown to be deleted from map") } } func TestShouldSkipProxyHost_ActiveCooldown(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": { failures: 0, cooldownUntil: time.Now().Add(5 * time.Minute), // active lastError: "connection refused", }, }, } if !tc.shouldSkipProxyHost("192.168.1.100") { t.Error("expected host with active cooldown to return true") } // Verify the host is still in the map tc.proxyMu.Lock() state, exists := tc.proxyHostStates["192.168.1.100"] tc.proxyMu.Unlock() if !exists { t.Error("expected host with active cooldown to remain in map") } if state.lastError != "connection refused" { t.Errorf("expected lastError to be preserved, got %q", state.lastError) } } func TestShouldSkipProxyHost_ExpiredCooldownResetsState(t *testing.T) { initialState := &proxyHostState{ failures: 5, cooldownUntil: time.Now().Add(-time.Second), // just expired lastError: "previous error", } tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": initialState, }, } // This call should reset the state and delete the host result := tc.shouldSkipProxyHost("192.168.1.100") if result { t.Error("expected expired cooldown to return false") } // After the call, the host should be deleted from the map tc.proxyMu.Lock() _, exists := tc.proxyHostStates["192.168.1.100"] tc.proxyMu.Unlock() if exists { t.Error("expected host to be deleted after cooldown expired") } } func TestShouldSkipProxyHost_TrimsWhitespace(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": { cooldownUntil: time.Now().Add(5 * time.Minute), }, }, } // Host with leading/trailing whitespace should match after trimming if !tc.shouldSkipProxyHost(" 192.168.1.100 ") { t.Error("expected trimmed host to match entry in map") } } // ============================================================================= // Tests for handleProxySuccess // ============================================================================= func TestHandleProxySuccess_NilProxyClient(t *testing.T) { tc := &TemperatureCollector{ proxyClient: nil, proxyFailures: 5, // should remain unchanged } tc.handleProxySuccess() if tc.proxyFailures != 5 { t.Errorf("expected proxyFailures to remain 5 when proxyClient is nil, got %d", tc.proxyFailures) } } func TestHandleProxySuccess_ResetsFailures(t *testing.T) { stub := &stubTemperatureProxy{} tc := &TemperatureCollector{ proxyClient: stub, proxyFailures: 3, } tc.handleProxySuccess() if tc.proxyFailures != 0 { t.Errorf("expected proxyFailures to be reset to 0, got %d", tc.proxyFailures) } } func TestHandleProxySuccess_AlreadyZeroFailures(t *testing.T) { stub := &stubTemperatureProxy{} tc := &TemperatureCollector{ proxyClient: stub, proxyFailures: 0, } tc.handleProxySuccess() if tc.proxyFailures != 0 { t.Errorf("expected proxyFailures to remain 0, got %d", tc.proxyFailures) } } // ============================================================================= // Tests for handleProxyHostSuccess // ============================================================================= func TestHandleProxyHostSuccess_EmptyHost(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": {failures: 2, cooldownUntil: time.Now().Add(time.Minute)}, }, } tc.handleProxyHostSuccess("") // Map should be unchanged tc.proxyMu.Lock() if len(tc.proxyHostStates) != 1 { t.Errorf("expected map to have 1 entry, got %d", len(tc.proxyHostStates)) } tc.proxyMu.Unlock() } func TestHandleProxyHostSuccess_WhitespaceOnlyHost(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": {failures: 2, cooldownUntil: time.Now().Add(time.Minute)}, }, } tc.handleProxyHostSuccess(" ") // Map should be unchanged tc.proxyMu.Lock() if len(tc.proxyHostStates) != 1 { t.Errorf("expected map to have 1 entry, got %d", len(tc.proxyHostStates)) } tc.proxyMu.Unlock() } func TestHandleProxyHostSuccess_RemovesHostFromMap(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": {failures: 5, cooldownUntil: time.Now().Add(time.Minute)}, "192.168.1.101": {failures: 3, cooldownUntil: time.Now().Add(time.Minute)}, }, } tc.handleProxyHostSuccess("192.168.1.100") tc.proxyMu.Lock() defer tc.proxyMu.Unlock() if _, exists := tc.proxyHostStates["192.168.1.100"]; exists { t.Error("expected host 192.168.1.100 to be removed from map") } if _, exists := tc.proxyHostStates["192.168.1.101"]; !exists { t.Error("expected host 192.168.1.101 to remain in map") } } func TestHandleProxyHostSuccess_HostNotInMap(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": {failures: 2}, }, } // Should not panic when host doesn't exist tc.handleProxyHostSuccess("192.168.1.200") tc.proxyMu.Lock() if len(tc.proxyHostStates) != 1 { t.Errorf("expected map to have 1 entry, got %d", len(tc.proxyHostStates)) } tc.proxyMu.Unlock() } func TestHandleProxyHostSuccess_TrimsWhitespace(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: map[string]*proxyHostState{ "192.168.1.100": {failures: 5, cooldownUntil: time.Now().Add(time.Minute)}, }, } tc.handleProxyHostSuccess(" 192.168.1.100 ") tc.proxyMu.Lock() defer tc.proxyMu.Unlock() if _, exists := tc.proxyHostStates["192.168.1.100"]; exists { t.Error("expected host to be removed after trimming whitespace from input") } } func TestHandleProxyHostSuccess_NilMap(t *testing.T) { tc := &TemperatureCollector{ proxyHostStates: nil, } // Should not panic with nil map tc.handleProxyHostSuccess("192.168.1.100") } // ============================================================================= // Tests for parseNVMeTemps // ============================================================================= func TestParseNVMeTemps(t *testing.T) { tests := []struct { name string chipName string chipMap map[string]interface{} wantNVMe []models.NVMeTemp wantDevice string }{ { name: "empty chipMap does nothing", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{}, wantNVMe: nil, }, { name: "sensorData is not a map (skipped)", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": "not a map", "Sensor 1": 12345, }, wantNVMe: nil, }, { name: "sensor name doesn't contain Composite or Sensor 1 (skipped)", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Temperature": map[string]interface{}{ "temp1_input": 42.5, }, "Sensor 2": map[string]interface{}{ "temp1_input": 38.0, }, }, wantNVMe: nil, }, { name: "Composite with valid temp_input is added", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": 42.5, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0400", Temp: 42.5}, }, }, { name: "Sensor 1 with valid temp_input is added", chipName: "nvme-pci-0500", chipMap: map[string]interface{}{ "Sensor 1": map[string]interface{}{ "temp1_input": 38.75, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0500", Temp: 38.75}, }, }, { name: "invalid/NaN temp value is skipped", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": "not-a-number", }, }, wantNVMe: nil, }, { name: "zero temp value is skipped", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": 0.0, }, }, wantNVMe: nil, }, { name: "negative temp value is skipped", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": -5.0, }, }, wantNVMe: nil, }, { name: "device name extraction from chip name works correctly", chipName: "nvme-pci-0100", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": 35.0, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0100", Temp: 35.0}, }, }, { name: "only first valid sensor is used (Composite before Sensor 1)", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": 40.0, }, "Sensor 1": map[string]interface{}{ "temp1_input": 45.0, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0400", Temp: 40.0}, }, }, { name: "Composite substring match (e.g., 'Composite temp')", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite temp": map[string]interface{}{ "temp1_input": 41.0, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0400", Temp: 41.0}, }, }, { name: "Sensor 1 substring match (e.g., 'Sensor 1 temp')", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Sensor 1 temp": map[string]interface{}{ "temp1_input": 39.0, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0400", Temp: 39.0}, }, }, { name: "nil sensorData is skipped", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": nil, }, wantNVMe: nil, }, { name: "temp2_input also works", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp2_input": 44.0, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0400", Temp: 44.0}, }, }, { name: "skips invalid Composite and uses valid Sensor 1", chipName: "nvme-pci-0400", chipMap: map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": 0.0, // invalid (zero) }, "Sensor 1": map[string]interface{}{ "temp1_input": 42.0, }, }, wantNVMe: []models.NVMeTemp{ {Device: "nvme0400", Temp: 42.0}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} collector.parseNVMeTemps(tt.chipName, tt.chipMap, temp) if tt.wantNVMe == nil { if len(temp.NVMe) != 0 { t.Errorf("parseNVMeTemps() added %d NVMe entries, want 0", len(temp.NVMe)) } return } if len(temp.NVMe) != len(tt.wantNVMe) { t.Fatalf("parseNVMeTemps() added %d NVMe entries, want %d", len(temp.NVMe), len(tt.wantNVMe)) } for i, want := range tt.wantNVMe { got := temp.NVMe[i] if got.Device != want.Device { t.Errorf("NVMe[%d].Device = %q, want %q", i, got.Device, want.Device) } if got.Temp != want.Temp { t.Errorf("NVMe[%d].Temp = %v, want %v", i, got.Temp, want.Temp) } } }) } } func TestParseNVMeTemps_AppendsToExisting(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{ NVMe: []models.NVMeTemp{ {Device: "nvme0300", Temp: 30.0}, }, } chipMap := map[string]interface{}{ "Composite": map[string]interface{}{ "temp1_input": 42.5, }, } collector.parseNVMeTemps("nvme-pci-0400", chipMap, temp) if len(temp.NVMe) != 2 { t.Fatalf("expected 2 NVMe entries after append, got %d", len(temp.NVMe)) } if temp.NVMe[0].Device != "nvme0300" || temp.NVMe[0].Temp != 30.0 { t.Errorf("first NVMe entry was modified: got %+v", temp.NVMe[0]) } if temp.NVMe[1].Device != "nvme0400" || temp.NVMe[1].Temp != 42.5 { t.Errorf("second NVMe entry incorrect: got %+v", temp.NVMe[1]) } } // ============================================================================= // Tests for isProxyEnabled // ============================================================================= func TestIsProxyEnabled_NilProxyClient(t *testing.T) { tc := &TemperatureCollector{ proxyClient: nil, useProxy: true, // even if this is true, nil client should return false } if tc.isProxyEnabled() { t.Error("expected isProxyEnabled() to return false when proxyClient is nil") } } func TestIsProxyEnabled_UseProxyAlreadyTrue(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(false) // shouldn't matter since useProxy is already true tc := &TemperatureCollector{ proxyClient: stub, useProxy: true, } if !tc.isProxyEnabled() { t.Error("expected isProxyEnabled() to return true when useProxy is already true") } // Verify useProxy remains true (unchanged) if !tc.useProxy { t.Error("expected useProxy to remain true") } } func TestIsProxyEnabled_UseProxyFalseStillInCooldown(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(true) // shouldn't be checked since still in cooldown cooldownTime := time.Now().Add(5 * time.Minute) tc := &TemperatureCollector{ proxyClient: stub, useProxy: false, proxyCooldownUntil: cooldownTime, proxyFailures: 2, } if tc.isProxyEnabled() { t.Error("expected isProxyEnabled() to return false while in cooldown") } // Verify state is unchanged if tc.useProxy { t.Error("expected useProxy to remain false during cooldown") } if tc.proxyFailures != 2 { t.Errorf("expected proxyFailures to remain 2, got %d", tc.proxyFailures) } if !tc.proxyCooldownUntil.Equal(cooldownTime) { t.Errorf("expected proxyCooldownUntil to remain unchanged") } } func TestIsProxyEnabled_CooldownExpiredProxyAvailable(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(true) tc := &TemperatureCollector{ proxyClient: stub, useProxy: false, proxyCooldownUntil: time.Now().Add(-time.Minute), // expired proxyFailures: 2, } if !tc.isProxyEnabled() { t.Error("expected isProxyEnabled() to return true when cooldown expired and proxy available") } // Verify state was restored if !tc.useProxy { t.Error("expected useProxy to be set to true after restoration") } if tc.proxyFailures != 0 { t.Errorf("expected proxyFailures to be reset to 0, got %d", tc.proxyFailures) } if !tc.proxyCooldownUntil.IsZero() { t.Errorf("expected proxyCooldownUntil to be zero after restoration, got %s", tc.proxyCooldownUntil) } } func TestIsProxyEnabled_CooldownExpiredProxyUnavailable(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(false) expiredCooldown := time.Now().Add(-time.Minute) tc := &TemperatureCollector{ proxyClient: stub, useProxy: false, proxyCooldownUntil: expiredCooldown, proxyFailures: 2, } before := time.Now() if tc.isProxyEnabled() { t.Error("expected isProxyEnabled() to return false when proxy unavailable") } // Verify state if tc.useProxy { t.Error("expected useProxy to remain false when proxy unavailable") } // Cooldown should be extended by proxyRetryInterval (5 minutes) if !tc.proxyCooldownUntil.After(before) { t.Errorf("expected proxyCooldownUntil to be extended into the future, got %s", tc.proxyCooldownUntil) } expectedMinCooldown := before.Add(proxyRetryInterval - time.Second) if tc.proxyCooldownUntil.Before(expectedMinCooldown) { t.Errorf("expected proxyCooldownUntil to be at least %s, got %s", expectedMinCooldown, tc.proxyCooldownUntil) } } func TestIsProxyEnabled_ZeroCooldownProxyAvailable(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(true) tc := &TemperatureCollector{ proxyClient: stub, useProxy: false, proxyCooldownUntil: time.Time{}, // zero value - time.Now().After(zero) is true proxyFailures: 0, } if !tc.isProxyEnabled() { t.Error("expected isProxyEnabled() to return true with zero cooldown and available proxy") } if !tc.useProxy { t.Error("expected useProxy to be set to true") } } func TestIsProxyEnabled_ZeroCooldownProxyUnavailable(t *testing.T) { stub := &stubTemperatureProxy{} stub.setAvailable(false) tc := &TemperatureCollector{ proxyClient: stub, useProxy: false, proxyCooldownUntil: time.Time{}, // zero value } before := time.Now() if tc.isProxyEnabled() { t.Error("expected isProxyEnabled() to return false when proxy unavailable") } // Should set a new cooldown since proxy is unavailable if !tc.proxyCooldownUntil.After(before) { t.Errorf("expected proxyCooldownUntil to be set in the future, got %s", tc.proxyCooldownUntil) } } // ============================================================================= // 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) } } // ============================================================================= // 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) } } // TestParseSensorsJSON_TableDriven tests parseSensorsJSON with table-driven test cases func TestParseSensorsJSON_TableDriven(t *testing.T) { tests := []struct { name string input string wantErr bool errContains string wantAvail bool wantHasCPU bool wantHasNVMe bool wantHasGPU bool wantHasSMART bool checkFunc func(t *testing.T, temp *models.Temperature) }{ { name: "empty string returns error", input: "", wantErr: true, errContains: "empty sensors output", }, { name: "whitespace-only string returns error", input: " \t\n ", wantErr: true, errContains: "empty sensors output", }, { name: "invalid JSON returns error", input: "not valid json {", wantErr: true, errContains: "failed to parse sensors JSON", }, { name: "empty JSON object returns no error but unavailable", input: "{}", wantErr: false, wantAvail: false, wantHasCPU: false, wantHasNVMe: false, }, { name: "wrapper format with sensors and smart data", input: `{ "sensors": { "coretemp-isa-0000": { "Package id 0": {"temp1_input": 55.0}, "Core 0": {"temp2_input": 52.0} } }, "smart": [ {"device": "/dev/sda", "serial": "ABC123", "model": "TestDisk", "type": "sat", "temperature": 35} ] }`, wantErr: false, wantAvail: true, wantHasCPU: true, wantHasNVMe: false, wantHasGPU: false, wantHasSMART: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if temp.CPUPackage != 55.0 { t.Errorf("expected CPUPackage 55.0, got %.2f", temp.CPUPackage) } if len(temp.SMART) != 1 { t.Errorf("expected 1 SMART entry, got %d", len(temp.SMART)) } }, }, { name: "legacy format (direct sensors JSON)", input: `{ "coretemp-isa-0000": { "Package id 0": {"temp1_input": 48.0}, "Core 0": {"temp2_input": 46.0} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, wantHasNVMe: false, checkFunc: func(t *testing.T, temp *models.Temperature) { if temp.CPUPackage != 48.0 { t.Errorf("expected CPUPackage 48.0, got %.2f", temp.CPUPackage) } }, }, { name: "k10temp chip type", input: `{ "k10temp-pci-00c3": { "Tctl": {"temp1_input": 58.5} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if temp.CPUPackage != 58.5 { t.Errorf("expected CPUPackage 58.5 from Tctl, got %.2f", temp.CPUPackage) } }, }, { name: "acpitz chip type", input: `{ "acpitz-acpi-0": { "temp1": {"temp1_input": 42.0} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, }, { name: "nvme chip type", input: `{ "nvme-pci-0100": { "Composite": {"temp1_input": 41.85} } }`, wantErr: false, wantAvail: true, wantHasCPU: false, wantHasNVMe: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if len(temp.NVMe) != 1 { t.Fatalf("expected 1 NVMe temp, got %d", len(temp.NVMe)) } if temp.NVMe[0].Temp != 41.85 { t.Errorf("expected NVMe temp 41.85, got %.2f", temp.NVMe[0].Temp) } }, }, { name: "amdgpu chip type", input: `{ "amdgpu-pci-0300": { "edge": {"temp1_input": 65.0}, "junction": {"temp2_input": 72.0}, "mem": {"temp3_input": 68.0} } }`, wantErr: false, wantAvail: true, wantHasCPU: false, wantHasGPU: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if len(temp.GPU) == 0 { t.Fatalf("expected GPU temps, got none") } }, }, { name: "nouveau chip type (NVIDIA)", input: `{ "nouveau-pci-0100": { "GPU core": {"temp1_input": 55.0} } }`, wantErr: false, wantAvail: true, wantHasCPU: false, wantHasGPU: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if len(temp.GPU) == 0 { t.Fatalf("expected GPU temps for nouveau, got none") } }, }, { name: "chip data that is not a map should continue", input: `{ "coretemp-isa-0000": { "Package id 0": {"temp1_input": 50.0} }, "invalid-chip": "not a map" }`, wantErr: false, wantAvail: true, wantHasCPU: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if temp.CPUPackage != 50.0 { t.Errorf("expected CPUPackage 50.0 despite invalid chip, got %.2f", temp.CPUPackage) } }, }, { name: "chip data that is an array should continue", input: `{ "k10temp-pci-00c3": { "Tctl": {"temp1_input": 60.0} }, "array-chip": [1, 2, 3] }`, wantErr: false, wantAvail: true, wantHasCPU: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if temp.CPUPackage != 60.0 { t.Errorf("expected CPUPackage 60.0 despite array chip, got %.2f", temp.CPUPackage) } }, }, { name: "zenpower chip type", input: `{ "zenpower-pci-00c3": { "Tdie": {"temp1_input": 62.5} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, }, { name: "nct6795 SuperIO chip", input: `{ "nct6795-isa-0290": { "CPUTIN": {"temp1_input": 45.0} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, }, { name: "it87 chip type", input: `{ "it87-isa-0a40": { "temp1": {"temp1_input": 38.0} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, }, { name: "rp1_adc (Raspberry Pi RP1)", input: `{ "rp1_adc-isa-0000": { "temp1": {"temp1_input": 49.5} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, }, { name: "CPUMax calculated from cores when no package temp", input: `{ "coretemp-isa-0000": { "Core 0": {"temp2_input": 45.0}, "Core 1": {"temp3_input": 52.0}, "Core 2": {"temp4_input": 48.0} } }`, wantErr: false, wantAvail: true, wantHasCPU: true, checkFunc: func(t *testing.T, temp *models.Temperature) { if temp.CPUMax != 52.0 { t.Errorf("expected CPUMax 52.0 (highest core), got %.2f", temp.CPUMax) } if len(temp.Cores) != 3 { t.Errorf("expected 3 cores, got %d", len(temp.Cores)) } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tc := &TemperatureCollector{} temp, err := tc.parseSensorsJSON(tt.input) 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 != tt.wantAvail { t.Errorf("Available = %v, want %v", temp.Available, tt.wantAvail) } if temp.HasCPU != tt.wantHasCPU { t.Errorf("HasCPU = %v, want %v", temp.HasCPU, tt.wantHasCPU) } if temp.HasNVMe != tt.wantHasNVMe { t.Errorf("HasNVMe = %v, want %v", temp.HasNVMe, tt.wantHasNVMe) } if temp.HasGPU != tt.wantHasGPU { t.Errorf("HasGPU = %v, want %v", temp.HasGPU, tt.wantHasGPU) } if temp.HasSMART != tt.wantHasSMART { t.Errorf("HasSMART = %v, want %v", temp.HasSMART, tt.wantHasSMART) } if tt.checkFunc != nil { tt.checkFunc(t, temp) } }) } } // ============================================================================= // Tests for parseGPUTemps // ============================================================================= func TestParseGPUTemps_EmptyChipMap(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} collector.parseGPUTemps("amdgpu-pci-0400", map[string]interface{}{}, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries for empty chipMap, got %d", len(temp.GPU)) } } func TestParseGPUTemps_EdgeOnly(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "edge": map[string]interface{}{ "temp1_input": 55.0, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Device != "amdgpu-pci-0400" { t.Errorf("Device = %q, want %q", temp.GPU[0].Device, "amdgpu-pci-0400") } if temp.GPU[0].Edge != 55.0 { t.Errorf("Edge = %v, want 55.0", temp.GPU[0].Edge) } if temp.GPU[0].Junction != 0 { t.Errorf("Junction = %v, want 0", temp.GPU[0].Junction) } if temp.GPU[0].Mem != 0 { t.Errorf("Mem = %v, want 0", temp.GPU[0].Mem) } } func TestParseGPUTemps_JunctionOnly(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "junction": map[string]interface{}{ "temp1_input": 72.5, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Junction != 72.5 { t.Errorf("Junction = %v, want 72.5", temp.GPU[0].Junction) } if temp.GPU[0].Edge != 0 { t.Errorf("Edge = %v, want 0", temp.GPU[0].Edge) } if temp.GPU[0].Mem != 0 { t.Errorf("Mem = %v, want 0", temp.GPU[0].Mem) } } func TestParseGPUTemps_MemOnly(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "mem": map[string]interface{}{ "temp1_input": 68.0, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Mem != 68.0 { t.Errorf("Mem = %v, want 68.0", temp.GPU[0].Mem) } if temp.GPU[0].Edge != 0 { t.Errorf("Edge = %v, want 0", temp.GPU[0].Edge) } if temp.GPU[0].Junction != 0 { t.Errorf("Junction = %v, want 0", temp.GPU[0].Junction) } } func TestParseGPUTemps_AllThreeTemperatures(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "edge": map[string]interface{}{ "temp1_input": 55.0, }, "junction": map[string]interface{}{ "temp2_input": 72.5, }, "mem": map[string]interface{}{ "temp3_input": 68.0, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Edge != 55.0 { t.Errorf("Edge = %v, want 55.0", temp.GPU[0].Edge) } if temp.GPU[0].Junction != 72.5 { t.Errorf("Junction = %v, want 72.5", temp.GPU[0].Junction) } if temp.GPU[0].Mem != 68.0 { t.Errorf("Mem = %v, want 68.0", temp.GPU[0].Mem) } } func TestParseGPUTemps_SkipsInvalidSensorData(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "edge": "not a map", "junction": 12345, "mem": nil, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries when sensor data is invalid, got %d", len(temp.GPU)) } } func TestParseGPUTemps_SkipsZeroTemperatures(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "edge": map[string]interface{}{ "temp1_input": 0.0, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries when temperature is zero, got %d", len(temp.GPU)) } } func TestParseGPUTemps_SkipsNegativeTemperatures(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "edge": map[string]interface{}{ "temp1_input": -5.0, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries when temperature is negative, got %d", len(temp.GPU)) } } func TestParseGPUTemps_CaseInsensitiveSensorName(t *testing.T) { tests := []struct { name string sensorName string wantEdge float64 wantJunc float64 wantMem float64 }{ {"lowercase edge", "edge", 55.0, 0, 0}, {"uppercase EDGE", "EDGE", 55.0, 0, 0}, {"mixed case Edge", "Edge", 55.0, 0, 0}, {"edge in longer name", "GPU Edge Temp", 55.0, 0, 0}, {"lowercase junction", "junction", 0, 72.0, 0}, {"uppercase JUNCTION", "JUNCTION", 0, 72.0, 0}, {"mixed case Junction", "Junction", 0, 72.0, 0}, {"lowercase mem", "mem", 0, 0, 68.0}, {"uppercase MEM", "MEM", 0, 0, 68.0}, {"mem in longer name", "Memory Temp", 0, 0, 68.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} var tempValue float64 if tt.wantEdge > 0 { tempValue = tt.wantEdge } else if tt.wantJunc > 0 { tempValue = tt.wantJunc } else { tempValue = tt.wantMem } chipMap := map[string]interface{}{ tt.sensorName: map[string]interface{}{ "temp1_input": tempValue, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Edge != tt.wantEdge { t.Errorf("Edge = %v, want %v", temp.GPU[0].Edge, tt.wantEdge) } if temp.GPU[0].Junction != tt.wantJunc { t.Errorf("Junction = %v, want %v", temp.GPU[0].Junction, tt.wantJunc) } if temp.GPU[0].Mem != tt.wantMem { t.Errorf("Mem = %v, want %v", temp.GPU[0].Mem, tt.wantMem) } }) } } func TestParseGPUTemps_HotspotMapsToJunction(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "hotspot": map[string]interface{}{ "temp1_input": 85.0, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Junction != 85.0 { t.Errorf("Junction = %v, want 85.0 (hotspot should map to junction)", temp.GPU[0].Junction) } } func TestParseGPUTemps_AppendsToExisting(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{ GPU: []models.GPUTemp{ {Device: "amdgpu-pci-0300", Edge: 50.0}, }, } chipMap := map[string]interface{}{ "edge": map[string]interface{}{ "temp1_input": 55.0, }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 2 { t.Fatalf("expected 2 GPU entries, got %d", len(temp.GPU)) } if temp.GPU[0].Device != "amdgpu-pci-0300" || temp.GPU[0].Edge != 50.0 { t.Errorf("first GPU entry was modified: got %+v", temp.GPU[0]) } if temp.GPU[1].Device != "amdgpu-pci-0400" || temp.GPU[1].Edge != 55.0 { t.Errorf("second GPU entry incorrect: got %+v", temp.GPU[1]) } } func TestParseGPUTemps_MixedValidInvalidSensors(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "edge": map[string]interface{}{ "temp1_input": 55.0, }, "junction": "invalid", // should be skipped "mem": map[string]interface{}{ "temp1_input": 0.0, // should be skipped (zero) }, } collector.parseGPUTemps("amdgpu-pci-0400", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Edge != 55.0 { t.Errorf("Edge = %v, want 55.0", temp.GPU[0].Edge) } if temp.GPU[0].Junction != 0 { t.Errorf("Junction = %v, want 0 (invalid data)", temp.GPU[0].Junction) } if temp.GPU[0].Mem != 0 { t.Errorf("Mem = %v, want 0 (zero temp)", temp.GPU[0].Mem) } } // ============================================================================= // Tests for parseNouveauGPUTemps // ============================================================================= func TestParseNouveauGPUTemps_EmptyChipMap(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} collector.parseNouveauGPUTemps("nouveau-pci-0100", map[string]interface{}{}, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries for empty chipMap, got %d", len(temp.GPU)) } } func TestParseNouveauGPUTemps_GPUCoreSensor(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "GPU core": map[string]interface{}{ "temp1_input": 65.0, }, } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Device != "nouveau-pci-0100" { t.Errorf("Device = %q, want %q", temp.GPU[0].Device, "nouveau-pci-0100") } if temp.GPU[0].Edge != 65.0 { t.Errorf("Edge = %v, want 65.0", temp.GPU[0].Edge) } } func TestParseNouveauGPUTemps_CaseInsensitiveSensorName(t *testing.T) { tests := []struct { name string sensorName string }{ {"lowercase gpu core", "gpu core"}, {"uppercase GPU CORE", "GPU CORE"}, {"mixed case GPU Core", "GPU Core"}, {"gpu only", "GPU"}, {"core only", "core"}, {"GPU temp", "GPU temp"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ tt.sensorName: map[string]interface{}{ "temp1_input": 60.0, }, } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Edge != 60.0 { t.Errorf("Edge = %v, want 60.0", temp.GPU[0].Edge) } }) } } func TestParseNouveauGPUTemps_SkipsInvalidSensorData(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "GPU core": "not a map", } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries when sensor data is invalid, got %d", len(temp.GPU)) } } func TestParseNouveauGPUTemps_SkipsZeroTemperatures(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "GPU core": map[string]interface{}{ "temp1_input": 0.0, }, } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries when temperature is zero, got %d", len(temp.GPU)) } } func TestParseNouveauGPUTemps_SkipsNegativeTemperatures(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "GPU core": map[string]interface{}{ "temp1_input": -10.0, }, } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries when temperature is negative, got %d", len(temp.GPU)) } } func TestParseNouveauGPUTemps_AppendsToExisting(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{ GPU: []models.GPUTemp{ {Device: "amdgpu-pci-0300", Edge: 50.0}, }, } chipMap := map[string]interface{}{ "GPU core": map[string]interface{}{ "temp1_input": 65.0, }, } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 2 { t.Fatalf("expected 2 GPU entries, got %d", len(temp.GPU)) } if temp.GPU[0].Device != "amdgpu-pci-0300" || temp.GPU[0].Edge != 50.0 { t.Errorf("first GPU entry was modified: got %+v", temp.GPU[0]) } if temp.GPU[1].Device != "nouveau-pci-0100" || temp.GPU[1].Edge != 65.0 { t.Errorf("second GPU entry incorrect: got %+v", temp.GPU[1]) } } func TestParseNouveauGPUTemps_OnlyEdgeIsSet(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "GPU core": map[string]interface{}{ "temp1_input": 70.0, }, } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 1 { t.Fatalf("expected 1 GPU entry, got %d", len(temp.GPU)) } if temp.GPU[0].Edge != 70.0 { t.Errorf("Edge = %v, want 70.0", temp.GPU[0].Edge) } // Nouveau only sets Edge, Junction and Mem should remain zero if temp.GPU[0].Junction != 0 { t.Errorf("Junction = %v, want 0 (nouveau only sets edge)", temp.GPU[0].Junction) } if temp.GPU[0].Mem != 0 { t.Errorf("Mem = %v, want 0 (nouveau only sets edge)", temp.GPU[0].Mem) } } func TestParseNouveauGPUTemps_NilSensorData(t *testing.T) { collector := &TemperatureCollector{} temp := &models.Temperature{} chipMap := map[string]interface{}{ "GPU core": nil, } collector.parseNouveauGPUTemps("nouveau-pci-0100", chipMap, temp) if len(temp.GPU) != 0 { t.Errorf("expected no GPU entries when sensor data is nil, got %d", len(temp.GPU)) } } // 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 }