Files
silo/internal/api/bom_handlers_test.go
Forbes 5f144878d6 feat(api): solver service Phase 3b — server endpoints, job definitions, and result cache
Add server-side solver service module with REST API endpoints, database
schema, job definitions, and runner result caching.

New files:
- migrations/021_solver_results.sql: solver_results table with upsert constraint
- internal/db/solver_results.go: SolverResultRepository (Upsert, GetByItem, GetByItemRevision)
- internal/api/solver_handlers.go: solver API handlers and maybeCacheSolverResult hook
- jobdefs/assembly-solve.yaml: manual solve job definition
- jobdefs/assembly-validate.yaml: auto-validate on revision creation
- jobdefs/assembly-kinematic.yaml: manual kinematic simulation job

Modified:
- internal/config/config.go: SolverConfig struct with max_context_size_mb, default_timeout
- internal/modules/modules.go, loader.go: register solver module (depends on jobs)
- internal/db/jobs.go: ListSolverJobs helper with definition_name prefix filter
- internal/api/handlers.go: wire SolverResultRepository into Server
- internal/api/routes.go: /api/solver/* routes + /api/items/{partNumber}/solver/results
- internal/api/runner_handlers.go: async result cache hook on job completion

API endpoints:
- POST   /api/solver/jobs          — submit solver job (editor)
- GET    /api/solver/jobs          — list solver jobs with filters
- GET    /api/solver/jobs/{id}     — get solver job status
- POST   /api/solver/jobs/{id}/cancel — cancel solver job (editor)
- GET    /api/solver/solvers       — registry of available solvers
- GET    /api/items/{pn}/solver/results — cached results for item

Also fixes pre-existing test compilation errors (missing workflows param
in NewServer calls across 6 test files).
2026-02-20 12:08:34 -06:00

252 lines
7.2 KiB
Go

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())
}
}