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
324 lines
9.6 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|
|
}
|