package proxmox import ( "context" "encoding/json" "fmt" "math" "net/http" "net/http/httptest" "reflect" "strings" "testing" "time" ) func TestDiskUnmarshalWearout(t *testing.T) { tests := []struct { name string wearout json.RawMessage expected int }{ { name: "numeric", wearout: json.RawMessage(`81`), expected: 81, }, { name: "numeric string", wearout: json.RawMessage(`"81"`), expected: 81, }, { name: "escaped numeric string", wearout: json.RawMessage(`"\"81\""`), expected: 81, }, { name: "percentage string", wearout: json.RawMessage(`"81%"`), expected: 81, }, { name: "percentage with spaces", wearout: json.RawMessage(`" 82 % "`), expected: 82, }, { name: "not applicable string", wearout: json.RawMessage(`"N/A"`), expected: wearoutUnknown, }, { name: "empty string", wearout: json.RawMessage(`""`), expected: wearoutUnknown, }, { name: "null value", wearout: json.RawMessage(`null`), expected: wearoutUnknown, }, { name: "unknown string", wearout: json.RawMessage(`"Unknown"`), expected: wearoutUnknown, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { payload := fmt.Sprintf(`{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":%s,"size":1000,"rpm":7200,"used":"LVM","vendor":"Example","wwn":"example"}`, tc.wearout) var disk Disk if err := json.Unmarshal([]byte(payload), &disk); err != nil { t.Fatalf("unexpected error unmarshalling disk: %v", err) } if disk.Wearout != tc.expected { t.Fatalf("wearout: got %d, want %d", disk.Wearout, tc.expected) } }) } } func TestDiskUnmarshalRPM(t *testing.T) { tests := []struct { name string rpm json.RawMessage expected int }{ { name: "numeric", rpm: json.RawMessage(`7200`), expected: 7200, }, { name: "numeric string", rpm: json.RawMessage(`"5400"`), expected: 5400, }, { name: "ssd string", rpm: json.RawMessage(`"SSD"`), expected: 0, }, { name: "ssd lowercase", rpm: json.RawMessage(`"ssd"`), expected: 0, }, { name: "na string", rpm: json.RawMessage(`"N/A"`), expected: 0, }, { name: "empty string", rpm: json.RawMessage(`""`), expected: 0, }, { name: "null value", rpm: json.RawMessage(`null`), expected: 0, }, { name: "invalid string", rpm: json.RawMessage(`"unknown-value"`), expected: 0, }, { name: "string with spaces", rpm: json.RawMessage(`" 7200 "`), expected: 7200, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { payload := fmt.Sprintf(`{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":50,"size":1000,"rpm":%s,"used":"LVM","vendor":"Example","wwn":"example"}`, tc.rpm) var disk Disk if err := json.Unmarshal([]byte(payload), &disk); err != nil { t.Fatalf("unexpected error unmarshalling disk: %v", err) } if disk.RPM != tc.expected { t.Fatalf("rpm: got %d, want %d", disk.RPM, tc.expected) } }) } } func TestParseTagColorMap(t *testing.T) { tests := []struct { name string tagStyle string expected map[string]string }{ { name: "parses documented proxmox background and text color format", tagStyle: "color-map=Production:000000:FFFFFF;staging:ffaa00:101010,ordering=config", expected: map[string]string{ "production": "#000000", "staging": "#ffaa00", }, }, { name: "parses legacy single-color entries with leading hash", tagStyle: "ordering=config,color-map=backup:#ABCDEF;ops:123456", expected: map[string]string{ "backup": "#abcdef", "ops": "#123456", }, }, { name: "ignores invalid color tokens", tagStyle: "color-map=good:00ff00;bad:zzzzzz;also-bad:12345", expected: map[string]string{ "good": "#00ff00", }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := ParseTagColorMap(tc.tagStyle); !reflect.DeepEqual(got, tc.expected) { t.Fatalf("ParseTagColorMap() = %#v, want %#v", got, tc.expected) } }) } } func TestDiskUnmarshalJSON_InvalidJSON(t *testing.T) { var disk Disk err := json.Unmarshal([]byte(`{invalid json`), &disk) if err == nil { t.Error("expected error for invalid JSON") } } func TestDiskUnmarshalJSON_UnexpectedWearoutType(t *testing.T) { // Test with boolean wearout value (unexpected type) payload := `{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":true,"size":1000,"rpm":7200}` var disk Disk if err := json.Unmarshal([]byte(payload), &disk); err != nil { t.Fatalf("unexpected error unmarshalling disk: %v", err) } // Unexpected type should normalize to unknown if disk.Wearout != wearoutUnknown { t.Fatalf("wearout: got %d, want %d (unknown)", disk.Wearout, wearoutUnknown) } } func TestDiskUnmarshalJSON_UnexpectedRPMType(t *testing.T) { // Test with boolean rpm value (unexpected type) payload := `{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":50,"size":1000,"rpm":true}` var disk Disk if err := json.Unmarshal([]byte(payload), &disk); err != nil { t.Fatalf("unexpected error unmarshalling disk: %v", err) } // Unexpected type should normalize to 0 if disk.RPM != 0 { t.Fatalf("rpm: got %d, want 0", disk.RPM) } } func TestVMFileSystemUnmarshalFlexibleNumbers(t *testing.T) { t.Run("accepts numeric values", func(t *testing.T) { payload := `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":8589934592,"used-bytes":3221225472,"disk":[{"dev":"/dev/vtbd0p2"}]}` var fs VMFileSystem if err := json.Unmarshal([]byte(payload), &fs); err != nil { t.Fatalf("unexpected error unmarshalling numeric fields: %v", err) } if fs.TotalBytes != 8589934592 || fs.UsedBytes != 3221225472 { t.Fatalf("unexpected numeric values: got total=%d used=%d", fs.TotalBytes, fs.UsedBytes) } }) t.Run("accepts numeric strings", func(t *testing.T) { payload := `{"name":"rootfs","type":"ufs","mountpoint":"/","total-bytes":"5368709120","used-bytes":"2147483648","disk":[{"dev":"/dev/vtbd0p3"}]}` var fs VMFileSystem if err := json.Unmarshal([]byte(payload), &fs); err != nil { t.Fatalf("unexpected error unmarshalling string fields: %v", err) } if fs.TotalBytes != 5368709120 || fs.UsedBytes != 2147483648 { t.Fatalf("unexpected string values: got total=%d used=%d", fs.TotalBytes, fs.UsedBytes) } }) t.Run("accepts float-like strings", func(t *testing.T) { payload := `{"name":"rootfs","type":"ufs","mountpoint":"/","total-bytes":"1073741824.0","used-bytes":"536870912.0","disk":[{"dev":"/dev/vtbd0p4"}]}` var fs VMFileSystem if err := json.Unmarshal([]byte(payload), &fs); err != nil { t.Fatalf("unexpected error unmarshalling float-like strings: %v", err) } if fs.TotalBytes != 1073741824 || fs.UsedBytes != 536870912 { t.Fatalf("unexpected float string values: got total=%d used=%d", fs.TotalBytes, fs.UsedBytes) } }) t.Run("falls back to total-bytes-privileged when total-bytes is missing", func(t *testing.T) { payload := `{"name":"windows","type":"ntfs","mountpoint":"C:\\\\","total-bytes-privileged":536870912000,"used-bytes":214748364800}` var fs VMFileSystem if err := json.Unmarshal([]byte(payload), &fs); err != nil { t.Fatalf("unexpected error unmarshalling privileged total-bytes: %v", err) } if fs.TotalBytes != 536870912000 || fs.TotalBytesPrivileged != 536870912000 || fs.UsedBytes != 214748364800 { t.Fatalf("unexpected privileged values: got total=%d privileged=%d used=%d", fs.TotalBytes, fs.TotalBytesPrivileged, fs.UsedBytes) } }) t.Run("uses windows name as mountpoint fallback when mountpoint is empty", func(t *testing.T) { payload := `{"name":"C:\\Windows","type":"ntfs","mountpoint":"","total-bytes":536870912000,"used-bytes":214748364800}` var fs VMFileSystem if err := json.Unmarshal([]byte(payload), &fs); err != nil { t.Fatalf("unexpected error unmarshalling windows name fallback: %v", err) } if fs.Mountpoint != "C:\\Windows" { t.Fatalf("expected windows mountpoint fallback from name, got %q", fs.Mountpoint) } }) t.Run("accepts disk metadata as an object", func(t *testing.T) { payload := `{"name":"rootfs","type":"ext4","mountpoint":"/","total-bytes":1000,"used-bytes":200,"disk":{"dev":"/dev/sda"}}` var fs VMFileSystem if err := json.Unmarshal([]byte(payload), &fs); err != nil { t.Fatalf("unexpected error unmarshalling disk object: %v", err) } if len(fs.DiskRaw) != 1 { t.Fatalf("expected disk metadata object to normalize to one entry, got %d", len(fs.DiskRaw)) } if diskMap, ok := fs.DiskRaw[0].(map[string]interface{}); !ok || diskMap["dev"] != "/dev/sda" { t.Fatalf("expected normalized disk metadata to preserve dev, got %#v", fs.DiskRaw[0]) } }) } func TestVMFileSystemUnmarshalJSON_InvalidValues(t *testing.T) { tests := []struct { name string payload string wantErr bool }{ { name: "invalid JSON", payload: `{invalid json}`, wantErr: true, }, { name: "invalid total-bytes field type", payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":true,"used-bytes":1000}`, wantErr: true, }, { name: "invalid used-bytes field type", payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":1000,"used-bytes":[1,2,3]}`, wantErr: true, }, { name: "invalid string value for total-bytes", payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":"not-a-number","used-bytes":1000}`, wantErr: true, }, { name: "invalid string value for used-bytes", payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":1000,"used-bytes":"invalid"}`, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var fs VMFileSystem err := json.Unmarshal([]byte(tc.payload), &fs) if tc.wantErr && err == nil { t.Errorf("expected error for %s, got nil", tc.name) } if !tc.wantErr && err != nil { t.Errorf("unexpected error for %s: %v", tc.name, err) } }) } } func TestMemoryStatusEffectiveAvailable(t *testing.T) { t.Run("nil receiver returns zero", func(t *testing.T) { var status *MemoryStatus if status.EffectiveAvailable() != 0 { t.Fatalf("expected nil receiver to return 0") } }) tests := []struct { name string status MemoryStatus want uint64 }{ { name: "uses available field when set", status: MemoryStatus{Total: 16 * 1024, Available: 6 * 1024}, want: 6 * 1024, }, { name: "uses avail field fallback", status: MemoryStatus{Total: 16 * 1024, Avail: 5 * 1024}, want: 5 * 1024, }, { name: "derives from free buffers cached", status: MemoryStatus{Total: 32 * 1024, Free: 4 * 1024, Buffers: 2 * 1024, Cached: 6 * 1024}, want: 12 * 1024, }, { name: "available zero but buffers and cache present", status: MemoryStatus{ Total: 64 * 1024, Used: 40 * 1024, Free: 6 * 1024, Buffers: 8 * 1024, Cached: 10 * 1024, }, want: 24 * 1024, }, { name: "caps derived value at total", status: MemoryStatus{Total: 8 * 1024, Free: 4 * 1024, Buffers: 4 * 1024, Cached: 4 * 1024}, want: 8 * 1024, }, { name: "returns zero when no data", status: MemoryStatus{Total: 24 * 1024}, want: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := tc.status.EffectiveAvailable(); got != tc.want { t.Fatalf("EffectiveAvailable: got %d, want %d", got, tc.want) } }) } } func TestMemoryStatusUnmarshalFlexibleValues(t *testing.T) { tests := []struct { name string payload string want MemoryStatus }{ { name: "numeric strings", payload: `{"total":"16549875712","used":"6050492416","free":"2467389440","available":"10499383296","buffers":"0","cached":"0","shared":"0"}`, want: MemoryStatus{ Total: 16549875712, Used: 6050492416, Free: 2467389440, Available: 10499383296, Buffers: 0, Cached: 0, Shared: 0, }, }, { name: "scientific notation", payload: `{"total":1.6549875712e+10,"used":6.050492416e+09,"free":2.46738944e+09,"available":1.0499383296e+10,"buffers":0,"cached":0,"shared":0}`, want: MemoryStatus{ Total: 16549875712, Used: 6050492416, Free: 2467389440, Available: 10499383296, }, }, { name: "float-like strings with spaces", payload: `{"total":" 8589934592.0 ","used":"3221225472.0","free":"536870912.0","avail":"4831838208.0","buffers":"67108864","cached":"4026531840","shared":"0"}`, want: MemoryStatus{ Total: 8589934592, Used: 3221225472, Free: 536870912, Avail: 4831838208, Buffers: 67108864, Cached: 4026531840, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var status MemoryStatus if err := json.Unmarshal([]byte(tc.payload), &status); err != nil { t.Fatalf("unexpected error unmarshalling %s payload: %v", tc.name, err) } if status.Total != tc.want.Total { t.Fatalf("total: got %d, want %d", status.Total, tc.want.Total) } if status.Used != tc.want.Used { t.Fatalf("used: got %d, want %d", status.Used, tc.want.Used) } if status.Free != tc.want.Free { t.Fatalf("free: got %d, want %d", status.Free, tc.want.Free) } if status.Available != tc.want.Available { t.Fatalf("available: got %d, want %d", status.Available, tc.want.Available) } if status.Avail != tc.want.Avail { t.Fatalf("avail: got %d, want %d", status.Avail, tc.want.Avail) } if status.Buffers != tc.want.Buffers { t.Fatalf("buffers: got %d, want %d", status.Buffers, tc.want.Buffers) } if status.Cached != tc.want.Cached { t.Fatalf("cached: got %d, want %d", status.Cached, tc.want.Cached) } if status.Shared != tc.want.Shared { t.Fatalf("shared: got %d, want %d", status.Shared, tc.want.Shared) } }) } } func TestMemoryStatusUnmarshalJSON_InvalidValues(t *testing.T) { tests := []struct { name string payload string wantErr bool }{ { name: "invalid JSON", payload: `{invalid json}`, wantErr: true, }, { name: "invalid total field type", payload: `{"total":true,"used":1000,"free":500}`, wantErr: true, }, { name: "invalid used field type", payload: `{"total":1000,"used":[1,2,3],"free":500}`, wantErr: true, }, { name: "invalid free field type", payload: `{"total":1000,"used":500,"free":{"nested":"object"}}`, wantErr: true, }, { name: "invalid available field type", payload: `{"total":1000,"used":500,"free":300,"available":true}`, wantErr: true, }, { name: "invalid string value for total", payload: `{"total":"not-a-number","used":500}`, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var status MemoryStatus err := json.Unmarshal([]byte(tc.payload), &status) if tc.wantErr && err == nil { t.Errorf("expected error for %s, got nil", tc.name) } if !tc.wantErr && err != nil { t.Errorf("unexpected error for %s: %v", tc.name, err) } }) } } func TestFlexIntUnmarshalJSON(t *testing.T) { tests := []struct { name string input string want FlexInt wantErr bool }{ { name: "integer", input: "42", want: FlexInt(42), }, { name: "zero", input: "0", want: FlexInt(0), }, { name: "negative integer", input: "-10", want: FlexInt(-10), }, { name: "float truncates", input: "1.5", want: FlexInt(1), }, { name: "float 2.9 truncates", input: "2.9", want: FlexInt(2), }, { name: "string integer", input: `"123"`, want: FlexInt(123), }, { name: "string float", input: `"1.5"`, want: FlexInt(1), }, { name: "string float 3.7", input: `"3.7"`, want: FlexInt(3), }, { name: "large integer", input: "1000000", want: FlexInt(1000000), }, { name: "float overflow", input: "1e309", wantErr: true, }, { name: "string float overflow", input: `"1e309"`, wantErr: true, }, { name: "invalid string", input: `"not a number"`, wantErr: true, }, { name: "empty string", input: `""`, wantErr: true, }, { name: "invalid json", input: `{invalid}`, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var f FlexInt err := f.UnmarshalJSON([]byte(tc.input)) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if f != tc.want { t.Fatalf("got %d, want %d", f, tc.want) } }) } } func TestVMIpAddressUnmarshalJSONCapsPrefix(t *testing.T) { var addr VMIpAddress if err := addr.UnmarshalJSON([]byte(`{"ip-address":"2001:db8::1","prefix":"18446744073709551615"}`)); err != nil { t.Fatalf("unexpected error: %v", err) } if addr.Address != "2001:db8::1" { t.Fatalf("Address = %q", addr.Address) } if addr.Prefix != 128 { t.Fatalf("Prefix = %d, want 128", addr.Prefix) } } func TestParseUint64Flexible(t *testing.T) { tests := []struct { name string value interface{} want uint64 wantErr bool }{ // nil {name: "nil", value: nil, want: 0}, // uint64 {name: "uint64", value: uint64(42), want: 42}, {name: "uint64 max", value: uint64(18446744073709551615), want: 18446744073709551615}, // int {name: "int positive", value: int(100), want: 100}, {name: "int zero", value: int(0), want: 0}, {name: "int negative", value: int(-5), want: 0}, // int64 {name: "int64 positive", value: int64(200), want: 200}, {name: "int64 negative", value: int64(-10), want: 0}, // float64 {name: "float64 positive", value: float64(3.7), want: 3}, {name: "float64 truncates down", value: float64(9.9), want: 9}, {name: "float64 negative", value: float64(-1.5), want: 0}, {name: "float64 zero", value: float64(0.0), want: 0}, // json.Number {name: "json.Number integer", value: json.Number("99"), want: 99}, {name: "json.Number float", value: json.Number("3.14"), want: 3}, {name: "json.Number invalid", value: json.Number("abc"), wantErr: true}, // string - empty/whitespace {name: "string empty", value: "", want: 0}, {name: "string whitespace", value: " ", want: 0}, // string - decimal {name: "string decimal", value: "12345", want: 12345}, {name: "string with whitespace", value: " 678 ", want: 678}, {name: "string invalid decimal", value: "abc", wantErr: true}, {name: "string negative decimal", value: "-100", wantErr: true}, // string - hex {name: "string hex lowercase", value: "0x10", want: 16}, {name: "string hex uppercase", value: "0X1F", want: 31}, {name: "string hex invalid", value: "0xGG", wantErr: true}, // string - float notation {name: "string float", value: "3.14", want: 3}, {name: "string scientific", value: "1e3", want: 1000}, {name: "string scientific uppercase", value: "1.5E2", want: 150}, {name: "string negative float", value: "-2.5", want: 0}, {name: "string invalid float", value: "1.2.3", wantErr: true}, // unsupported type {name: "unsupported bool", value: true, wantErr: true}, {name: "unsupported slice", value: []int{1, 2}, wantErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := parseUint64Flexible(tc.value) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil with value %d", got) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tc.want { t.Fatalf("got %d, want %d", got, tc.want) } }) } } func TestCoerceUint64(t *testing.T) { tests := []struct { name string field string value interface{} want uint64 wantErr bool }{ // nil handling { name: "nil returns zero", field: "test", value: nil, want: 0, }, // float64 handling { name: "float64 positive", field: "test", value: float64(100.0), want: 100, }, { name: "float64 rounds", field: "test", value: float64(100.6), want: 101, }, { name: "float64 negative returns zero", field: "test", value: float64(-10), want: 0, }, { name: "float64 zero", field: "test", value: float64(0), want: 0, }, { name: "float64 NaN returns error", field: "test", value: math.NaN(), wantErr: true, }, { name: "float64 positive infinity returns error", field: "test", value: math.Inf(1), wantErr: true, }, // int handling { name: "int positive", field: "test", value: int(42), want: 42, }, { name: "int negative returns zero", field: "test", value: int(-5), want: 0, }, { name: "int zero", field: "test", value: int(0), want: 0, }, // int64 handling { name: "int64 positive", field: "test", value: int64(1000000000000), want: 1000000000000, }, { name: "int64 negative returns zero", field: "test", value: int64(-100), want: 0, }, // int32 handling { name: "int32 positive", field: "test", value: int32(12345), want: 12345, }, { name: "int32 negative returns zero", field: "test", value: int32(-1), want: 0, }, // uint32 handling { name: "uint32", field: "test", value: uint32(4294967295), want: 4294967295, }, // uint64 handling { name: "uint64", field: "test", value: uint64(18446744073709551615), want: 18446744073709551615, }, // json.Number handling { name: "json.Number integer", field: "test", value: json.Number("12345"), want: 12345, }, { name: "json.Number float", field: "test", value: json.Number("123.45"), want: 123, }, // string handling { name: "string integer", field: "test", value: "12345", want: 12345, }, { name: "string with whitespace", field: "test", value: " 12345 ", want: 12345, }, { name: "string empty", field: "test", value: "", want: 0, }, { name: "string null", field: "test", value: "null", want: 0, }, { name: "string NULL uppercase", field: "test", value: "NULL", want: 0, }, { name: "string with quotes", field: "test", value: `"12345"`, want: 12345, }, { name: "string with single quotes", field: "test", value: `'12345'`, want: 12345, }, { name: "string with commas", field: "test", value: "1,000,000", want: 1000000, }, { name: "string float notation", field: "test", value: "123.45", want: 123, }, { name: "string scientific notation", field: "test", value: "1e6", want: 1000000, }, { name: "string scientific notation uppercase", field: "test", value: "1E6", want: 1000000, }, { name: "string invalid", field: "test", value: "not a number", wantErr: true, }, { name: "string invalid float in scientific notation", field: "test", value: "1.2.3e4", wantErr: true, }, // String without .eE that fails ParseUint (covers the ParseUint error branch) { name: "string invalid no decimal chars", field: "test", value: "abc", wantErr: true, }, { name: "string quoted null", field: "test", value: `"null"`, want: 0, }, { name: "string quoted empty", field: "test", value: `""`, want: 0, }, { name: "string single quoted empty", field: "test", value: `''`, want: 0, }, { name: "float64 at MaxUint64 boundary", field: "test", value: float64(math.MaxUint64), want: math.MaxUint64, }, { name: "float64 exceeding MaxUint64", field: "test", value: float64(math.MaxUint64) * 2, want: math.MaxUint64, }, // unsupported type { name: "unsupported type bool", field: "test", value: true, wantErr: true, }, { name: "unsupported type slice", field: "test", value: []int{1, 2, 3}, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := coerceUint64(tc.field, tc.value) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tc.want { t.Fatalf("got %d, want %d", got, tc.want) } }) } } func TestParseWearoutValue(t *testing.T) { tests := []struct { name string raw string want int }{ // Empty and whitespace {name: "empty string", raw: "", want: wearoutUnknown}, {name: "whitespace only", raw: " ", want: wearoutUnknown}, {name: "tab whitespace", raw: "\t\n", want: wearoutUnknown}, // Simple numeric strings {name: "zero", raw: "0", want: 0}, {name: "simple integer", raw: "81", want: 81}, {name: "integer with leading space", raw: " 75", want: 75}, {name: "integer with trailing space", raw: "90 ", want: 90}, {name: "integer with surrounding space", raw: " 42 ", want: 42}, {name: "100 percent", raw: "100", want: 100}, // Quoted values (API sometimes wraps values in quotes) {name: "double quoted", raw: `"81"`, want: 81}, {name: "single quoted", raw: `'75'`, want: 75}, {name: "escaped quotes", raw: `\"81\"`, want: 81}, {name: "double escaped quotes", raw: `"\"90\""`, want: 90}, {name: "mixed quote styles", raw: `"'50'"`, want: 50}, // Percentage symbols {name: "percentage symbol", raw: "81%", want: 81}, {name: "percentage with space before", raw: "82 %", want: 82}, {name: "percentage with space after", raw: "83% ", want: 83}, {name: "quoted percentage", raw: `"75%"`, want: 75}, // N/A and similar {name: "N/A uppercase", raw: "N/A", want: wearoutUnknown}, {name: "n/a lowercase", raw: "n/a", want: wearoutUnknown}, {name: "NA no slash", raw: "NA", want: wearoutUnknown}, {name: "na lowercase no slash", raw: "na", want: wearoutUnknown}, {name: "none", raw: "none", want: wearoutUnknown}, {name: "None capitalized", raw: "None", want: wearoutUnknown}, {name: "NONE uppercase", raw: "NONE", want: wearoutUnknown}, {name: "unknown", raw: "unknown", want: wearoutUnknown}, {name: "Unknown capitalized", raw: "Unknown", want: wearoutUnknown}, {name: "UNKNOWN uppercase", raw: "UNKNOWN", want: wearoutUnknown}, {name: "quoted N/A", raw: `"N/A"`, want: wearoutUnknown}, // Float values {name: "float value truncated", raw: "81.5", want: 81}, {name: "float zero decimal", raw: "90.0", want: 90}, {name: "float high precision", raw: "75.999", want: 75}, {name: "negative float", raw: "-5.5", want: -5}, {name: "float zero exactly", raw: "0.0", want: 0}, {name: "float negative zero", raw: "-0.0", want: 0}, // Quoted values that become empty after trimming {name: "only escaped quotes", raw: `\"\"`, want: wearoutUnknown}, {name: "quoted whitespace", raw: `" "`, want: wearoutUnknown}, // Digit extraction fallback (messy SMART data) {name: "percentage text mixed", raw: "about 50 percent", want: 50}, {name: "text with digits", raw: "wear level 25 remaining", want: 25}, {name: "complex messy string", raw: "SSD: 15% endurance", want: 15}, // Edge cases {name: "negative value", raw: "-1", want: -1}, {name: "large value", raw: "999", want: 999}, // Non-parseable strings (no digits at all) {name: "no digits text", raw: "not available", want: wearoutUnknown}, {name: "symbols only", raw: "---", want: wearoutUnknown}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := parseWearoutValue(tc.raw) if got != tc.want { t.Errorf("parseWearoutValue(%q) = %d, want %d", tc.raw, got, tc.want) } }) } } func TestClampWearoutConsumed(t *testing.T) { tests := []struct { name string val int want int }{ // Unknown value passthrough {name: "unknown passthrough", val: wearoutUnknown, want: wearoutUnknown}, // Normal range {name: "zero", val: 0, want: 0}, {name: "middle value", val: 50, want: 50}, {name: "max valid", val: 100, want: 100}, // Clamping negative {name: "negative clamped to zero", val: -5, want: 0}, {name: "large negative clamped", val: -100, want: 0}, // Clamping over 100 {name: "over 100 clamped", val: 105, want: 100}, {name: "way over 100", val: 999, want: 100}, // Edge cases {name: "just under unknown", val: -2, want: 0}, {name: "one", val: 1, want: 1}, {name: "ninety-nine", val: 99, want: 99}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := clampWearoutConsumed(tc.val) if got != tc.want { t.Errorf("clampWearoutConsumed(%d) = %d, want %d", tc.val, got, tc.want) } }) } } // TestMemoryStatusEffectiveAvailable_RegressionIssue435 tests the specific scenarios // reported in GitHub issue #435 where memory calculations incorrectly included cache/buffers func TestMemoryStatusEffectiveAvailable_RegressionIssue435(t *testing.T) { tests := []struct { name string status MemoryStatus wantAvailable uint64 wantUsedPct float64 // Expected usage percentage when using EffectiveAvailable description string }{ { name: "real proxmox 8.x node with available field", status: MemoryStatus{ Total: 16466186240, // 16GB Used: 13981696000, // ~14GB (includes cache) Free: 1558323200, // ~1.5GB Available: 7422545920, // ~7GB (cache-aware - this is what we should use!) Buffers: 23592960, // ~23MB Cached: 5478891520, // ~5.4GB }, wantAvailable: 7422545920, wantUsedPct: 54.92, // Should be ~55%, not ~85%! description: "Proxmox 8.x returns 'available' field - most accurate", }, { name: "older proxmox with avail field", status: MemoryStatus{ Total: 8589934592, // 8GB Used: 6871947674, // ~6.4GB (includes cache) Free: 805306368, // ~768MB Avail: 3221225472, // ~3GB (cache-aware) }, wantAvailable: 3221225472, wantUsedPct: 62.5, // Should be ~62.5%, not ~80% description: "Older Proxmox uses 'avail' field as fallback", }, { name: "proxmox without available/avail - derive from components", status: MemoryStatus{ Total: 16777216000, // ~16GB Used: 13421772800, // ~12.5GB (includes cache) Free: 2147483648, // 2GB Buffers: 536870912, // 512MB Cached: 4294967296, // 4GB }, wantAvailable: 6979321856, // Free + Buffers + Cached = ~6.5GB wantUsedPct: 58.4, // Should be ~58%, not ~80% description: "When available/avail missing, derive from free+buffers+cached", }, { name: "proxmox 8.4 hides cache fields - derive from total-minus-used gap", status: MemoryStatus{ Total: 134794743808, // ~125.6GB Used: 107351023616, // ~100GB actual usage Free: 6471057408, // ~6GB bare free reported Buffers: 0, Cached: 0, }, wantAvailable: 27443720192, // total - used => ~25.6GB reclaimable (free + cache) wantUsedPct: 79.6, // Matches Proxmox node dashboard description: "Proxmox 8.4 stops reporting buffers/cached; use total-used gap to recover cache-aware metric", }, { name: "issue #435 specific case - 86% vs 42% real usage", status: MemoryStatus{ Total: 33554432000, // ~32GB Used: 28857589760, // ~27GB (includes cache - WRONG!) Free: 1073741824, // 1GB Available: 19327352832, // ~18GB (cache-aware - CORRECT!) }, wantAvailable: 19327352832, wantUsedPct: 42.4, // Should be ~42%, not 86%! description: "Real user report: 86% shown when actual usage is 42%", }, { name: "missing available but buffers/cached still present (issue 553 guard)", status: MemoryStatus{ Total: 34359738368, // 32GB Used: 27917287424, // ~26GB reported used (includes cache) Free: 2147483648, // 2GB Buffers: 3221225472, // 3GB Cached: 8053063680, // 7.5GB }, wantAvailable: 13421772800, // Free + Buffers + Cached wantUsedPct: 60.94, description: "When available=0 but buffers/cached exist, derive reclaimable memory instead of alerting", }, { name: "missing all cache-aware fields - fallback to zero", status: MemoryStatus{ Total: 8589934592, Used: 6871947674, Free: 0, // All fields missing }, wantAvailable: 1717986918, // Derived from total - used wantUsedPct: 80.0, // Still aligns with cache-inclusive calculation when nothing else reported description: "When all cache fields missing, fall back to total-used gap instead of zero", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := tc.status.EffectiveAvailable() if got != tc.wantAvailable { t.Errorf("EffectiveAvailable() = %d, want %d\nScenario: %s", got, tc.wantAvailable, tc.description) } // Calculate what the usage percentage would be with cache-aware calculation if tc.wantAvailable > 0 && tc.wantAvailable <= tc.status.Total { actualUsed := tc.status.Total - tc.wantAvailable usagePct := (float64(actualUsed) / float64(tc.status.Total)) * 100 // Allow 0.5% tolerance for floating point differences if usagePct < tc.wantUsedPct-0.5 || usagePct > tc.wantUsedPct+0.5 { t.Errorf("Calculated usage = %.2f%%, want ~%.2f%%\nScenario: %s", usagePct, tc.wantUsedPct, tc.description) } } }) } } func TestAuthHTTPError_Error(t *testing.T) { t.Parallel() tests := []struct { name string err *authHTTPError contains string }{ { name: "unauthorized status includes status code", err: &authHTTPError{status: 401, body: "invalid credentials"}, contains: "status 401", }, { name: "forbidden status includes status code", err: &authHTTPError{status: 403, body: "access denied"}, contains: "status 403", }, { name: "unauthorized includes body", err: &authHTTPError{status: 401, body: "bad user/pass"}, contains: "bad user/pass", }, { name: "forbidden includes body", err: &authHTTPError{status: 403, body: "permission denied"}, contains: "permission denied", }, { name: "other status omits status code", err: &authHTTPError{status: 500, body: "server error"}, contains: "authentication failed: server error", }, { name: "bad request omits status code", err: &authHTTPError{status: 400, body: "bad request"}, contains: "authentication failed: bad request", }, { name: "zero status omits status code", err: &authHTTPError{status: 0, body: "unknown error"}, contains: "authentication failed: unknown error", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() msg := tc.err.Error() if !containsSubstring(msg, tc.contains) { t.Errorf("Error() = %q, want to contain %q", msg, tc.contains) } }) } } func TestShouldFallbackToForm(t *testing.T) { t.Parallel() tests := []struct { name string err error want bool }{ { name: "nil error", err: nil, want: false, }, { name: "non-authHTTPError", err: fmt.Errorf("some other error"), want: false, }, { name: "bad request triggers fallback", err: &authHTTPError{status: 400, body: "bad request"}, want: true, }, { name: "unsupported media type triggers fallback", err: &authHTTPError{status: 415, body: "unsupported media type"}, want: true, }, { name: "unauthorized does not trigger fallback", err: &authHTTPError{status: 401, body: "unauthorized"}, want: false, }, { name: "forbidden does not trigger fallback", err: &authHTTPError{status: 403, body: "forbidden"}, want: false, }, { name: "server error does not trigger fallback", err: &authHTTPError{status: 500, body: "internal error"}, want: false, }, { name: "not found does not trigger fallback", err: &authHTTPError{status: 404, body: "not found"}, want: false, }, { name: "zero status does not trigger fallback", err: &authHTTPError{status: 0, body: ""}, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() got := shouldFallbackToForm(tc.err) if got != tc.want { t.Errorf("shouldFallbackToForm(%v) = %v, want %v", tc.err, got, tc.want) } }) } } // containsSubstring checks if s contains substr (helper for error message checks) func containsSubstring(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) } func findSubstring(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } func TestVMAgentFieldUnmarshalJSON(t *testing.T) { tests := []struct { name string input string want int }{ { name: "integer format enabled", input: `1`, want: 1, }, { name: "integer format zero", input: `0`, want: 0, }, { name: "object format with available > 0", input: `{"enabled":1,"available":1}`, want: 1, }, { name: "object format with enabled > 0 but available=0", input: `{"enabled":1,"available":0}`, want: 1, }, { name: "object format with both 0", input: `{"enabled":0,"available":0}`, want: 0, }, { name: "object format available takes priority", input: `{"enabled":0,"available":1}`, want: 1, }, { name: "invalid JSON defaults to 0", input: `{invalid}`, want: 0, }, { name: "empty string defaults to 0", input: ``, want: 0, }, { name: "null value defaults to 0", input: `null`, want: 0, }, { name: "string value defaults to 0", input: `"enabled"`, want: 0, }, { name: "array value defaults to 0", input: `[1,2,3]`, want: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var agent VMAgentField // UnmarshalJSON always returns nil, so we just check the value _ = agent.UnmarshalJSON([]byte(tc.input)) if agent.Value != tc.want { t.Errorf("VMAgentField.UnmarshalJSON(%q) = %d, want %d", tc.input, agent.Value, tc.want) } }) } } func TestVMAgentFieldUnmarshalJSON_InVMStatus(t *testing.T) { tests := []struct { name string payload string want int }{ { name: "VMStatus with integer agent field", payload: `{"status":"running","cpu":0.5,"cpus":4,"mem":1073741824,"maxmem":4294967296,"agent":1}`, want: 1, }, { name: "VMStatus with object agent field", payload: `{"status":"running","cpu":0.1,"cpus":2,"mem":536870912,"maxmem":2147483648,"agent":{"enabled":1,"available":1}}`, want: 1, }, { name: "VMStatus with object agent enabled but not available", payload: `{"status":"stopped","cpu":0,"cpus":1,"mem":0,"maxmem":1073741824,"agent":{"enabled":1,"available":0}}`, want: 1, }, { name: "VMStatus with object agent both zero", payload: `{"status":"stopped","cpu":0,"cpus":1,"mem":0,"maxmem":1073741824,"agent":{"enabled":0,"available":0}}`, want: 0, }, { name: "VMStatus without agent field", payload: `{"status":"running","cpu":0.2,"cpus":2,"mem":268435456,"maxmem":1073741824}`, want: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var status VMStatus if err := json.Unmarshal([]byte(tc.payload), &status); err != nil { t.Fatalf("unexpected error unmarshalling VMStatus: %v", err) } if status.Agent.Value != tc.want { t.Errorf("VMStatus.Agent.Value = %d, want %d", status.Agent.Value, tc.want) } }) } } func TestGetMHzString(t *testing.T) { tests := []struct { name string cpu CPUInfo want string }{ { name: "nil MHz returns empty string", cpu: CPUInfo{MHz: nil}, want: "", }, { name: "string MHz returned as-is", cpu: CPUInfo{MHz: "3600.000"}, want: "3600.000", }, { name: "empty string MHz", cpu: CPUInfo{MHz: ""}, want: "", }, { name: "float64 MHz formatted without decimals", cpu: CPUInfo{MHz: float64(3600.123)}, want: "3600", }, { name: "float64 MHz zero", cpu: CPUInfo{MHz: float64(0)}, want: "0", }, { name: "float64 MHz large value", cpu: CPUInfo{MHz: float64(5000.999)}, want: "5001", }, { name: "int MHz formatted", cpu: CPUInfo{MHz: int(2400)}, want: "2400", }, { name: "int MHz zero", cpu: CPUInfo{MHz: int(0)}, want: "0", }, { name: "other type uses default formatting", cpu: CPUInfo{MHz: int64(3200)}, want: "3200", }, { name: "boolean type uses default formatting", cpu: CPUInfo{MHz: true}, want: "true", }, { name: "slice type uses default formatting", cpu: CPUInfo{MHz: []int{1, 2, 3}}, want: "[1 2 3]", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := tc.cpu.GetMHzString() if got != tc.want { t.Errorf("GetMHzString() = %q, want %q", got, tc.want) } }) } } func TestGetNodes_InvalidJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Return invalid JSON that will cause decode error fmt.Fprint(w, `{"data": [{"node": "test" invalid json}`) })) defer server.Close() cfg := ClientConfig{ Host: server.URL, TokenName: "test@pve!token", TokenValue: "secret", VerifySSL: false, Timeout: 2 * time.Second, } client, err := NewClient(cfg) if err != nil { t.Fatalf("NewClient failed: %v", err) } ctx := context.Background() _, err = client.GetNodes(ctx) if err == nil { t.Fatal("expected JSON decode error, got nil") } } func TestNewClient_InvalidUserFormat(t *testing.T) { tests := []struct { name string user string wantErr bool errContains string }{ { name: "missing realm separator", user: "userwithoutrealm", wantErr: true, errContains: "invalid user format", }, { name: "empty user", user: "", wantErr: true, errContains: "invalid user format", }, { name: "multiple @ symbols", user: "user@realm@extra", wantErr: true, errContains: "invalid user format", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cfg := ClientConfig{ Host: "https://localhost:8006", User: tc.user, Password: "testpass", } _, err := NewClient(cfg) if tc.wantErr { if err == nil { t.Errorf("expected error for user %q, got nil", tc.user) } else if !strings.Contains(err.Error(), tc.errContains) { t.Errorf("expected error containing %q, got: %v", tc.errContains, err) } } if !tc.wantErr && err != nil { t.Errorf("unexpected error for user %q: %v", tc.user, err) } }) } }