package monitoring import ( "encoding/json" "errors" "fmt" "math" "testing" "time" "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 { name string input string want string }{ // Empty and whitespace {"empty string", "", ""}, {"whitespace only", " ", ""}, {"whitespace with tabs", " \t ", ""}, // Full URLs with scheme {"https URL with port and path", "https://example.com:8006/api", "example.com"}, {"http URL with path", "http://host/path", "host"}, {"https URL with trailing slash", "https://node.local:8006/", "node.local"}, // URLs without scheme {"host with port", "example.com:8006", "example.com"}, {"host with port no scheme", "node.local:8006", "node.local"}, // Hostname only {"hostname only", "example.com", "example.com"}, {"simple hostname", "node.local", "node.local"}, // IP addresses {"IPv4 with port", "192.168.1.1:8006", "192.168.1.1"}, {"IPv4 only", "192.168.1.100", "192.168.1.100"}, {"IPv6 bracketed with port", "https://[2001:db8::1]:8006", "2001:db8::1"}, // Host with path (no scheme) {"host with path no scheme", "node.local/path", "node.local"}, {"host with deep path", "server.example.com/api/v1/resource", "server.example.com"}, // Edge cases with just scheme prefix {"just https prefix", "https://", ""}, {"just http prefix", "http://", ""}, // URL with Host but empty Hostname (port-only host) {"URL with port-only host", "http://:8080", ":8080"}, {"URL with port-only host and path", "http://:8080/path", ":8080"}, // Whitespace trimming {"whitespace around hostname", " node.local ", "node.local"}, {"whitespace around URL", " https://example.com:8006 ", "example.com"}, } for _, tc := range cases { tc := tc t.Run(tc.name, 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 returns trimmed nodeName {"nil instance trims", nil, " nodeX ", "nodeX"}, // empty nodeName returns "unknown-node" {"empty nodeName", nil, "", "unknown-node"}, // whitespace-only nodeName returns "unknown-node" {"whitespace nodeName", nil, " ", "unknown-node"}, // non-cluster: instance.Name takes priority {"friendly standalone", &config.PVEInstance{Name: "Friendly"}, "nodeA", "Friendly"}, // non-cluster: falls back to nodeName when Name empty {"non-cluster nodeName fallback", &config.PVEInstance{Name: "", Host: "pve.example.com"}, "node1", "node1"}, // non-cluster: falls back to host label when nodeName is "unknown-node" {"host fallback", &config.PVEInstance{Host: "https://host.local:8006"}, "unknown-node", "host.local"}, // non-cluster: returns unknown-node when host is IP address {"host IP fallback to unknown-node", &config.PVEInstance{Name: "", Host: "https://192.168.1.100:8006"}, "", "unknown-node"}, // cluster: lookupClusterEndpointLabel result takes priority {"cluster host label", clusterInstance, "node1", "node1.local"}, // cluster: falls back to baseName when no endpoint label {"cluster base fallback", clusterInstance, "node3", "node3"}, // cluster: falls back to nodeName (IP fallback via endpoint) {"cluster ip fallback", clusterInstance, "node2", "node2"}, // cluster: falls back to friendly name when baseName is "unknown-node" {"cluster friendly fallback", &config.PVEInstance{IsCluster: true, Name: "Cluster Name", ClusterEndpoints: []config.ClusterEndpoint{}}, "", "Cluster Name"}, // cluster: returns unknown-node when no fallbacks available {"cluster no fallbacks", &config.PVEInstance{IsCluster: true, Name: "", Host: "pve.example.com", ClusterEndpoints: []config.ClusterEndpoint{}}, "", "unknown-node"}, } 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) } }) } } // TestMergeNVMeTempsIntoDisks moved to merge_temps_test.go 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 TestMakeGuestID(t *testing.T) { t.Parallel() cases := []struct { name string instanceName string node string vmid int want string }{ // Standard cases with canonical format: instance:node:vmid {name: "basic cluster guest", instanceName: "delly", node: "delly", vmid: 100, want: "delly:delly:100"}, {name: "multi-node cluster", instanceName: "delly", node: "minipc", vmid: 201, want: "delly:minipc:201"}, {name: "standalone node", instanceName: "pve-standalone", node: "pve-standalone", vmid: 200, want: "pve-standalone:pve-standalone:200"}, // Names with special characters {name: "hyphenated instance", instanceName: "my-cluster", node: "node-1", vmid: 100, want: "my-cluster:node-1:100"}, {name: "production cluster", instanceName: "production", node: "web-server", vmid: 999, want: "production:web-server:999"}, // Edge cases {name: "different vmids same node", instanceName: "pve1", node: "node1", vmid: 300, want: "pve1:node1:300"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := makeGuestID(tc.instanceName, tc.node, tc.vmid); got != tc.want { t.Fatalf("makeGuestID(%q, %q, %d) = %q, want %q", tc.instanceName, tc.node, tc.vmid, got, tc.want) } }) } } 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") } } func TestIsGuestAgentOSInfoUnsupportedError(t *testing.T) { t.Parallel() cases := []struct { name string err error want bool }{ {name: "nil error", err: nil, want: false}, {name: "unrelated error", err: errors.New("guest agent timeout"), want: false}, { name: "missing os-release path", err: errors.New(`API error 500: {"errors":{"message":"guest agent command failed: Failed to open file '/etc/os-release': No such file or directory"}}`), want: true, }, { name: "missing usr lib os-release", err: errors.New("API error 500: guest agent command failed: Failed to open file '/usr/lib/os-release': No such file or directory"), want: true, }, { name: "unsupported command", err: errors.New("API error 500: unsupported command: guest-get-osinfo"), want: true, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := isGuestAgentOSInfoUnsupportedError(tc.err); got != tc.want { t.Fatalf("isGuestAgentOSInfoUnsupportedError(%v) = %v, want %v", tc.err, got, tc.want) } }) } } func TestSortContent(t *testing.T) { t.Parallel() cases := []struct { name string input string want string }{ {name: "empty string", input: "", want: ""}, {name: "single value", input: "images", want: "images"}, {name: "already sorted", input: "backup,images,rootdir", want: "backup,images,rootdir"}, {name: "unsorted values", input: "rootdir,images,backup", want: "backup,images,rootdir"}, {name: "reverse sorted", input: "vztmpl,rootdir,images,backup", want: "backup,images,rootdir,vztmpl"}, {name: "duplicates preserved", input: "images,backup,images", want: "backup,images,images"}, {name: "single character values", input: "c,a,b", want: "a,b,c"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := sortContent(tc.input); got != tc.want { t.Fatalf("sortContent(%q) = %q, want %q", tc.input, got, tc.want) } }) } } func TestFormatSeconds(t *testing.T) { t.Parallel() cases := []struct { name string total int want string }{ {name: "zero", total: 0, want: ""}, {name: "negative", total: -1, want: ""}, {name: "one second", total: 1, want: "00:00:01"}, {name: "one minute", total: 60, want: "00:01:00"}, {name: "one hour", total: 3600, want: "01:00:00"}, {name: "mixed time", total: 3661, want: "01:01:01"}, {name: "59 seconds", total: 59, want: "00:00:59"}, {name: "59 minutes 59 seconds", total: 3599, want: "00:59:59"}, {name: "many hours", total: 36000, want: "10:00:00"}, {name: "over 24 hours", total: 90061, want: "25:01:01"}, {name: "complex time", total: 7384, want: "02:03:04"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := formatSeconds(tc.total); got != tc.want { t.Fatalf("formatSeconds(%d) = %q, want %q", tc.total, got, tc.want) } }) } } func TestDedupeStringsPreserveOrder(t *testing.T) { t.Parallel() cases := []struct { name string input []string want []string }{ {name: "nil input", input: nil, want: nil}, {name: "empty slice", input: []string{}, want: nil}, {name: "single value", input: []string{"a"}, want: []string{"a"}}, {name: "no duplicates", input: []string{"a", "b", "c"}, want: []string{"a", "b", "c"}}, {name: "with duplicates", input: []string{"a", "b", "a", "c", "b"}, want: []string{"a", "b", "c"}}, {name: "all duplicates", input: []string{"x", "x", "x"}, want: []string{"x"}}, {name: "preserves order", input: []string{"c", "a", "b", "a"}, want: []string{"c", "a", "b"}}, {name: "empty strings filtered", input: []string{"a", "", "b", " ", "c"}, want: []string{"a", "b", "c"}}, {name: "whitespace trimmed", input: []string{" a ", "a", " b "}, want: []string{"a", "b"}}, {name: "only empty strings", input: []string{"", " ", " "}, want: nil}, {name: "mixed empty and values", input: []string{"", "a", "", "a", ""}, want: []string{"a"}}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := dedupeStringsPreserveOrder(tc.input) if !stringSlicesEqual(got, tc.want) { t.Fatalf("dedupeStringsPreserveOrder(%v) = %v, want %v", tc.input, got, tc.want) } }) } } func TestSanitizeGuestAddressStrings(t *testing.T) { t.Parallel() cases := []struct { name string input string want []string }{ {name: "empty string", input: "", want: nil}, {name: "whitespace only", input: " ", want: nil}, {name: "valid ipv4", input: "192.168.1.100", want: []string{"192.168.1.100"}}, {name: "valid ipv6", input: "2001:db8::1", want: []string{"2001:db8::1"}}, {name: "dhcp placeholder", input: "dhcp", want: nil}, {name: "DHCP uppercase", input: "DHCP", want: nil}, {name: "manual placeholder", input: "manual", want: nil}, {name: "static placeholder", input: "static", want: nil}, {name: "auto placeholder", input: "auto", want: nil}, {name: "none placeholder", input: "none", want: nil}, {name: "n/a placeholder", input: "n/a", want: nil}, {name: "unknown placeholder", input: "unknown", want: nil}, {name: "zero ipv4", input: "0.0.0.0", want: nil}, {name: "zero ipv6", input: "::", want: nil}, {name: "loopback ipv6", input: "::1", want: nil}, {name: "loopback ipv4", input: "127.0.0.1", want: nil}, {name: "loopback subnet", input: "127.0.0.2", want: nil}, {name: "link local ipv6", input: "fe80::1", want: nil}, {name: "link local with zone", input: "fe80::1%eth0", want: nil}, {name: "ip with cidr", input: "192.168.1.100/24", want: []string{"192.168.1.100"}}, {name: "ipv6 with cidr", input: "2001:db8::1/64", want: []string{"2001:db8::1"}}, {name: "comma separated", input: "192.168.1.1,192.168.1.2", want: []string{"192.168.1.1", "192.168.1.2"}}, {name: "semicolon separated", input: "192.168.1.1;192.168.1.2", want: []string{"192.168.1.1", "192.168.1.2"}}, {name: "space separated", input: "192.168.1.1 192.168.1.2", want: []string{"192.168.1.1", "192.168.1.2"}}, {name: "mixed valid and invalid", input: "192.168.1.1,dhcp,10.0.0.1", want: []string{"192.168.1.1", "10.0.0.1"}}, {name: "filters loopback from list", input: "192.168.1.1,127.0.0.1,10.0.0.1", want: []string{"192.168.1.1", "10.0.0.1"}}, {name: "ipv6 zone identifier stripped", input: "2001:db8::1%eth0", want: []string{"2001:db8::1"}}, {name: "whitespace trimmed", input: " 192.168.1.100 ", want: []string{"192.168.1.100"}}, // Edge case: ::1 with CIDR notation to hit the HasPrefix(lower, "::1") check after CIDR stripping {name: "loopback ipv6 with cidr", input: "::1/128", want: nil}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := sanitizeGuestAddressStrings(tc.input) if !stringSlicesEqual(got, tc.want) { t.Fatalf("sanitizeGuestAddressStrings(%q) = %v, want %v", tc.input, got, tc.want) } }) } } func TestCopyFloatPointer(t *testing.T) { t.Parallel() t.Run("nil input", func(t *testing.T) { t.Parallel() if got := copyFloatPointer(nil); got != nil { t.Fatalf("copyFloatPointer(nil) = %v, want nil", got) } }) t.Run("copies value", func(t *testing.T) { t.Parallel() original := 42.5 copy := copyFloatPointer(&original) if copy == nil { t.Fatal("copyFloatPointer returned nil for non-nil input") } if *copy != original { t.Fatalf("copyFloatPointer value = %v, want %v", *copy, original) } }) t.Run("independent copy", func(t *testing.T) { t.Parallel() original := 100.0 copy := copyFloatPointer(&original) original = 200.0 if *copy != 100.0 { t.Fatalf("copy was modified when original changed: got %v, want 100.0", *copy) } }) t.Run("different pointer", func(t *testing.T) { t.Parallel() original := 50.0 copy := copyFloatPointer(&original) if copy == &original { t.Fatal("copyFloatPointer returned same pointer as input") } }) } func TestClampInterval(t *testing.T) { t.Parallel() cases := []struct { name string value time.Duration min time.Duration max time.Duration want time.Duration }{ {name: "within range", value: 30 * time.Second, min: 10 * time.Second, max: 60 * time.Second, want: 30 * time.Second}, {name: "below min", value: 5 * time.Second, min: 10 * time.Second, max: 60 * time.Second, want: 10 * time.Second}, {name: "above max", value: 120 * time.Second, min: 10 * time.Second, max: 60 * time.Second, want: 60 * time.Second}, {name: "at min boundary", value: 10 * time.Second, min: 10 * time.Second, max: 60 * time.Second, want: 10 * time.Second}, {name: "at max boundary", value: 60 * time.Second, min: 10 * time.Second, max: 60 * time.Second, want: 60 * time.Second}, {name: "zero value below min", value: 0, min: 10 * time.Second, max: 60 * time.Second, want: 10 * time.Second}, {name: "negative below min", value: -5 * time.Second, min: 10 * time.Second, max: 60 * time.Second, want: 10 * time.Second}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := clampInterval(tc.value, tc.min, tc.max); got != tc.want { t.Fatalf("clampInterval(%v, %v, %v) = %v, want %v", tc.value, tc.min, tc.max, got, tc.want) } }) } } // stringSlicesEqual compares two string slices for equality func stringSlicesEqual(a, b []string) bool { if len(a) != len(b) { return false } for i, v := range a { if v != b[i] { return false } } return true } // customStringer is a test type implementing fmt.Stringer for testing the fmt.Stringer case type customStringer struct { value string } func (c customStringer) String() string { return c.value } func TestStringValue(t *testing.T) { t.Parallel() cases := []struct { name string input interface{} want string }{ // String inputs {name: "plain string", input: "hello", want: "hello"}, {name: "string with whitespace", input: " hello ", want: "hello"}, {name: "empty string", input: "", want: ""}, {name: "whitespace only string", input: " ", want: ""}, // Numeric inputs - integers {name: "int", input: 42, want: "42"}, {name: "int zero", input: 0, want: "0"}, {name: "int negative", input: -123, want: "-123"}, {name: "int32", input: int32(2147483647), want: "2147483647"}, {name: "int32 negative", input: int32(-1), want: "-1"}, {name: "int64", input: int64(9223372036854775807), want: "9223372036854775807"}, {name: "int64 negative", input: int64(-9223372036854775808), want: "-9223372036854775808"}, {name: "uint32", input: uint32(4294967295), want: "4294967295"}, {name: "uint64", input: uint64(18446744073709551615), want: "18446744073709551615"}, // Numeric inputs - floats {name: "float64 whole", input: float64(42), want: "42"}, {name: "float64 decimal", input: float64(3.14159), want: "3.14159"}, {name: "float64 negative", input: float64(-1.5), want: "-1.5"}, {name: "float64 zero", input: float64(0), want: "0"}, {name: "float32 whole", input: float32(42), want: "42"}, {name: "float32 decimal", input: float32(2.5), want: "2.5"}, // json.Number {name: "json.Number int", input: json.Number("12345"), want: "12345"}, {name: "json.Number float", input: json.Number("3.14"), want: "3.14"}, // fmt.Stringer (custom type) {name: "fmt.Stringer", input: customStringer{value: "custom"}, want: "custom"}, {name: "fmt.Stringer with whitespace", input: customStringer{value: " trimmed "}, want: "trimmed"}, {name: "fmt.Stringer empty", input: customStringer{value: ""}, want: ""}, // Unsupported types {name: "nil", input: nil, want: ""}, {name: "bool true", input: true, want: ""}, {name: "bool false", input: false, want: ""}, {name: "slice", input: []int{1, 2, 3}, want: ""}, {name: "map", input: map[string]int{"a": 1}, want: ""}, {name: "struct", input: struct{ X int }{X: 1}, want: ""}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := stringValue(tc.input); got != tc.want { t.Fatalf("stringValue(%v) = %q, want %q", tc.input, got, tc.want) } }) } } func TestAnyToInt64(t *testing.T) { t.Parallel() cases := []struct { name string input interface{} want int64 }{ // Integer types {name: "int positive", input: 42, want: 42}, {name: "int zero", input: 0, want: 0}, {name: "int negative", input: -123, want: -123}, {name: "int32 positive", input: int32(100), want: 100}, {name: "int32 max", input: int32(2147483647), want: 2147483647}, {name: "int32 min", input: int32(-2147483648), want: -2147483648}, {name: "int64 positive", input: int64(9223372036854775807), want: 9223372036854775807}, {name: "int64 negative", input: int64(-9223372036854775808), want: -9223372036854775808}, {name: "uint32", input: uint32(4294967295), want: 4294967295}, // uint64 edge cases {name: "uint64 normal", input: uint64(1000), want: 1000}, {name: "uint64 max int64", input: uint64(9223372036854775807), want: 9223372036854775807}, {name: "uint64 overflow", input: uint64(18446744073709551615), want: math.MaxInt64}, // Float types (truncated to int64) {name: "float64 whole", input: float64(42), want: 42}, {name: "float64 truncated", input: float64(3.9), want: 3}, {name: "float64 negative truncated", input: float64(-2.9), want: -2}, {name: "float64 zero", input: float64(0), want: 0}, {name: "float32 whole", input: float32(100), want: 100}, {name: "float32 truncated", input: float32(5.7), want: 5}, // String parsing {name: "string int", input: "12345", want: 12345}, {name: "string negative", input: "-999", want: -999}, {name: "string zero", input: "0", want: 0}, {name: "string empty", input: "", want: 0}, {name: "string float", input: "3.14", want: 3}, {name: "string invalid", input: "abc", want: 0}, {name: "string mixed", input: "123abc", want: 0}, // json.Number {name: "json.Number int", input: json.Number("67890"), want: 67890}, {name: "json.Number negative", input: json.Number("-500"), want: -500}, {name: "json.Number float", input: json.Number("2.718"), want: 2}, // Unsupported types {name: "nil", input: nil, want: 0}, {name: "bool true", input: true, want: 0}, {name: "bool false", input: false, want: 0}, {name: "slice", input: []int{1, 2, 3}, want: 0}, {name: "map", input: map[string]int{"a": 1}, want: 0}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := anyToInt64(tc.input); got != tc.want { t.Fatalf("anyToInt64(%v) = %d, want %d", tc.input, got, tc.want) } }) } } func TestParseInterfaceStat(t *testing.T) { t.Parallel() cases := []struct { name string stats interface{} key string want int64 }{ // Nil and invalid stats {name: "nil stats", stats: nil, key: "bytes", want: 0}, {name: "non-map stats", stats: "not a map", key: "bytes", want: 0}, {name: "int stats", stats: 123, key: "bytes", want: 0}, // Missing key {name: "missing key", stats: map[string]interface{}{"packets": 100}, key: "bytes", want: 0}, {name: "empty map", stats: map[string]interface{}{}, key: "bytes", want: 0}, // Valid keys with various types {name: "int value", stats: map[string]interface{}{"bytes": 1000}, key: "bytes", want: 1000}, {name: "int64 value", stats: map[string]interface{}{"bytes": int64(5000000000)}, key: "bytes", want: 5000000000}, {name: "float64 value", stats: map[string]interface{}{"bytes": float64(2048.5)}, key: "bytes", want: 2048}, {name: "string value", stats: map[string]interface{}{"bytes": "4096"}, key: "bytes", want: 4096}, {name: "json.Number value", stats: map[string]interface{}{"bytes": json.Number("8192")}, key: "bytes", want: 8192}, // Different keys {name: "packets key", stats: map[string]interface{}{"packets": 500, "bytes": 1000}, key: "packets", want: 500}, {name: "errors key", stats: map[string]interface{}{"errors": 3}, key: "errors", want: 3}, // Edge cases {name: "zero value", stats: map[string]interface{}{"bytes": 0}, key: "bytes", want: 0}, {name: "negative value", stats: map[string]interface{}{"bytes": -100}, key: "bytes", want: -100}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := parseInterfaceStat(tc.stats, tc.key); got != tc.want { t.Fatalf("parseInterfaceStat(%v, %q) = %d, want %d", tc.stats, tc.key, got, tc.want) } }) } } func TestExtractGuestOSInfo(t *testing.T) { t.Parallel() cases := []struct { name string data map[string]interface{} wantName string wantVersion string }{ // Nil and empty {name: "nil data", data: nil, wantName: "", wantVersion: ""}, {name: "empty map", data: map[string]interface{}{}, wantName: "", wantVersion: ""}, // Standard Linux os-release fields { name: "standard linux", data: map[string]interface{}{ "name": "Debian GNU/Linux", "version": "12 (bookworm)", "version-id": "12", }, wantName: "Debian GNU/Linux", wantVersion: "12 (bookworm)", }, { name: "ubuntu with pretty-name", data: map[string]interface{}{ "name": "Ubuntu", "pretty-name": "Ubuntu 22.04.3 LTS", "version": "22.04.3 LTS (Jammy Jellyfish)", "version-id": "22.04", }, wantName: "Ubuntu", wantVersion: "22.04.3 LTS (Jammy Jellyfish)", }, // Fallback scenarios { name: "name fallback to pretty-name", data: map[string]interface{}{ "pretty-name": "Alpine Linux v3.18", "version-id": "3.18", }, wantName: "Alpine Linux v3.18", wantVersion: "3.18", }, { name: "name fallback to id", data: map[string]interface{}{ "id": "alpine", "version-id": "3.18", }, wantName: "alpine", wantVersion: "3.18", }, { name: "version fallback to version-id", data: map[string]interface{}{ "name": "Fedora", "version-id": "38", }, wantName: "Fedora", wantVersion: "38", }, { name: "version fallback to pretty-name when different", data: map[string]interface{}{ "name": "Rocky Linux", "pretty-name": "Rocky Linux 9.2 (Blue Onyx)", }, wantName: "Rocky Linux", wantVersion: "Rocky Linux 9.2 (Blue Onyx)", }, { name: "version fallback to kernel-release", data: map[string]interface{}{ "name": "Linux", "kernel-release": "5.15.0-generic", }, wantName: "Linux", wantVersion: "5.15.0-generic", }, // Special case: version equals name { name: "version equals name cleared", data: map[string]interface{}{ "name": "CentOS", "version": "CentOS", }, wantName: "CentOS", wantVersion: "", }, // Wrapped in "result" field (QEMU guest agent format) { name: "wrapped in result", data: map[string]interface{}{ "result": map[string]interface{}{ "name": "Arch Linux", "version-id": "rolling", }, }, wantName: "Arch Linux", wantVersion: "rolling", }, { name: "result not a map", data: map[string]interface{}{ "result": "not a map", "name": "Windows", }, wantName: "Windows", wantVersion: "", }, // Windows-like data { name: "windows style", data: map[string]interface{}{ "name": "Microsoft Windows", "pretty-name": "Windows 11 Pro", "version": "22H2", }, wantName: "Microsoft Windows", wantVersion: "22H2", }, // FreeBSD { name: "freebsd", data: map[string]interface{}{ "name": "FreeBSD", "version": "13.2-RELEASE", "version-id": "13.2", }, wantName: "FreeBSD", wantVersion: "13.2-RELEASE", }, // Whitespace handling { name: "whitespace trimmed", data: map[string]interface{}{ "name": " Debian ", "version": " 12 ", }, wantName: "Debian", wantVersion: "12", }, // Non-string types converted { name: "numeric version", data: map[string]interface{}{ "name": "Custom OS", "version": float64(10), }, wantName: "Custom OS", wantVersion: "10", }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() gotName, gotVersion := extractGuestOSInfo(tc.data) if gotName != tc.wantName || gotVersion != tc.wantVersion { t.Fatalf("extractGuestOSInfo(%v) = (%q, %q), want (%q, %q)", tc.data, gotName, gotVersion, tc.wantName, tc.wantVersion) } }) } } func TestCloneStringFloatMap(t *testing.T) { t.Parallel() cases := []struct { name string input map[string]float64 want map[string]float64 }{ {name: "nil input", input: nil, want: nil}, {name: "empty map", input: map[string]float64{}, want: nil}, {name: "single entry", input: map[string]float64{"cpu": 42.5}, want: map[string]float64{"cpu": 42.5}}, {name: "multiple entries", input: map[string]float64{"temp1": 45.0, "temp2": 50.0}, want: map[string]float64{"temp1": 45.0, "temp2": 50.0}}, {name: "zero value", input: map[string]float64{"zero": 0.0}, want: map[string]float64{"zero": 0.0}}, {name: "negative value", input: map[string]float64{"neg": -10.5}, want: map[string]float64{"neg": -10.5}}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := cloneStringFloatMap(tc.input) if tc.want == nil { if got != nil { t.Fatalf("cloneStringFloatMap(%v) = %v, want nil", tc.input, got) } return } if len(got) != len(tc.want) { t.Fatalf("cloneStringFloatMap(%v) length = %d, want %d", tc.input, len(got), len(tc.want)) } for k, v := range tc.want { if got[k] != v { t.Fatalf("cloneStringFloatMap(%v)[%q] = %v, want %v", tc.input, k, got[k], v) } } // Verify it's a deep copy if tc.input != nil && len(tc.input) > 0 { for k := range tc.input { tc.input[k] = 999.0 if got[k] == 999.0 { t.Fatalf("cloneStringFloatMap() returned reference, not a copy") } break } } }) } } func TestCloneStringMap(t *testing.T) { t.Parallel() cases := []struct { name string input map[string]string want map[string]string }{ {name: "nil input", input: nil, want: nil}, {name: "empty map", input: map[string]string{}, want: nil}, {name: "single entry", input: map[string]string{"key": "value"}, want: map[string]string{"key": "value"}}, {name: "multiple entries", input: map[string]string{"a": "1", "b": "2"}, want: map[string]string{"a": "1", "b": "2"}}, {name: "empty string value", input: map[string]string{"empty": ""}, want: map[string]string{"empty": ""}}, {name: "empty string key", input: map[string]string{"": "value"}, want: map[string]string{"": "value"}}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := cloneStringMap(tc.input) if tc.want == nil { if got != nil { t.Fatalf("cloneStringMap(%v) = %v, want nil", tc.input, got) } return } if len(got) != len(tc.want) { t.Fatalf("cloneStringMap(%v) length = %d, want %d", tc.input, len(got), len(tc.want)) } for k, v := range tc.want { if got[k] != v { t.Fatalf("cloneStringMap(%v)[%q] = %q, want %q", tc.input, k, got[k], v) } } // Verify it's a deep copy if tc.input != nil && len(tc.input) > 0 { for k := range tc.input { tc.input[k] = "modified" if got[k] == "modified" { t.Fatalf("cloneStringMap() returned reference, not a copy") } break } } }) } } func TestNormalizeAgentVersion(t *testing.T) { t.Parallel() cases := []struct { input string want string }{ // Empty/whitespace {"", ""}, {" ", ""}, {" \t ", ""}, // Already has v prefix {"v1.0.0", "v1.0.0"}, {"V1.0.0", "v1.0.0"}, // Needs v prefix {"1.0.0", "v1.0.0"}, {"4.35.0", "v4.35.0"}, // Multiple v prefixes trimmed {"vv1.0.0", "v1.0.0"}, {"VVV1.0.0", "v1.0.0"}, {"vVv1.0.0", "v1.0.0"}, // Only v/V (edge case) {"v", ""}, {"V", ""}, {"vV", ""}, // With whitespace {" v1.0.0 ", "v1.0.0"}, {" 1.0.0 ", "v1.0.0"}, } for _, tc := range cases { tc := tc t.Run(tc.input, func(t *testing.T) { t.Parallel() if got := normalizeAgentVersion(tc.input); got != tc.want { t.Fatalf("normalizeAgentVersion(%q) = %q, want %q", tc.input, got, tc.want) } }) } } func TestNormalizePBSNamespacePath(t *testing.T) { t.Parallel() cases := []struct { input string want string }{ // Root path normalizes to empty {"/", ""}, // Other paths preserved {"", ""}, {"backup", "backup"}, {"/backup", "/backup"}, {"backup/subdir", "backup/subdir"}, } for _, tc := range cases { tc := tc t.Run(tc.input, func(t *testing.T) { t.Parallel() if got := normalizePBSNamespacePath(tc.input); got != tc.want { t.Fatalf("normalizePBSNamespacePath(%q) = %q, want %q", tc.input, got, tc.want) } }) } } func TestNamespacePathsForDatastore(t *testing.T) { t.Parallel() cases := []struct { name string ds models.PBSDatastore want []string }{ { name: "no namespaces returns empty string", ds: models.PBSDatastore{Name: "ds1", Namespaces: nil}, want: []string{""}, }, { name: "empty namespaces returns empty string", ds: models.PBSDatastore{Name: "ds1", Namespaces: []models.PBSNamespace{}}, want: []string{""}, }, { name: "single namespace", ds: models.PBSDatastore{ Name: "ds1", Namespaces: []models.PBSNamespace{ {Path: "backup"}, }, }, want: []string{"backup"}, }, { name: "multiple namespaces", ds: models.PBSDatastore{ Name: "ds1", Namespaces: []models.PBSNamespace{ {Path: "backup"}, {Path: "archive"}, }, }, want: []string{"backup", "archive"}, }, { name: "root namespace normalized", ds: models.PBSDatastore{ Name: "ds1", Namespaces: []models.PBSNamespace{ {Path: "/"}, }, }, want: []string{""}, }, { name: "duplicate paths deduplicated", ds: models.PBSDatastore{ Name: "ds1", Namespaces: []models.PBSNamespace{ {Path: "backup"}, {Path: "backup"}, {Path: "archive"}, }, }, want: []string{"backup", "archive"}, }, { name: "duplicate root paths deduplicated", ds: models.PBSDatastore{ Name: "ds1", Namespaces: []models.PBSNamespace{ {Path: "/"}, {Path: "/"}, }, }, want: []string{""}, }, { name: "all empty paths result in single empty", ds: models.PBSDatastore{ Name: "ds1", Namespaces: []models.PBSNamespace{ {Path: ""}, {Path: ""}, }, }, want: []string{""}, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := namespacePathsForDatastore(tc.ds) if len(got) != len(tc.want) { t.Fatalf("namespacePathsForDatastore() = %v, want %v", got, tc.want) } for i, v := range tc.want { if got[i] != v { t.Fatalf("namespacePathsForDatastore()[%d] = %q, want %q", i, got[i], v) } } }) } } func TestNormalizeDockerHostID(t *testing.T) { t.Parallel() cases := []struct { input string want string }{ {"", ""}, {"host1", "host1"}, {" host1 ", "host1"}, {" ", ""}, {"\t\n", ""}, {"docker-host-123", "docker-host-123"}, } for _, tc := range cases { tc := tc t.Run(tc.input, func(t *testing.T) { t.Parallel() if got := normalizeDockerHostID(tc.input); got != tc.want { t.Fatalf("normalizeDockerHostID(%q) = %q, want %q", tc.input, got, tc.want) } }) } }