diff --git a/internal/netbox/client.go b/internal/netbox/client.go index b379dec..7d8c575 100644 --- a/internal/netbox/client.go +++ b/internal/netbox/client.go @@ -97,6 +97,31 @@ func (c *Client) CreateDevice(ctx context.Context, name, assetTag string, device return int64(result.GetId()), nil } +// CreateCable creates a new cable record in NetBox with the given label, optional asset tag, +// and test data JSON. Returns the new cable's NetBox ID or an error. +// The test_data and catalog_status custom fields are set from testDataJSON and "complete". +func (c *Client) CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error) { + if label == "" { + return 0, fmt.Errorf("cable label must not be empty") + } + req := nb.NewWritableCableRequest() + req.SetLabel(label) + customFields := map[string]interface{}{ + "test_data": testDataJSON, + "catalog_status": "complete", + } + if assetTag != "" { + customFields["hw_id"] = assetTag + } + req.SetCustomFields(customFields) + result, _, err := c.api.DcimAPI.DcimCablesCreate(ctx). + WritableCableRequest(*req).Execute() + if err != nil { + return 0, fmt.Errorf("CreateCable %q: %w", label, err) + } + return int64(result.GetId()), nil +} + // DeleteDevice removes a device from NetBox by its internal ID. // Used primarily for test cleanup after CreateDevice integration tests. func (c *Client) DeleteDevice(ctx context.Context, id int64) error { diff --git a/internal/netbox/client_test.go b/internal/netbox/client_test.go index e5d7363..7d7f14a 100644 --- a/internal/netbox/client_test.go +++ b/internal/netbox/client_test.go @@ -2,8 +2,12 @@ package netbox_test import ( "context" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" + "strings" "testing" "git.georgsen.dk/hwlab/internal/netbox" @@ -116,3 +120,78 @@ func TestCreateDeviceLive(t *testing.T) { t.Logf("cleanup warning: DeleteDevice(%d): %v", id, err) } } + +// TestCreateCable_EmptyLabel verifies that CreateCable rejects an empty label +// without making any NetBox API call. +func TestCreateCable_EmptyLabel(t *testing.T) { + c, err := netbox.NewClient("http://localhost:8001", "token") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, err = c.CreateCable(context.Background(), "", "HW-00001", `{"cable_type":0}`) + if err == nil { + t.Fatal("expected error for empty label, got nil") + } + if !strings.Contains(err.Error(), "cable label must not be empty") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestCreateCable_Success verifies that CreateCable returns a positive ID on 201. +// Uses httptest.NewUnstartedServer so srv.URL is accessible inside the handler closure. +func TestCreateCable_Success(t *testing.T) { + var srvURL string + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/dcim/cables/" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + // Cable model requires id, url, and display fields. + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 42, + "url": srvURL + "/api/dcim/cables/42/", + "display": "USB-C 2m Cable", + "label": "USB-C 2m Cable", + }) + return + } + http.NotFound(w, r) + })) + srv.Start() + srvURL = srv.URL + defer srv.Close() + + c, err := netbox.NewClient(srv.URL, "token") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + id, err := c.CreateCable(context.Background(), "USB-C 2m Cable", "HW-00042", `{"cable_type":0}`) + if err != nil { + t.Fatalf("CreateCable: %v", err) + } + if id <= 0 { + t.Fatalf("expected id > 0, got %d", id) + } +} + +// TestCreateCable_UnprocessableEntity verifies that a 422 from NetBox returns an error. +func TestCreateCable_UnprocessableEntity(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/dcim/cables/" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string]string{"label": "This field may not be blank."}) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c, err := netbox.NewClient(srv.URL, "token") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, err = c.CreateCable(context.Background(), "USB-C 2m Cable", "HW-00099", `{}`) + if err == nil { + t.Fatal("expected error on 422, got nil") + } +} diff --git a/internal/netbox/types.go b/internal/netbox/types.go index 3d96c6c..2137fc1 100644 --- a/internal/netbox/types.go +++ b/internal/netbox/types.go @@ -12,6 +12,15 @@ type Device struct { LastUpdated time.Time } +// CableRecord represents a HWLab cable item backed by a NetBox cable record. +type CableRecord struct { + ID int + HWID string + Label string + TestData string // raw JSON blob from test run + CatalogStatus string +} + // CustomFields holds all HWLab-defined NetBox custom field values for a device. // NetBox returns these as map[string]interface{} — we provide typed access. type CustomFields struct {