diff --git a/internal/api/handlers/inventory.go b/internal/api/handlers/inventory.go new file mode 100644 index 0000000..44ae634 --- /dev/null +++ b/internal/api/handlers/inventory.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "git.georgsen.dk/hwlab/internal/netbox" +) + +// InventoryNetBoxClient is the narrow interface the inventory handler needs. +// *netbox.Client satisfies this interface. +type InventoryNetBoxClient interface { + ListDevices(ctx context.Context, limit int) ([]netbox.Device, error) + GetDevice(ctx context.Context, id int) (*netbox.Device, error) +} + +// InventoryHandler handles GET /api/inventory and GET /api/inventory/{id}. +type InventoryHandler struct { + nb InventoryNetBoxClient +} + +// NewInventoryHandler creates an InventoryHandler. +func NewInventoryHandler(nb InventoryNetBoxClient) *InventoryHandler { + return &InventoryHandler{nb: nb} +} + +// InventoryItemResponse is the JSON shape the frontend consumes. +type InventoryItemResponse struct { + ID int `json:"id"` + Name string `json:"name"` + AssetTag string `json:"asset_tag"` + HWID string `json:"hw_id"` + CatalogStatus string `json:"catalog_status"` + ProductURL string `json:"product_url,omitempty"` + Firmware string `json:"firmware_version,omitempty"` + TestDate string `json:"test_date,omitempty"` + TestData string `json:"test_data,omitempty"` + AINotes string `json:"ai_notes,omitempty"` + PhotoURLs []string `json:"photo_urls"` +} + +func deviceToResponse(d netbox.Device) InventoryItemResponse { + urls := d.CustomFields.PhotoURLs + if urls == nil { + urls = []string{} + } + return InventoryItemResponse{ + ID: d.ID, + Name: d.Name, + AssetTag: d.AssetTag, + HWID: d.CustomFields.HWID, + CatalogStatus: d.CustomFields.CatalogStatus, + ProductURL: d.CustomFields.ProductURL, + Firmware: d.CustomFields.FirmwareVersion, + TestDate: d.CustomFields.TestDate, + TestData: d.CustomFields.TestData, + AINotes: d.CustomFields.AINotes, + PhotoURLs: urls, + } +} + +// ListInventory handles GET /api/inventory — returns up to 200 items. +func (h *InventoryHandler) ListInventory(w http.ResponseWriter, r *http.Request) { + devices, err := h.nb.ListDevices(r.Context(), 200) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)}) + return + } + items := make([]InventoryItemResponse, 0, len(devices)) + for _, d := range devices { + items = append(items, deviceToResponse(d)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(items) +} + +// GetInventoryItem handles GET /api/inventory/{id}. +func (h *InventoryHandler) GetInventoryItem(w http.ResponseWriter, r *http.Request) { + rawID := chi.URLParam(r, "id") + id, err := strconv.Atoi(rawID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "id must be an integer"}) + return + } + + device, err := h.nb.GetDevice(r.Context(), id) + if err != nil { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(err.Error(), "404") || strings.Contains(strings.ToLower(err.Error()), "not found") { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "item not found"}) + return + } + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)}) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(deviceToResponse(*device)) +} diff --git a/internal/api/handlers/inventory_test.go b/internal/api/handlers/inventory_test.go new file mode 100644 index 0000000..7702874 --- /dev/null +++ b/internal/api/handlers/inventory_test.go @@ -0,0 +1,279 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.georgsen.dk/hwlab/internal/netbox" +) + +// mockInventoryNetBoxClient implements InventoryNetBoxClient for unit tests. +type mockInventoryNetBoxClient struct { + listDevices func(ctx context.Context, limit int) ([]netbox.Device, error) + getDevice func(ctx context.Context, id int) (*netbox.Device, error) +} + +func (m *mockInventoryNetBoxClient) ListDevices(ctx context.Context, limit int) ([]netbox.Device, error) { + return m.listDevices(ctx, limit) +} + +func (m *mockInventoryNetBoxClient) GetDevice(ctx context.Context, id int) (*netbox.Device, error) { + return m.getDevice(ctx, id) +} + +// newInventoryRouter wraps the handler in a chi router with the {id} param registered, +// so chi.URLParam works in tests. +func newInventoryRouter(h *InventoryHandler) http.Handler { + r := chi.NewRouter() + r.Get("/api/inventory", h.ListInventory) + r.Get("/api/inventory/{id}", h.GetInventoryItem) + return r +} + +// TestListInventoryEmpty: mock returns [], handler returns 200 with JSON `[]`. +func TestListInventoryEmpty(t *testing.T) { + mock := &mockInventoryNetBoxClient{ + listDevices: func(_ context.Context, _ int) ([]netbox.Device, error) { + return []netbox.Device{}, nil + }, + } + h := NewInventoryHandler(mock) + router := newInventoryRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/inventory", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var items []InventoryItemResponse + if err := json.NewDecoder(w.Body).Decode(&items); err != nil { + t.Fatalf("decode error: %v", err) + } + if len(items) != 0 { + t.Errorf("expected empty array, got %d items", len(items)) + } +} + +// TestListInventoryItems: mock returns 2 devices, handler returns 200 with JSON array of 2 items. +func TestListInventoryItems(t *testing.T) { + mock := &mockInventoryNetBoxClient{ + listDevices: func(_ context.Context, _ int) ([]netbox.Device, error) { + return []netbox.Device{ + { + ID: 1, + Name: "Raspberry Pi 4", + AssetTag: "HW-00001", + CustomFields: netbox.CustomFields{ + HWID: "HW-00001", + CatalogStatus: "indexed", + PhotoURLs: []string{"http://example.com/photo1.jpg"}, + }, + }, + { + ID: 2, + Name: "Cisco Switch", + AssetTag: "HW-00002", + CustomFields: netbox.CustomFields{ + HWID: "HW-00002", + CatalogStatus: "needs_research", + PhotoURLs: nil, + }, + }, + }, nil + }, + } + h := NewInventoryHandler(mock) + router := newInventoryRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/inventory", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var items []InventoryItemResponse + if err := json.NewDecoder(w.Body).Decode(&items); err != nil { + t.Fatalf("decode error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + // Check first item fields + if items[0].HWID != "HW-00001" { + t.Errorf("item[0] hw_id: expected HW-00001, got %s", items[0].HWID) + } + if items[0].Name != "Raspberry Pi 4" { + t.Errorf("item[0] name: expected 'Raspberry Pi 4', got %s", items[0].Name) + } + if items[0].CatalogStatus != "indexed" { + t.Errorf("item[0] catalog_status: expected indexed, got %s", items[0].CatalogStatus) + } + if items[0].AssetTag != "HW-00001" { + t.Errorf("item[0] asset_tag: expected HW-00001, got %s", items[0].AssetTag) + } + if len(items[0].PhotoURLs) != 1 { + t.Errorf("item[0] photo_urls: expected 1, got %d", len(items[0].PhotoURLs)) + } + + // Second item — nil PhotoURLs should become empty slice in JSON + if items[1].PhotoURLs == nil { + t.Errorf("item[1] photo_urls: expected non-nil empty slice, got nil") + } +} + +// TestListInventoryNetBoxError: mock returns error, handler returns 502. +func TestListInventoryNetBoxError(t *testing.T) { + mock := &mockInventoryNetBoxClient{ + listDevices: func(_ context.Context, _ int) ([]netbox.Device, error) { + return nil, errors.New("connection refused") + }, + } + h := NewInventoryHandler(mock) + router := newInventoryRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/inventory", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", w.Code) + } + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode error: %v", err) + } + if body["error"] == "" { + t.Error("expected non-empty error field") + } +} + +// TestGetInventoryItemFound: mock returns device with id=42, GET /api/inventory/42 returns 200. +func TestGetInventoryItemFound(t *testing.T) { + mock := &mockInventoryNetBoxClient{ + getDevice: func(_ context.Context, id int) (*netbox.Device, error) { + if id != 42 { + t.Errorf("expected id=42, got %d", id) + } + return &netbox.Device{ + ID: 42, + Name: "Test Device", + AssetTag: "HW-00042", + CustomFields: netbox.CustomFields{ + HWID: "HW-00042", + CatalogStatus: "indexed", + AINotes: "some notes", + PhotoURLs: []string{}, + }, + }, nil + }, + } + h := NewInventoryHandler(mock) + router := newInventoryRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/inventory/42", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var item InventoryItemResponse + if err := json.NewDecoder(w.Body).Decode(&item); err != nil { + t.Fatalf("decode error: %v", err) + } + if item.ID != 42 { + t.Errorf("expected id=42, got %d", item.ID) + } + if item.HWID != "HW-00042" { + t.Errorf("expected hw_id=HW-00042, got %s", item.HWID) + } +} + +// TestGetInventoryItemNotFound: mock returns error containing "404", handler returns 404. +func TestGetInventoryItemNotFound(t *testing.T) { + mock := &mockInventoryNetBoxClient{ + getDevice: func(_ context.Context, _ int) (*netbox.Device, error) { + return nil, errors.New("get device 99: 404 not found") + }, + } + h := NewInventoryHandler(mock) + router := newInventoryRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/inventory/99", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode error: %v", err) + } + if body["error"] == "" { + t.Error("expected non-empty error field") + } +} + +// TestGetInventoryItemInvalidID: GET /api/inventory/abc returns 400. +func TestGetInventoryItemInvalidID(t *testing.T) { + mock := &mockInventoryNetBoxClient{ + getDevice: func(_ context.Context, _ int) (*netbox.Device, error) { + t.Error("GetDevice should not be called for invalid ID") + return nil, nil + }, + } + h := NewInventoryHandler(mock) + router := newInventoryRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/inventory/abc", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode error: %v", err) + } + if body["error"] == "" { + t.Error("expected non-empty error field") + } +} + +// TestGetInventoryItemNetBoxError: mock returns server error, handler returns 502. +func TestGetInventoryItemNetBoxError(t *testing.T) { + mock := &mockInventoryNetBoxClient{ + getDevice: func(_ context.Context, _ int) (*netbox.Device, error) { + return nil, errors.New("internal server error") + }, + } + h := NewInventoryHandler(mock) + router := newInventoryRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/inventory/5", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", w.Code) + } + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode error: %v", err) + } + if body["error"] == "" { + t.Error("expected non-empty error field") + } +}