test: add comprehensive test suite for backend
Add 56 tests covering the core backend packages: Unit tests (no database required): - internal/partnum: 7 tests for part number generation logic (sequence, format templates, enum validation, constants) - internal/schema: 8 tests for YAML schema loading, property merging, validation, and default application Integration tests (require TEST_DATABASE_URL): - internal/db/items: 10 tests for item CRUD, archive/unarchive, revisions, and thumbnail operations - internal/db/relationships: 10 tests for BOM CRUD, cycle detection, self-reference blocking, where-used, expanded/flat BOM - internal/db/projects: 5 tests for project CRUD and item association - internal/api/bom_handlers: 6 HTTP handler tests for BOM endpoints including flat BOM, cost calculation, add/delete entries - internal/api/items: 5 HTTP handler tests for item CRUD endpoints Infrastructure: - internal/testutil: shared helpers for test DB pool setup, migration runner, and table truncation - internal/db/helpers_test.go: DB wrapper for integration tests - internal/db/db.go: add NewFromPool constructor - Makefile: add test-integration target with default DSN Integration tests skip gracefully when TEST_DATABASE_URL is unset. Dev-mode auth (nil authConfig) used for API handler tests. Fixes: fmt.Errorf Go vet warning in partnum/generator.go Closes #2
This commit is contained in:
238
internal/api/bom_handlers_test.go
Normal file
238
internal/api/bom_handlers_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
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/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)
|
||||
return NewServer(
|
||||
zerolog.Nop(),
|
||||
database,
|
||||
map[string]*schema.Schema{},
|
||||
"", // schemasDir
|
||||
nil, // storage
|
||||
nil, // authService
|
||||
nil, // sessionManager
|
||||
nil, // oidcBackend
|
||||
nil, // authConfig (nil = dev mode)
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
StandardCost: cost,
|
||||
}
|
||||
if err := s.items.Create(context.Background(), item, nil); 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user