Files
silo/internal/api/csv_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

261 lines
7.2 KiB
Go

package api
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/modules"
"github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/testutil"
"github.com/rs/zerolog"
"github.com/kindredsystems/silo/internal/db"
)
// findSchemasDir walks upward to find the project root and returns
// the path to the schemas/ directory.
func findSchemasDir(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getting working directory: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return filepath.Join(dir, "schemas")
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find project root")
}
dir = parent
}
}
// newTestServerWithSchemas creates a Server backed by a real test DB with schemas loaded.
func newTestServerWithSchemas(t *testing.T) *Server {
t.Helper()
pool := testutil.MustConnectTestPool(t)
database := db.NewFromPool(pool)
broker := NewBroker(zerolog.Nop())
state := NewServerState(zerolog.Nop(), nil, broker)
schemasDir := findSchemasDir(t)
schemas, err := schema.LoadAll(schemasDir)
if err != nil {
t.Fatalf("loading schemas: %v", err)
}
return NewServer(
zerolog.Nop(),
database,
schemas,
schemasDir,
nil, // storage
nil, // authService
nil, // sessionManager
nil, // oidcBackend
nil, // authConfig
broker,
state,
nil, // jobDefs
"", // jobDefsDir
modules.NewRegistry(), // modules
nil, // cfg
nil, // workflows
)
}
func newCSVRouter(s *Server) http.Handler {
r := chi.NewRouter()
r.Get("/api/items/export.csv", s.HandleExportCSV)
r.Get("/api/items/template.csv", s.HandleCSVTemplate)
r.Post("/api/items/import", s.HandleImportCSV)
r.Get("/api/items/{partNumber}/bom/export.csv", s.HandleExportBOMCSV)
return r
}
func TestHandleExportCSVEmpty(t *testing.T) {
s := newTestServer(t)
router := newCSVRouter(s)
req := httptest.NewRequest("GET", "/api/items/export.csv", 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())
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/csv") {
t.Errorf("content-type: got %q, want text/csv", ct)
}
// Should have header row only
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if len(lines) != 1 {
t.Errorf("expected 1 line (header only), got %d", len(lines))
}
}
func TestHandleExportCSVWithItems(t *testing.T) {
s := newTestServer(t)
router := newCSVRouter(s)
createItemDirect(t, s, "CSV-001", "first csv item", nil)
createItemDirect(t, s, "CSV-002", "second csv item", nil)
req := httptest.NewRequest("GET", "/api/items/export.csv", 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())
}
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
// header + 2 data rows
if len(lines) != 3 {
t.Errorf("expected 3 lines (header + 2 rows), got %d", len(lines))
}
}
func TestHandleCSVTemplate(t *testing.T) {
s := newTestServerWithSchemas(t)
router := newCSVRouter(s)
req := httptest.NewRequest("GET", "/api/items/template.csv?schema=kindred-rd", 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())
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/csv") {
t.Errorf("content-type: got %q, want text/csv", ct)
}
// Should contain at least "category" and "description" columns
header := strings.Split(strings.TrimSpace(w.Body.String()), "\n")[0]
if !strings.Contains(header, "category") {
t.Error("template header missing 'category' column")
}
if !strings.Contains(header, "description") {
t.Error("template header missing 'description' column")
}
}
// csvMultipartBody creates a multipart form body with a CSV file and optional form fields.
func csvMultipartBody(t *testing.T, csvContent string, fields map[string]string) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "import.csv")
if err != nil {
t.Fatalf("creating form file: %v", err)
}
io.WriteString(part, csvContent)
for k, v := range fields {
writer.WriteField(k, v)
}
writer.Close()
return body, writer.FormDataContentType()
}
func TestHandleImportCSVDryRun(t *testing.T) {
s := newTestServerWithSchemas(t)
router := newCSVRouter(s)
csv := "category,description\nF01,Dry run widget\nF01,Dry run gadget\n"
body, contentType := csvMultipartBody(t, csv, map[string]string{"dry_run": "true"})
req := authRequest(httptest.NewRequest("POST", "/api/items/import", body))
req.Header.Set("Content-Type", contentType)
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 result CSVImportResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("decoding response: %v", err)
}
if result.TotalRows != 2 {
t.Errorf("total_rows: got %d, want 2", result.TotalRows)
}
// Dry run should not create items
if len(result.CreatedItems) != 0 {
t.Errorf("dry run should not create items, got %d", len(result.CreatedItems))
}
}
func TestHandleImportCSVCommit(t *testing.T) {
s := newTestServerWithSchemas(t)
router := newCSVRouter(s)
csv := "category,description\nF01,Committed widget\n"
body, contentType := csvMultipartBody(t, csv, nil)
req := authRequest(httptest.NewRequest("POST", "/api/items/import", body))
req.Header.Set("Content-Type", contentType)
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 result CSVImportResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("decoding response: %v", err)
}
if result.SuccessCount != 1 {
t.Errorf("success_count: got %d, want 1", result.SuccessCount)
}
if len(result.CreatedItems) != 1 {
t.Errorf("created_items: got %d, want 1", len(result.CreatedItems))
}
}
func TestHandleExportBOMCSV(t *testing.T) {
s := newTestServer(t)
router := newCSVRouter(s)
createItemDirect(t, s, "BOMCSV-P", "parent", nil)
createItemDirect(t, s, "BOMCSV-C", "child", nil)
addBOMDirect(t, s, "BOMCSV-P", "BOMCSV-C", 3)
req := httptest.NewRequest("GET", "/api/items/BOMCSV-P/bom/export.csv", 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())
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/csv") {
t.Errorf("content-type: got %q, want text/csv", ct)
}
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
// header + 1 BOM entry
if len(lines) != 2 {
t.Errorf("expected 2 lines (header + 1 row), got %d", len(lines))
}
}