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") } }