--- phase: 03-dashboard-intake-ui plan: "02" subsystem: api, netbox tags: [go, inventory, rest-api, interface, tdd, chi] dependency_graph: requires: - internal/netbox.Client (ListDevices + GetDevice) provides: - internal/api/handlers.InventoryHandler - internal/api/handlers.NewInventoryHandler - internal/api/handlers.InventoryNetBoxClient (interface) - internal/api/handlers.InventoryItemResponse - GET /api/inventory route - GET /api/inventory/{id} route affects: - internal/api/router.go (NewRouter signature extended) - cmd/hwlab/main.go (InventoryHandler constructed and wired) tech_stack: added: [] patterns: - interface-for-testability (InventoryNetBoxClient mirrors IntakeNetBoxClient pattern) - nil-safe-slice (nil PhotoURLs coerced to [] before JSON encoding) - tdd-red-green (7 tests written failing, then handler implemented to pass) key_files: created: - internal/api/handlers/inventory.go - internal/api/handlers/inventory_test.go modified: - internal/api/router.go - cmd/hwlab/main.go decisions: - id: INV-01 summary: "InventoryNetBoxClient interface wraps only ListDevices+GetDevice — keeps the handler narrow and fully testable without a live NetBox connection" - id: INV-02 summary: "nil PhotoURLs from netbox.CustomFields coerced to []string{} in deviceToResponse to guarantee JSON encodes as [] not null — prevents frontend null-handling bugs" - id: INV-03 summary: "Hard-coded limit=200 in ListDevices call (T-03-06) — caller cannot request unbounded result sets; pagination deferred to a future plan" metrics: duration: "~10 minutes" completed: "2026-04-10T00:00:00Z" tasks_completed: 2 files_created: 2 files_modified: 2 --- # Phase 3 Plan 02: GET /api/inventory + GET /api/inventory/:id Summary **One-liner:** Inventory list and detail REST endpoints behind a narrow InventoryNetBoxClient interface, with 7 TDD unit tests covering all HTTP status paths and routes wired into the chi router. ## What Was Built ### `internal/api/handlers/inventory.go` - `InventoryNetBoxClient` interface with `ListDevices` and `GetDevice` — `*netbox.Client` satisfies it without any changes - `InventoryHandler` struct and `NewInventoryHandler(nb InventoryNetBoxClient) *InventoryHandler` constructor - `InventoryItemResponse` JSON struct: `id`, `name`, `asset_tag`, `hw_id`, `catalog_status`, `product_url`, `firmware_version`, `test_date`, `test_data`, `ai_notes`, `photo_urls` - `deviceToResponse` helper: maps `netbox.Device` → `InventoryItemResponse`, coerces nil `PhotoURLs` to `[]string{}` - `ListInventory`: `GET /api/inventory` — calls `ListDevices(ctx, 200)`, returns 200 JSON array or 502 on error - `GetInventoryItem`: `GET /api/inventory/{id}` — validates ID with `strconv.Atoi` (400 on failure), calls `GetDevice`, returns 200/404/502 ### `internal/api/handlers/inventory_test.go` Seven TDD unit tests using `mockInventoryNetBoxClient`: | Test | Scenario | Expected | |------|----------|----------| | TestListInventoryEmpty | mock returns [] | 200, empty JSON array | | TestListInventoryItems | mock returns 2 devices | 200, 2-item array with hw_id/name/catalog_status/asset_tag/photo_urls | | TestListInventoryNetBoxError | mock returns error | 502, {"error": "..."} | | TestGetInventoryItemFound | mock returns device id=42 | 200, device JSON with correct fields | | TestGetInventoryItemNotFound | mock returns "404 not found" error | 404, {"error": "item not found"} | | TestGetInventoryItemInvalidID | path param = "abc" | 400, {"error": "id must be an integer"} | | TestGetInventoryItemNetBoxError | mock returns server error | 502, {"error": "..."} | ### `internal/api/router.go` - `NewRouter` signature extended: `NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler` - Two new routes in `/api` group: `GET /inventory` and `GET /inventory/{id}` ### `cmd/hwlab/main.go` - `inventoryHandler := handlers.NewInventoryHandler(nbClient)` constructed after NetBox client - Passed as third argument to `api.NewRouter` ## Deviations from Plan None — plan executed exactly as written. ## Known Stubs None — both endpoints call real `netbox.Client` methods (`ListDevices`, `GetDevice`). No mock or placeholder data flows to responses in production. ## Threat Surface Coverage All four threats from the plan's threat register are addressed: | Threat | Mitigation | Where | |--------|------------|-------| | T-03-04: URL param tampering | `strconv.Atoi` rejects non-integer IDs → 400 | inventory.go:GetInventoryItem | | T-03-05: Error message info disclosure | Errors describe NetBox connectivity only; no credentials or paths exposed | inventory.go error paths | | T-03-06: DoS via unbounded list | Hard-coded `limit=200` in ListDevices call | inventory.go:ListInventory | | T-03-07: Unauthenticated endpoints | Accepted for Phase 3 homelab single-operator; Phase 4+ can add auth middleware | router.go | ## Self-Check Files created: - internal/api/handlers/inventory.go: FOUND - internal/api/handlers/inventory_test.go: FOUND Commits: - b0b6153: feat(03-02): InventoryHandler with list+detail endpoints and 7 unit tests - 743611f: feat(03-02): wire GET /api/inventory and GET /api/inventory/{id} routes `go build ./...`: PASS `go test ./internal/api/handlers/... -run "TestListInventory|TestGetInventory" -v`: 7/7 PASS `go test ./...`: all packages PASS `grep "inventory" internal/api/router.go`: both GET routes PRESENT `grep "NewInventoryHandler" cmd/hwlab/main.go`: PRESENT ## Self-Check: PASSED