feat(03-02): InventoryHandler with list+detail endpoints and 7 unit tests

- 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
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:14:43 +00:00
parent 206d6886aa
commit b0b6153b24
2 changed files with 389 additions and 0 deletions

View file

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

View file

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