- InventoryNetBoxClient interface (ListDevices + GetDevice) for testability - ListInventory returns 200 JSON array (limit=200, 502 on NetBox error) - GetInventoryItem returns 200/404/400/502 based on ID validity and NetBox response - deviceToResponse maps netbox.Device to InventoryItemResponse (nil PhotoURLs → []) - 7 TDD tests: empty list, 2-item list, NetBox error, found, not found, invalid ID, server error
279 lines
8 KiB
Go
279 lines
8 KiB
Go
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")
|
|
}
|
|
}
|