package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/auth" "github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/testutil" "github.com/rs/zerolog" ) // newTestServer creates a Server backed by a real test DB with no auth. func newTestServer(t *testing.T) *Server { t.Helper() pool := testutil.MustConnectTestPool(t) database := db.NewFromPool(pool) broker := NewBroker(zerolog.Nop()) state := NewServerState(zerolog.Nop(), nil, broker) return NewServer( zerolog.Nop(), database, map[string]*schema.Schema{}, "", // schemasDir nil, // storage nil, // authService nil, // sessionManager nil, // oidcBackend nil, // authConfig (nil = dev mode) broker, state, nil, // jobDefs "", // jobDefsDir modules.NewRegistry(), // modules nil, // cfg nil, // workflows ) } // newTestRouter creates a chi router with BOM routes for testing. func newTestRouter(s *Server) http.Handler { r := chi.NewRouter() r.Route("/api/items/{partNumber}", func(r chi.Router) { r.Get("/bom", s.HandleGetBOM) r.Get("/bom/flat", s.HandleGetFlatBOM) r.Get("/bom/cost", s.HandleGetBOMCost) r.Post("/bom", s.HandleAddBOMEntry) r.Delete("/bom/{childPartNumber}", s.HandleDeleteBOMEntry) }) return r } // createItemDirect creates an item directly via the DB for test setup. func createItemDirect(t *testing.T, s *Server, pn, desc string, cost *float64) { t.Helper() item := &db.Item{ PartNumber: pn, ItemType: "part", Description: desc, } var props map[string]any if cost != nil { props = map[string]any{"standard_cost": *cost} } if err := s.items.Create(context.Background(), item, props); err != nil { t.Fatalf("creating item %s: %v", pn, err) } } // authRequest returns a copy of the request with an admin user in context. func authRequest(r *http.Request) *http.Request { u := &auth.User{ ID: "test-admin-id", Username: "testadmin", DisplayName: "Test Admin", Role: auth.RoleAdmin, AuthSource: "local", } return r.WithContext(auth.ContextWithUser(r.Context(), u)) } // addBOMDirect adds a BOM relationship directly via the DB. func addBOMDirect(t *testing.T, s *Server, parentPN, childPN string, qty float64) { t.Helper() ctx := context.Background() parent, _ := s.items.GetByPartNumber(ctx, parentPN) child, _ := s.items.GetByPartNumber(ctx, childPN) if parent == nil || child == nil { t.Fatalf("parent or child not found: %s, %s", parentPN, childPN) } rel := &db.Relationship{ ParentItemID: parent.ID, ChildItemID: child.ID, RelType: "component", Quantity: &qty, } if err := s.relationships.Create(ctx, rel); err != nil { t.Fatalf("adding BOM %s→%s: %v", parentPN, childPN, err) } } func TestHandleGetBOM(t *testing.T) { s := newTestServer(t) router := newTestRouter(s) createItemDirect(t, s, "API-P1", "parent", nil) createItemDirect(t, s, "API-C1", "child1", nil) createItemDirect(t, s, "API-C2", "child2", nil) addBOMDirect(t, s, "API-P1", "API-C1", 2) addBOMDirect(t, s, "API-P1", "API-C2", 5) req := httptest.NewRequest("GET", "/api/items/API-P1/bom", 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 entries []BOMEntryResponse if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil { t.Fatalf("decoding response: %v", err) } if len(entries) != 2 { t.Errorf("expected 2 BOM entries, got %d", len(entries)) } } func TestHandleGetFlatBOM(t *testing.T) { s := newTestServer(t) router := newTestRouter(s) // A(qty 1) → B(qty 2) → X(qty 3) = X total 6 createItemDirect(t, s, "FA", "assembly A", nil) createItemDirect(t, s, "FB", "sub B", nil) createItemDirect(t, s, "FX", "leaf X", nil) addBOMDirect(t, s, "FA", "FB", 2) addBOMDirect(t, s, "FB", "FX", 3) req := httptest.NewRequest("GET", "/api/items/FA/bom/flat", 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 resp FlatBOMResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decoding response: %v", err) } if len(resp.FlatBOM) != 1 { t.Fatalf("expected 1 leaf part, got %d", len(resp.FlatBOM)) } if resp.FlatBOM[0].TotalQuantity != 6 { t.Errorf("total quantity: got %.1f, want 6.0", resp.FlatBOM[0].TotalQuantity) } } func TestHandleGetBOMCost(t *testing.T) { s := newTestServer(t) router := newTestRouter(s) cost10 := 10.0 cost5 := 5.0 createItemDirect(t, s, "CA", "assembly", nil) createItemDirect(t, s, "CX", "part X", &cost10) createItemDirect(t, s, "CY", "part Y", &cost5) addBOMDirect(t, s, "CA", "CX", 3) addBOMDirect(t, s, "CA", "CY", 2) req := httptest.NewRequest("GET", "/api/items/CA/bom/cost", 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 resp CostResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decoding response: %v", err) } // 3*10 + 2*5 = 40 if resp.TotalCost != 40 { t.Errorf("total cost: got %.2f, want 40.00", resp.TotalCost) } } func TestHandleGetFlatBOMNotFound(t *testing.T) { s := newTestServer(t) router := newTestRouter(s) req := httptest.NewRequest("GET", "/api/items/NONEXISTENT/bom/flat", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) } } func TestHandleAddBOMEntry(t *testing.T) { s := newTestServer(t) router := newTestRouter(s) createItemDirect(t, s, "ADD-P", "parent", nil) createItemDirect(t, s, "ADD-C", "child", nil) body := `{"child_part_number":"ADD-C","rel_type":"component","quantity":7}` req := authRequest(httptest.NewRequest("POST", "/api/items/ADD-P/bom", strings.NewReader(body))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String()) } var entry BOMEntryResponse if err := json.Unmarshal(w.Body.Bytes(), &entry); err != nil { t.Fatalf("decoding response: %v", err) } if entry.ChildPartNumber != "ADD-C" { t.Errorf("child_part_number: got %q, want %q", entry.ChildPartNumber, "ADD-C") } } func TestHandleDeleteBOMEntry(t *testing.T) { s := newTestServer(t) router := newTestRouter(s) createItemDirect(t, s, "DEL-P", "parent", nil) createItemDirect(t, s, "DEL-C", "child", nil) addBOMDirect(t, s, "DEL-P", "DEL-C", 1) req := authRequest(httptest.NewRequest("DELETE", "/api/items/DEL-P/bom/DEL-C", nil)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNoContent { t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String()) } }