Files
silo/internal/api/location_handlers_test.go
Forbes 4ef912cf4b feat: location hierarchy CRUD API
Add LocationRepository with CRUD operations, hierarchy traversal
(children, subtree by path prefix), and inventory-safe deletion.

Endpoints:
  GET    /api/locations          — list all or ?tree={path} for subtree
  POST   /api/locations          — create (auto-resolves parent_id, depth)
  GET    /api/locations/{path..} — get by hierarchical path
  PUT    /api/locations/{path..} — update name, type, metadata
  DELETE /api/locations/{path..} — delete (rejects if inventory exists)

Uses chi wildcard routes to support multi-segment paths like
/api/locations/lab/shelf-a/bin-3.

Includes 10 handler integration tests covering CRUD, nesting,
validation, duplicates, tree queries, and delete-not-found.

Closes #81
2026-02-15 03:15:54 -06:00

324 lines
9.6 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func newLocationRouter(s *Server) http.Handler {
r := chi.NewRouter()
r.Get("/api/locations", s.HandleListLocations)
r.Post("/api/locations", s.HandleCreateLocation)
r.Get("/api/locations/*", s.HandleGetLocation)
r.Put("/api/locations/*", s.HandleUpdateLocation)
r.Delete("/api/locations/*", s.HandleDeleteLocation)
return r
}
func TestHandleListLocationsEmpty(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
req := httptest.NewRequest("GET", "/api/locations", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
var locs []LocationResponse
if err := json.Unmarshal(w.Body.Bytes(), &locs); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(locs) != 0 {
t.Fatalf("expected 0 locations, got %d", len(locs))
}
}
func TestHandleCreateAndGetLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create root location
body := `{"path": "lab", "name": "Lab", "location_type": "building"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
}
var created LocationResponse
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding create response: %v", err)
}
if created.Path != "lab" {
t.Errorf("path: got %q, want %q", created.Path, "lab")
}
if created.Name != "Lab" {
t.Errorf("name: got %q, want %q", created.Name, "Lab")
}
if created.Depth != 0 {
t.Errorf("depth: got %d, want 0", created.Depth)
}
if created.ID == "" {
t.Error("expected ID to be set")
}
// Get by path
req = httptest.NewRequest("GET", "/api/locations/lab", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
var got LocationResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding get response: %v", err)
}
if got.ID != created.ID {
t.Errorf("ID mismatch: got %q, want %q", got.ID, created.ID)
}
}
func TestHandleCreateNestedLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create root
body := `{"path": "warehouse", "name": "Warehouse", "location_type": "building"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create root: got %d; body: %s", w.Code, w.Body.String())
}
// Create child
body = `{"path": "warehouse/shelf-a", "name": "Shelf A", "location_type": "shelf"}`
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create child: got %d; body: %s", w.Code, w.Body.String())
}
var child LocationResponse
json.Unmarshal(w.Body.Bytes(), &child)
if child.Depth != 1 {
t.Errorf("child depth: got %d, want 1", child.Depth)
}
if child.ParentID == nil {
t.Error("expected parent_id to be set")
}
// Create grandchild
body = `{"path": "warehouse/shelf-a/bin-3", "name": "Bin 3", "location_type": "bin"}`
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create grandchild: got %d; body: %s", w.Code, w.Body.String())
}
var gc LocationResponse
json.Unmarshal(w.Body.Bytes(), &gc)
if gc.Depth != 2 {
t.Errorf("grandchild depth: got %d, want 2", gc.Depth)
}
// Get nested path
req = httptest.NewRequest("GET", "/api/locations/warehouse/shelf-a/bin-3", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get nested: got %d; body: %s", w.Code, w.Body.String())
}
}
func TestHandleCreateLocationMissingParent(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
body := `{"path": "nonexistent/child", "name": "Child", "location_type": "shelf"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestHandleUpdateLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create
body := `{"path": "office", "name": "Office", "location_type": "room"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
}
// Update
body = `{"name": "Main Office", "location_type": "building", "metadata": {"floor": 2}}`
req = httptest.NewRequest("PUT", "/api/locations/office", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update: got %d; body: %s", w.Code, w.Body.String())
}
var updated LocationResponse
json.Unmarshal(w.Body.Bytes(), &updated)
if updated.Name != "Main Office" {
t.Errorf("name: got %q, want %q", updated.Name, "Main Office")
}
if updated.LocationType != "building" {
t.Errorf("type: got %q, want %q", updated.LocationType, "building")
}
}
func TestHandleDeleteLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create
body := `{"path": "temp", "name": "Temp", "location_type": "area"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
}
// Delete
req = httptest.NewRequest("DELETE", "/api/locations/temp", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("delete: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String())
}
// Verify gone
req = httptest.NewRequest("GET", "/api/locations/temp", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("get after delete: got %d, want %d", w.Code, http.StatusNotFound)
}
}
func TestHandleDeleteLocationNotFound(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
req := httptest.NewRequest("DELETE", "/api/locations/doesnotexist", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("delete missing: got %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String())
}
}
func TestHandleListLocationsTree(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create hierarchy
for _, loc := range []string{
`{"path": "site", "name": "Site", "location_type": "site"}`,
`{"path": "site/bldg", "name": "Building", "location_type": "building"}`,
`{"path": "site/bldg/room1", "name": "Room 1", "location_type": "room"}`,
`{"path": "other", "name": "Other", "location_type": "site"}`,
} {
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(loc))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
}
}
// List tree under "site"
req := httptest.NewRequest("GET", "/api/locations?tree=site", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("tree: got %d; body: %s", w.Code, w.Body.String())
}
var locs []LocationResponse
json.Unmarshal(w.Body.Bytes(), &locs)
if len(locs) != 3 {
t.Fatalf("tree count: got %d, want 3 (site + bldg + room1)", len(locs))
}
// Full list should have 4
req = httptest.NewRequest("GET", "/api/locations", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &locs)
if len(locs) != 4 {
t.Fatalf("full list: got %d, want 4", len(locs))
}
}
func TestHandleCreateLocationDuplicate(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
body := `{"path": "dup", "name": "Dup", "location_type": "area"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("first create: got %d; body: %s", w.Code, w.Body.String())
}
// Duplicate
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("duplicate: got %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
}
}
func TestHandleCreateLocationValidation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
tests := []struct {
name string
body string
}{
{"missing path", `{"name": "X", "location_type": "area"}`},
{"missing name", `{"path": "x", "location_type": "area"}`},
{"missing type", `{"path": "x", "name": "X"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(tc.body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d, want 400; body: %s", w.Code, w.Body.String())
}
})
}
}