diff --git a/internal/slurm/client_test.go b/internal/slurm/client_test.go new file mode 100644 index 0000000..af55b5a --- /dev/null +++ b/internal/slurm/client_test.go @@ -0,0 +1,136 @@ +package slurm + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewClient(t *testing.T) { + client, err := NewClient("http://localhost:6820/", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } + if client.UserAgent != DefaultUserAgent { + t.Errorf("expected UserAgent %q, got %q", DefaultUserAgent, client.UserAgent) + } + if client.client == nil { + t.Error("expected http.Client to be initialized (nil httpClient should default to http.DefaultClient)") + } + + _, err = NewClient("://invalid", nil) + if err == nil { + t.Fatal("expected error for invalid URL, got nil") + } + + client2, err := NewClient("http://localhost:6820/", nil) + if err != nil { + t.Fatalf("unexpected error with nil httpClient: %v", err) + } + if client2.client == nil { + t.Error("expected client.httpclient to be non-nil when passing nil") + } +} + +func TestNewClient_ServicesInitialized(t *testing.T) { + client, err := NewClient("http://localhost:6820/", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + services := []struct { + name string + svc interface{} + }{ + {"Jobs", client.Jobs}, + {"Nodes", client.Nodes}, + {"Partitions", client.Partitions}, + {"Reservations", client.Reservations}, + {"Diag", client.Diag}, + {"Ping", client.Ping}, + {"Licenses", client.Licenses}, + {"Reconfigure", client.Reconfigure}, + {"Shares", client.Shares}, + } + + for _, s := range services { + if s.svc == nil { + t.Errorf("%s service is nil", s.name) + } + } +} + +func TestClient_AuthHeaders(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-SLURM-USER-NAME"); got != "testuser" { + t.Errorf("expected X-SLURM-USER-NAME %q, got %q", "testuser", got) + } + if got := r.Header.Get("X-SLURM-USER-TOKEN"); got != "testtoken" { + t.Errorf("expected X-SLURM-USER-TOKEN %q, got %q", "testtoken", got) + } + w.WriteHeader(200) + fmt.Fprint(w, `{}`) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + transport := &TokenAuthTransport{ + UserName: "testuser", + Token: "testtoken", + } + httpClient := transport.Client() + client, err := NewClient(server.URL, httpClient) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req, err := client.NewRequest("GET", "slurm/v0.0.40/ping", nil) + if err != nil { + t.Fatalf("unexpected error creating request: %v", err) + } + _, err = client.Do(context.Background(), req, nil) + if err != nil { + t.Fatalf("unexpected error doing request: %v", err) + } +} + +func TestClient_ErrorHandling(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + fmt.Fprint(w, "internal server error") + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client, err := NewClient(server.URL, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req, err := client.NewRequest("GET", "slurm/v0.0.40/diag", nil) + if err != nil { + t.Fatalf("unexpected error creating request: %v", err) + } + _, err = client.Do(context.Background(), req, nil) + + if err == nil { + t.Fatal("expected error for 500 response") + } + + errorResp, ok := err.(*ErrorResponse) + if !ok { + t.Fatalf("expected *ErrorResponse, got %T", err) + } + if errorResp.Response.StatusCode != 500 { + t.Errorf("expected status 500, got %d", errorResp.Response.StatusCode) + } +} diff --git a/internal/slurm/types.go b/internal/slurm/types.go new file mode 100644 index 0000000..1964875 --- /dev/null +++ b/internal/slurm/types.go @@ -0,0 +1,73 @@ +package slurm + +// Ptr returns a pointer to the given value. Useful for optional fields. +func Ptr[T any](v T) *T { return &v } + +// --------------------------------------------------------------------------- +// NoVal types — 3-field structs representing "set/infinite/number" pattern +// from the Slurm OpenAPI spec. All fields are optional (pointer). +// --------------------------------------------------------------------------- + +// Uint64NoVal represents an optional uint64 that can be unset or infinite. +type Uint64NoVal struct { + Set *bool `json:"set,omitempty"` + Infinite *bool `json:"infinite,omitempty"` + Number *int64 `json:"number,omitempty"` +} + +// Uint32NoVal represents an optional uint32 that can be unset or infinite. +type Uint32NoVal struct { + Set *bool `json:"set,omitempty"` + Infinite *bool `json:"infinite,omitempty"` + Number *int64 `json:"number,omitempty"` +} + +// Uint16NoVal represents an optional uint16 that can be unset or infinite. +type Uint16NoVal struct { + Set *bool `json:"set,omitempty"` + Infinite *bool `json:"infinite,omitempty"` + Number *int64 `json:"number,omitempty"` +} + +// Float64NoVal represents an optional float64 that can be unset or infinite. +type Float64NoVal struct { + Set *bool `json:"set,omitempty"` + Infinite *bool `json:"infinite,omitempty"` + Number *float64 `json:"number,omitempty"` +} + +// --------------------------------------------------------------------------- +// String collection types — all marshal/unmarshal as JSON arrays of strings. +// --------------------------------------------------------------------------- + +// CSVString is a comma-separated string collection. +type CSVString []string + +// StringArray is a string array type (v0.0.40_string_array). +type StringArray []string + +// StringList is a string list type (v0.0.40_string_list). +type StringList []string + +// Hostlist is a hostlist type (v0.0.40_hostlist). +type Hostlist []string + +// HostlistString is a hostlist string type (v0.0.40_hostlist_string). +type HostlistString []string + +// --------------------------------------------------------------------------- +// Process exit types +// --------------------------------------------------------------------------- + +// ProcessExitCodeVerbose represents a verbose process exit code. +type ProcessExitCodeVerbose struct { + Status []string `json:"status,omitempty"` + ReturnCode *Uint32NoVal `json:"return_code,omitempty"` + Signal *ProcessExitSignal `json:"signal,omitempty"` +} + +// ProcessExitSignal represents a signal received by a process. +type ProcessExitSignal struct { + ID *Uint16NoVal `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} diff --git a/internal/slurm/types_meta.go b/internal/slurm/types_meta.go new file mode 100644 index 0000000..f45c933 --- /dev/null +++ b/internal/slurm/types_meta.go @@ -0,0 +1,66 @@ +package slurm + +// --------------------------------------------------------------------------- +// OpenAPI Meta — top-level metadata returned by every Slurm API response +// --------------------------------------------------------------------------- + +// OpenapiMeta contains plugin, client, command, and Slurm version metadata. +type OpenapiMeta struct { + Plugin *MetaPlugin `json:"plugin,omitempty"` + Client *MetaClient `json:"client,omitempty"` + Command StringArray `json:"command,omitempty"` + Slurm *MetaSlurm `json:"slurm,omitempty"` +} + +// MetaPlugin contains plugin-related metadata. +type MetaPlugin struct { + Type *string `json:"type,omitempty"` + Name *string `json:"name,omitempty"` + DataParser *string `json:"data_parser,omitempty"` + AccountingStorage *string `json:"accounting_storage,omitempty"` +} + +// MetaClient contains client-related metadata. +type MetaClient struct { + Source *string `json:"source,omitempty"` + User *string `json:"user,omitempty"` + Group *string `json:"group,omitempty"` +} + +// MetaSlurm contains Slurm version and cluster metadata. +type MetaSlurm struct { + Version *MetaSlurmVersion `json:"version,omitempty"` + Release *string `json:"release,omitempty"` + Cluster *string `json:"cluster,omitempty"` +} + +// MetaSlurmVersion contains major/micro/minor version components. +type MetaSlurmVersion struct { + Major *string `json:"major,omitempty"` + Micro *string `json:"micro,omitempty"` + Minor *string `json:"minor,omitempty"` +} + +// --------------------------------------------------------------------------- +// OpenAPI Error & Warning — standard error/warning types in responses +// --------------------------------------------------------------------------- + +// OpenapiError represents a single API error. +type OpenapiError struct { + Description *string `json:"description,omitempty"` + ErrorNumber *int32 `json:"error_number,omitempty"` + Error *string `json:"error,omitempty"` + Source *string `json:"source,omitempty"` +} + +// OpenapiErrors is a collection of OpenapiError. +type OpenapiErrors []OpenapiError + +// OpenapiWarning represents a single API warning. +type OpenapiWarning struct { + Description *string `json:"description,omitempty"` + Source *string `json:"source,omitempty"` +} + +// OpenapiWarnings is a collection of OpenapiWarning. +type OpenapiWarnings []OpenapiWarning diff --git a/internal/slurm/types_meta_test.go b/internal/slurm/types_meta_test.go new file mode 100644 index 0000000..6f2174d --- /dev/null +++ b/internal/slurm/types_meta_test.go @@ -0,0 +1,249 @@ +package slurm + +import ( + "encoding/json" + "testing" +) + +func TestOpenapiMetaRoundTrip(t *testing.T) { + orig := OpenapiMeta{ + Plugin: &MetaPlugin{ + Type: Ptr("type"), + Name: Ptr("name"), + DataParser: Ptr("parser"), + AccountingStorage: Ptr("storage"), + }, + Client: &MetaClient{ + Source: Ptr("source"), + User: Ptr("root"), + Group: Ptr("root"), + }, + Command: StringArray{"squeue", "--json"}, + Slurm: &MetaSlurm{ + Version: &MetaSlurmVersion{ + Major: Ptr("24"), + Micro: Ptr("5"), + Minor: Ptr("11"), + }, + Release: Ptr("24.11.5"), + Cluster: Ptr("test-cluster"), + }, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + + var decoded OpenapiMeta + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + // Verify plugin + if decoded.Plugin == nil || *decoded.Plugin.Type != "type" { + t.Fatal("plugin.type mismatch") + } + if *decoded.Plugin.Name != "name" { + t.Fatal("plugin.name mismatch") + } + if *decoded.Plugin.DataParser != "parser" { + t.Fatal("plugin.data_parser mismatch") + } + if *decoded.Plugin.AccountingStorage != "storage" { + t.Fatal("plugin.accounting_storage mismatch") + } + + // Verify client + if decoded.Client == nil || *decoded.Client.Source != "source" { + t.Fatal("client.source mismatch") + } + if *decoded.Client.User != "root" { + t.Fatal("client.user mismatch") + } + if *decoded.Client.Group != "root" { + t.Fatal("client.group mismatch") + } + + // Verify command + if len(decoded.Command) != 2 || decoded.Command[0] != "squeue" { + t.Fatalf("command mismatch: %v", decoded.Command) + } + + // Verify slurm + if decoded.Slurm == nil || decoded.Slurm.Version == nil { + t.Fatal("slurm or slurm.version is nil") + } + if *decoded.Slurm.Version.Major != "24" { + t.Fatal("slurm.version.major mismatch") + } + if *decoded.Slurm.Version.Micro != "5" { + t.Fatal("slurm.version.micro mismatch") + } + if *decoded.Slurm.Version.Minor != "11" { + t.Fatal("slurm.version.minor mismatch") + } + if *decoded.Slurm.Release != "24.11.5" { + t.Fatal("slurm.release mismatch") + } + if *decoded.Slurm.Cluster != "test-cluster" { + t.Fatal("slurm.cluster mismatch") + } +} + +func TestOpenapiMetaEmpty(t *testing.T) { + orig := OpenapiMeta{} + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + if string(data) != "{}" { + t.Fatalf("empty meta should be {}, got %s", data) + } +} + +func TestOpenapiErrorRoundTrip(t *testing.T) { + orig := OpenapiError{ + Description: Ptr("Job not found"), + ErrorNumber: Ptr(int32(2001)), + Error: Ptr("Invalid job id"), + Source: Ptr("slurmctld"), + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + + var decoded OpenapiError + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + if *decoded.Description != "Job not found" { + t.Fatal("description mismatch") + } + if *decoded.ErrorNumber != 2001 { + t.Fatal("error_number mismatch") + } + if *decoded.Error != "Invalid job id" { + t.Fatal("error mismatch") + } + if *decoded.Source != "slurmctld" { + t.Fatal("source mismatch") + } +} + +func TestOpenapiErrorsArray(t *testing.T) { + orig := OpenapiErrors{ + {Description: Ptr("err1"), ErrorNumber: Ptr(int32(1))}, + {Description: Ptr("err2"), ErrorNumber: Ptr(int32(2))}, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + + var decoded OpenapiErrors + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + if len(decoded) != 2 { + t.Fatalf("expected 2 errors, got %d", len(decoded)) + } + if *decoded[0].Description != "err1" { + t.Fatal("first error description mismatch") + } + if *decoded[1].ErrorNumber != 2 { + t.Fatal("second error_number mismatch") + } +} + +func TestOpenapiWarningRoundTrip(t *testing.T) { + orig := OpenapiWarning{ + Description: Ptr("Deprecated API version"), + Source: Ptr("slurmrestd"), + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + + var decoded OpenapiWarning + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + if *decoded.Description != "Deprecated API version" { + t.Fatal("description mismatch") + } + if *decoded.Source != "slurmrestd" { + t.Fatal("source mismatch") + } +} + +func TestOpenapiWarningsArray(t *testing.T) { + orig := OpenapiWarnings{ + {Description: Ptr("warn1")}, + {Description: Ptr("warn2"), Source: Ptr("src")}, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + + var decoded OpenapiWarnings + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + if len(decoded) != 2 { + t.Fatalf("expected 2 warnings, got %d", len(decoded)) + } + if *decoded[0].Description != "warn1" { + t.Fatal("first warning description mismatch") + } + if decoded[1].Source == nil || *decoded[1].Source != "src" { + t.Fatal("second warning source mismatch") + } +} + +func TestOpenapiErrorJSONTags(t *testing.T) { + raw := `{ + "description": "test desc", + "error_number": 42, + "error": "test error", + "source": "test source" + }` + var decoded OpenapiError + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Description == nil || *decoded.Description != "test desc" { + t.Fatal("description json tag mismatch") + } + if decoded.ErrorNumber == nil || *decoded.ErrorNumber != 42 { + t.Fatal("error_number json tag mismatch") + } + if decoded.Error == nil || *decoded.Error != "test error" { + t.Fatal("error json tag mismatch") + } + if decoded.Source == nil || *decoded.Source != "test source" { + t.Fatal("source json tag mismatch") + } +} + +func TestMetaSlurmVersionJSONTags(t *testing.T) { + raw := `{"major":"24","micro":"5","minor":"11"}` + var decoded MetaSlurmVersion + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatal(err) + } + if *decoded.Major != "24" || *decoded.Micro != "5" || *decoded.Minor != "11" { + t.Fatalf("version json tag mismatch: %+v", decoded) + } +} diff --git a/internal/slurm/types_test.go b/internal/slurm/types_test.go new file mode 100644 index 0000000..feebe54 --- /dev/null +++ b/internal/slurm/types_test.go @@ -0,0 +1,340 @@ +package slurm + +import ( + "encoding/json" + "testing" +) + +func TestPtr(t *testing.T) { + i := 42 + p := Ptr(i) + if p == nil { + t.Fatal("Ptr returned nil") + } + if *p != 42 { + t.Fatalf("Ptr(42) = %d, want 42", *p) + } + + s := "hello" + sp := Ptr(s) + if *sp != "hello" { + t.Fatalf("Ptr(\"hello\") = %q, want \"hello\"", *sp) + } +} + +func TestUint64NoVal(t *testing.T) { + testNoValInt(t, Uint64NoVal{}) +} + +func TestUint32NoVal(t *testing.T) { + testNoValInt(t, Uint32NoVal{}) +} + +func TestUint16NoVal(t *testing.T) { + testNoValInt(t, Uint16NoVal{}) +} + +// testNoValInt tests the 3-state behavior for integer NoVal types. +func testNoValInt(t *testing.T, _ interface{}) { + t.Helper() + + // State 1: unset — empty object, all fields nil + t.Run("unset", func(t *testing.T) { + raw := `{}` + var v struct { + Set *bool + Infinite *bool + Number *int64 + } + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatal(err) + } + if v.Set != nil || v.Infinite != nil || v.Number != nil { + t.Fatalf("unset state: expected all nil, got set=%v infinite=%v number=%v", v.Set, v.Infinite, v.Number) + } + }) + + // State 2: infinite + t.Run("infinite", func(t *testing.T) { + raw := `{"set":true,"infinite":true}` + var v struct { + Set *bool + Infinite *bool + Number *int64 + } + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatal(err) + } + if v.Set == nil || !*v.Set { + t.Fatal("set should be true") + } + if v.Infinite == nil || !*v.Infinite { + t.Fatal("infinite should be true") + } + if v.Number != nil { + t.Fatal("number should be nil in infinite state") + } + }) + + // State 3: value + t.Run("value", func(t *testing.T) { + raw := `{"set":true,"number":42}` + var v struct { + Set *bool + Infinite *bool + Number *int64 + } + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatal(err) + } + if v.Set == nil || !*v.Set { + t.Fatal("set should be true") + } + if v.Number == nil || *v.Number != 42 { + t.Fatalf("number should be 42, got %v", v.Number) + } + if v.Infinite != nil { + t.Fatal("infinite should be nil in value state") + } + }) +} + +func TestFloat64NoVal(t *testing.T) { + // State 1: unset + t.Run("unset", func(t *testing.T) { + var v Float64NoVal + if err := json.Unmarshal([]byte(`{}`), &v); err != nil { + t.Fatal(err) + } + if v.Set != nil || v.Infinite != nil || v.Number != nil { + t.Fatalf("expected all nil") + } + }) + + // State 2: infinite + t.Run("infinite", func(t *testing.T) { + var v Float64NoVal + if err := json.Unmarshal([]byte(`{"set":true,"infinite":true}`), &v); err != nil { + t.Fatal(err) + } + if v.Set == nil || !*v.Set { + t.Fatal("set should be true") + } + if v.Infinite == nil || !*v.Infinite { + t.Fatal("infinite should be true") + } + }) + + // State 3: value + t.Run("value", func(t *testing.T) { + var v Float64NoVal + if err := json.Unmarshal([]byte(`{"set":true,"number":42.5}`), &v); err != nil { + t.Fatal(err) + } + if v.Number == nil || *v.Number != 42.5 { + t.Fatalf("number should be 42.5, got %v", v.Number) + } + }) +} + +func TestUint64NoValRoundTrip(t *testing.T) { + orig := Uint64NoVal{ + Set: Ptr(true), + Number: Ptr(int64(100)), + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded Uint64NoVal + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if *decoded.Set != *orig.Set || *decoded.Number != *orig.Number { + t.Fatalf("round-trip mismatch: %+v vs %+v", orig, decoded) + } +} + +func TestUint32NoValRoundTrip(t *testing.T) { + orig := Uint32NoVal{ + Set: Ptr(true), + Infinite: Ptr(true), + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded Uint32NoVal + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if !*decoded.Set || !*decoded.Infinite { + t.Fatal("round-trip mismatch for infinite state") + } +} + +func TestUint16NoValRoundTrip(t *testing.T) { + orig := Uint16NoVal{ + Set: Ptr(true), + Number: Ptr(int64(9)), + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded Uint16NoVal + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if *decoded.Number != 9 { + t.Fatalf("expected 9, got %d", *decoded.Number) + } +} + +func TestFloat64NoValRoundTrip(t *testing.T) { + orig := Float64NoVal{ + Set: Ptr(true), + Number: Ptr(3.14), + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded Float64NoVal + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if *decoded.Number != 3.14 { + t.Fatalf("expected 3.14, got %f", *decoded.Number) + } +} + +func TestCSVStringRoundTrip(t *testing.T) { + orig := CSVString{"a", "b", "c"} + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + if string(data) != `["a","b","c"]` { + t.Fatalf("unexpected JSON: %s", data) + } + var decoded CSVString + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded) != 3 || decoded[0] != "a" || decoded[2] != "c" { + t.Fatalf("round-trip mismatch: %v", decoded) + } +} + +func TestStringArrayRoundTrip(t *testing.T) { + orig := StringArray{"x", "y"} + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded StringArray + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded) != 2 { + t.Fatalf("expected 2 elements, got %d", len(decoded)) + } +} + +func TestStringListRoundTrip(t *testing.T) { + orig := StringList{"foo"} + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded StringList + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded) != 1 || decoded[0] != "foo" { + t.Fatalf("round-trip mismatch: %v", decoded) + } +} + +func TestHostlistRoundTrip(t *testing.T) { + orig := Hostlist{"node01", "node02"} + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded Hostlist + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded) != 2 { + t.Fatalf("expected 2, got %d", len(decoded)) + } +} + +func TestHostlistStringRoundTrip(t *testing.T) { + orig := HostlistString{"node[01-04]"} + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded HostlistString + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded[0] != "node[01-04]" { + t.Fatalf("round-trip mismatch: %v", decoded) + } +} + +func TestProcessExitCodeVerboseRoundTrip(t *testing.T) { + orig := ProcessExitCodeVerbose{ + Status: []string{"EXITED"}, + ReturnCode: &Uint32NoVal{ + Set: Ptr(true), + Number: Ptr(int64(0)), + }, + Signal: &ProcessExitSignal{ + ID: &Uint16NoVal{Set: Ptr(false)}, + Name: Ptr("NONE"), + }, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var decoded ProcessExitCodeVerbose + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.Status) != 1 || decoded.Status[0] != "EXITED" { + t.Fatalf("status mismatch: %v", decoded.Status) + } + if decoded.ReturnCode == nil || decoded.ReturnCode.Number == nil || *decoded.ReturnCode.Number != 0 { + t.Fatal("return_code mismatch") + } + if decoded.Signal == nil || decoded.Signal.Name == nil || *decoded.Signal.Name != "NONE" { + t.Fatal("signal name mismatch") + } +} + +func TestEmptyStringCollections(t *testing.T) { + for name, typ := range map[string]interface{}{ + "CSVString": &CSVString{}, + "StringArray": &StringArray{}, + "StringList": &StringList{}, + "Hostlist": &Hostlist{}, + "HostlistString": &HostlistString{}, + } { + t.Run(name, func(t *testing.T) { + data, err := json.Marshal(typ) + if err != nil { + t.Fatal(err) + } + if string(data) != "null" && string(data) != "[]" { + // nil slice marshals to null, empty slice to [] + t.Fatalf("unexpected: %s", data) + } + }) + } +}