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).
213 lines
6.0 KiB
Go
213 lines
6.0 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"
|
|
)
|
|
|
|
// newAuthTestServer creates a Server with a real auth service (for token tests).
|
|
func newAuthTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
pool := testutil.MustConnectTestPool(t)
|
|
database := db.NewFromPool(pool)
|
|
users := db.NewUserRepository(database)
|
|
tokens := db.NewTokenRepository(database)
|
|
authSvc := auth.NewService(zerolog.Nop(), users, tokens)
|
|
broker := NewBroker(zerolog.Nop())
|
|
state := NewServerState(zerolog.Nop(), nil, broker)
|
|
return NewServer(
|
|
zerolog.Nop(),
|
|
database,
|
|
map[string]*schema.Schema{},
|
|
"", // schemasDir
|
|
nil, // storage
|
|
authSvc, // authService
|
|
nil, // sessionManager
|
|
nil, // oidcBackend
|
|
nil, // authConfig
|
|
broker,
|
|
state,
|
|
nil, // jobDefs
|
|
"", // jobDefsDir
|
|
modules.NewRegistry(), // modules
|
|
nil, // cfg
|
|
nil, // workflows
|
|
)
|
|
}
|
|
|
|
// ensureTestUser creates a user in the DB and returns their ID.
|
|
func ensureTestUser(t *testing.T, s *Server, username string) string {
|
|
t.Helper()
|
|
u := &db.User{
|
|
Username: username,
|
|
DisplayName: "Test " + username,
|
|
Email: username + "@test.local",
|
|
AuthSource: "local",
|
|
Role: "admin",
|
|
}
|
|
users := db.NewUserRepository(s.db)
|
|
if err := users.Upsert(context.Background(), u); err != nil {
|
|
t.Fatalf("upserting user: %v", err)
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
func newAuthRouter(s *Server) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Get("/api/auth/me", s.HandleGetCurrentUser)
|
|
r.Post("/api/auth/tokens", s.HandleCreateToken)
|
|
r.Get("/api/auth/tokens", s.HandleListTokens)
|
|
r.Delete("/api/auth/tokens/{id}", s.HandleRevokeToken)
|
|
r.Get("/api/auth/config", s.HandleAuthConfig)
|
|
return r
|
|
}
|
|
|
|
func TestHandleGetCurrentUser(t *testing.T) {
|
|
s := newTestServer(t)
|
|
router := newAuthRouter(s)
|
|
|
|
req := authRequest(httptest.NewRequest("GET", "/api/auth/me", 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 map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if resp["username"] != "testadmin" {
|
|
t.Errorf("username: got %v, want %q", resp["username"], "testadmin")
|
|
}
|
|
if resp["role"] != "admin" {
|
|
t.Errorf("role: got %v, want %q", resp["role"], "admin")
|
|
}
|
|
}
|
|
|
|
func TestHandleGetCurrentUserUnauth(t *testing.T) {
|
|
s := newTestServer(t)
|
|
router := newAuthRouter(s)
|
|
|
|
// No auth context
|
|
req := httptest.NewRequest("GET", "/api/auth/me", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestHandleAuthConfig(t *testing.T) {
|
|
s := newTestServer(t)
|
|
router := newAuthRouter(s)
|
|
|
|
req := httptest.NewRequest("GET", "/api/auth/config", 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 map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
// With nil oidc and nil authConfig, both should be false
|
|
if resp["oidc_enabled"] != false {
|
|
t.Errorf("oidc_enabled: got %v, want false", resp["oidc_enabled"])
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateAndListTokens(t *testing.T) {
|
|
s := newAuthTestServer(t)
|
|
router := newAuthRouter(s)
|
|
|
|
// Create a user in the DB so token generation can associate
|
|
userID := ensureTestUser(t, s, "tokenuser")
|
|
|
|
// Inject user with the DB-assigned ID
|
|
u := &auth.User{
|
|
ID: userID,
|
|
Username: "tokenuser",
|
|
DisplayName: "Test tokenuser",
|
|
Role: auth.RoleAdmin,
|
|
AuthSource: "local",
|
|
}
|
|
|
|
// Create token
|
|
body := `{"name":"test-token"}`
|
|
req := httptest.NewRequest("POST", "/api/auth/tokens", strings.NewReader(body))
|
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create token status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
|
|
}
|
|
|
|
var createResp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil {
|
|
t.Fatalf("decoding create response: %v", err)
|
|
}
|
|
if createResp["token"] == nil || createResp["token"] == "" {
|
|
t.Error("expected token in response")
|
|
}
|
|
tokenID, _ := createResp["id"].(string)
|
|
|
|
// List tokens
|
|
req = httptest.NewRequest("GET", "/api/auth/tokens", nil)
|
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
|
w = httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list tokens status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
|
|
var tokens []map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &tokens); err != nil {
|
|
t.Fatalf("decoding list response: %v", err)
|
|
}
|
|
if len(tokens) != 1 {
|
|
t.Errorf("expected 1 token, got %d", len(tokens))
|
|
}
|
|
|
|
// Revoke token
|
|
req = httptest.NewRequest("DELETE", "/api/auth/tokens/"+tokenID, nil)
|
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
|
w = httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("revoke token status: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String())
|
|
}
|
|
|
|
// List again — should be empty
|
|
req = httptest.NewRequest("GET", "/api/auth/tokens", nil)
|
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
|
w = httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
json.Unmarshal(w.Body.Bytes(), &tokens)
|
|
if len(tokens) != 0 {
|
|
t.Errorf("expected 0 tokens after revoke, got %d", len(tokens))
|
|
}
|
|
}
|