From 6fdef61710ca309ddf7938bd9e784f85179819eb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 16 Oct 2025 08:17:08 +0000 Subject: [PATCH] Expand monitoring and discovery test coverage --- internal/monitoring/helpers_test.go | 280 ++++++++++++++++++++++++++++ pkg/discovery/discovery.go | 2 - pkg/discovery/discovery_test.go | 19 ++ pkg/proxmox/zfs_test.go | 70 +++++++ 4 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 internal/monitoring/helpers_test.go create mode 100644 pkg/proxmox/zfs_test.go diff --git a/internal/monitoring/helpers_test.go b/internal/monitoring/helpers_test.go new file mode 100644 index 0000000..e8aa4f0 --- /dev/null +++ b/internal/monitoring/helpers_test.go @@ -0,0 +1,280 @@ +package monitoring + +import ( + "fmt" + "math" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" +) + +func TestNormalizeEndpointHost(t *testing.T) { + t.Parallel() + + cases := []struct { + input string + want string + }{ + {"", ""}, + {" node.local ", "node.local"}, + {"https://node.local:8006/", "node.local"}, + {"node.local:8006", "node.local"}, + {"node.local/path", "node.local"}, + {"https://[2001:db8::1]:8006", "2001:db8::1"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + if got := normalizeEndpointHost(tc.input); got != tc.want { + t.Fatalf("normalizeEndpointHost(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestIsLikelyIPAddress(t *testing.T) { + t.Parallel() + + cases := []struct { + value string + want bool + }{ + {"", false}, + {"example.local", false}, + {"10.0.0.1", true}, + {"2001:db8::1", true}, + {"fe80::1%eth0", true}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.value, func(t *testing.T) { + t.Parallel() + if got := isLikelyIPAddress(tc.value); got != tc.want { + t.Fatalf("isLikelyIPAddress(%q) = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +func TestGetNodeDisplayName(t *testing.T) { + t.Parallel() + + clusterInstance := &config.PVEInstance{ + IsCluster: true, + Name: "cluster", + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: "node1", Host: "https://node1.local:8006"}, + {NodeName: "node2", Host: "", IP: "10.0.0.2"}, + }, + } + + cases := []struct { + name string + instance *config.PVEInstance + node string + want string + }{ + {"nil instance trims", nil, " nodeX ", "nodeX"}, + {"friendly standalone", &config.PVEInstance{Name: "Friendly"}, "nodeA", "Friendly"}, + {"host fallback", &config.PVEInstance{Host: "https://host.local:8006"}, "unknown-node", "host.local"}, + {"cluster host label", clusterInstance, "node1", "node1.local"}, + {"cluster ip fallback", clusterInstance, "node2", "node2"}, + {"cluster base fallback", clusterInstance, "node3", "node3"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := getNodeDisplayName(tc.instance, tc.node); got != tc.want { + t.Fatalf("getNodeDisplayName(%v, %q) = %q, want %q", tc.instance, tc.node, got, tc.want) + } + }) + } +} + +func TestMergeNVMeTempsIntoDisks(t *testing.T) { + t.Parallel() + + original := []models.PhysicalDisk{ + {Node: "nodeA", Instance: "inst", DevPath: "/dev/nvme1n1", Type: "nvme", Temperature: 55}, + {Node: "nodeA", Instance: "inst", DevPath: "/dev/nvme0n1", Type: "NVME", Temperature: 60}, + {Node: "nodeB", Instance: "inst", DevPath: "/dev/sda", Type: "sata", Temperature: 45}, + } + + nodes := []models.Node{ + { + Name: "nodeA", + Temperature: &models.Temperature{ + Available: true, + NVMe: []models.NVMeTemp{ + {Device: "nvme0n1", Temp: 30.4}, + {Device: "nvme1n1", Temp: 31.6}, + }, + }, + }, + } + + merged := mergeNVMeTempsIntoDisks(original, nodes) + + if got, want := merged[0].Temperature, 32; got != want { + t.Fatalf("disk 0 temperature = %d, want %d", got, want) + } + if got, want := merged[1].Temperature, 30; got != want { + t.Fatalf("disk 1 temperature = %d, want %d", got, want) + } + if got, want := merged[2].Temperature, 45; got != want { + t.Fatalf("non-nvme disk temperature changed: got %d want %d", got, want) + } + if got := original[0].Temperature; got != 55 { + t.Fatalf("expected original slice unchanged, got %d", got) + } +} + +func TestMergeNVMeTempsIntoDisksClearsMissingOrInvalid(t *testing.T) { + t.Parallel() + + disks := []models.PhysicalDisk{ + {Node: "nodeA", Instance: "inst", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 65}, + {Node: "nodeC", Instance: "inst", DevPath: "/dev/nvme1n1", Type: "nvme", Temperature: 70}, + } + + nodes := []models.Node{ + { + Name: "nodeA", + Temperature: &models.Temperature{ + Available: true, + NVMe: []models.NVMeTemp{ + {Device: "nvme0n1", Temp: math.NaN()}, + }, + }, + }, + } + + merged := mergeNVMeTempsIntoDisks(disks, nodes) + + if got := merged[0].Temperature; got != 0 { + t.Fatalf("expected NaN temp to reset to 0, got %d", got) + } + if got := merged[1].Temperature; got != 0 { + t.Fatalf("expected missing temps to reset to 0, got %d", got) + } +} + +func TestSafePercentage(t *testing.T) { + t.Parallel() + + cases := []struct { + used, total float64 + want float64 + }{ + {50, 100, 50}, + {0, 0, 0}, + {math.NaN(), 100, 0}, + {75, math.NaN(), 0}, + {10, 0, 0}, + {math.Inf(1), 100, 0}, + } + + for _, tc := range cases { + tc := tc + t.Run(fmt.Sprintf("%v/%v", tc.used, tc.total), func(t *testing.T) { + t.Parallel() + if got := safePercentage(tc.used, tc.total); got != tc.want { + t.Fatalf("safePercentage(%v, %v) = %v, want %v", tc.used, tc.total, got, tc.want) + } + }) + } +} + +func TestSafeFloat(t *testing.T) { + t.Parallel() + + if got := safeFloat(math.NaN()); got != 0 { + t.Fatalf("expected NaN to return 0, got %v", got) + } + if got := safeFloat(math.Inf(1)); got != 0 { + t.Fatalf("expected +Inf to return 0, got %v", got) + } + if got := safeFloat(42.5); got != 42.5 { + t.Fatalf("expected value preserved, got %v", got) + } +} + +func TestMaxInt64(t *testing.T) { + t.Parallel() + + if got := maxInt64(5, 10); got != 10 { + t.Fatalf("expected 10, got %d", got) + } + if got := maxInt64(-1, -5); got != -1 { + t.Fatalf("expected -1, got %d", got) + } +} + +func TestConvertPoolInfoToModel(t *testing.T) { + t.Parallel() + + info := proxmox.ZFSPoolInfo{ + Name: "tank", + Health: "ONLINE", + State: "ONLINE", + Status: "OK", + Scan: "none requested", + Devices: []proxmox.ZFSPoolDevice{ + { + Name: "mirror-0", + State: "ONLINE", + Leaf: 0, + Children: []proxmox.ZFSPoolDevice{ + { + Name: "nvme0n1", + State: "ONLINE", + Leaf: 1, + Read: 1, + Write: 2, + Cksum: 3, + }, + { + Name: "nvme1n1", + State: "ONLINE", + Leaf: 1, + Read: 4, + Write: 5, + Cksum: 6, + }, + }, + }, + }, + } + + model := convertPoolInfoToModel(&info) + if model == nil { + t.Fatalf("expected pool model, got nil") + } + if model.Name != "tank" { + t.Fatalf("expected pool name tank, got %s", model.Name) + } + if model.State != "ONLINE" { + t.Fatalf("expected ONLINE state, got %s", model.State) + } + if len(model.Devices) != 2 { + t.Fatalf("expected 2 leaf devices, got %d", len(model.Devices)) + } + if model.ReadErrors != 5 || model.WriteErrors != 7 || model.ChecksumErrors != 9 { + t.Fatalf("unexpected error totals: read=%d write=%d checksum=%d", model.ReadErrors, model.WriteErrors, model.ChecksumErrors) + } +} + +func TestConvertPoolInfoToModelNil(t *testing.T) { + t.Parallel() + + if model := convertPoolInfoToModel(nil); model != nil { + t.Fatalf("expected nil result for nil input") + } +} diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go index cefa1b0..335dc68 100644 --- a/pkg/discovery/discovery.go +++ b/pkg/discovery/discovery.go @@ -191,12 +191,10 @@ func (s *Scanner) scanWorker(ctx context.Context, wg *sync.WaitGroup, ipChan <-c case <-ctx.Done(): return default: - // Check port 8006 (could be PVE or PMG) if server := s.checkPort8006(ctx, ip); server != nil { resultChan <- server } - // Check PBS (port 8007) if server := s.checkServer(ctx, ip, 8007, "pbs"); server != nil { resultChan <- server } diff --git a/pkg/discovery/discovery_test.go b/pkg/discovery/discovery_test.go index 4ef175e..fa6d0d9 100644 --- a/pkg/discovery/discovery_test.go +++ b/pkg/discovery/discovery_test.go @@ -377,3 +377,22 @@ func TestDiscoverServersWithCallback(t *testing.T) { t.Fatalf("expected callbacks for both servers, got %d", callbackCount) } } + +func TestDiscoverServersCancelledContext(t *testing.T) { + t.Parallel() + + scanner := NewScanner() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := scanner.DiscoverServersWithCallback(ctx, "127.0.0.1/32", nil) + if err == nil { + t.Fatalf("expected context error, got nil") + } + if result == nil { + t.Fatalf("expected result object even on cancellation") + } + if len(result.Servers) != 0 { + t.Fatalf("expected no servers on cancelled context") + } +} diff --git a/pkg/proxmox/zfs_test.go b/pkg/proxmox/zfs_test.go new file mode 100644 index 0000000..8ad7225 --- /dev/null +++ b/pkg/proxmox/zfs_test.go @@ -0,0 +1,70 @@ +package proxmox + +import "testing" + +func TestConvertDeviceRecursiveClassifiesVdevs(t *testing.T) { + mirror := ZFSPoolDevice{ + Name: "mirror-0", + State: "degraded", + Leaf: 0, + Children: []ZFSPoolDevice{ + {Name: "sda", State: "ONLINE", Leaf: 1}, + {Name: "sdb", State: "ONLINE", Leaf: 1}, + }, + } + + devices := convertDeviceRecursive(mirror, "") + if len(devices) != 1 { + t.Fatalf("expected single vdev entry, got %d", len(devices)) + } + vdev := devices[0] + if vdev.Type != "mirror" { + t.Fatalf("expected mirror type, got %s", vdev.Type) + } + if vdev.State != "DEGRADED" { + t.Fatalf("expected DEGRADED state, got %s", vdev.State) + } +} + +func TestConvertDeviceRecursiveLeavesIncludeErrors(t *testing.T) { + dev := ZFSPoolDevice{ + Name: "nvme0n1", + State: "degRaDed", + Leaf: 1, + Read: 1, + Write: 2, + Cksum: 3, + Msg: "device error", + } + + devices := convertDeviceRecursive(dev, "") + if len(devices) != 1 { + t.Fatalf("expected single device, got %d", len(devices)) + } + leaf := devices[0] + if !leaf.IsLeaf { + t.Fatalf("expected leaf device") + } + if leaf.Type != "disk" { + t.Fatalf("expected disk type, got %s", leaf.Type) + } + if leaf.ReadErrors != 1 || leaf.WriteErrors != 2 || leaf.ChecksumErrors != 3 { + t.Fatalf("unexpected error counts: %+v", leaf) + } + if leaf.Message != "device error" { + t.Fatalf("expected message to propagate, got %q", leaf.Message) + } +} + +func TestConvertDeviceRecursiveSkipsHealthyLeaf(t *testing.T) { + dev := ZFSPoolDevice{ + Name: "nvme0n1", + State: "ONLINE", + Leaf: 1, + } + + devices := convertDeviceRecursive(dev, "") + if len(devices) != 0 { + t.Fatalf("expected healthy leaf to be omitted, got %d entries", len(devices)) + } +}