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).
287 lines
8.7 KiB
Go
287 lines
8.7 KiB
Go
package api
|
|
|
|
import (
|
|
"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/config"
|
|
"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"
|
|
)
|
|
|
|
func newSettingsTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
pool := testutil.MustConnectTestPool(t)
|
|
database := db.NewFromPool(pool)
|
|
broker := NewBroker(zerolog.Nop())
|
|
state := NewServerState(zerolog.Nop(), nil, broker)
|
|
cfg := &config.Config{
|
|
Server: config.ServerConfig{Host: "0.0.0.0", Port: 8080},
|
|
Database: config.DatabaseConfig{
|
|
Host: "localhost", Port: 5432, Name: "silo_test",
|
|
User: "silo", Password: "secret", SSLMode: "disable",
|
|
MaxConnections: 10,
|
|
},
|
|
Storage: config.StorageConfig{
|
|
Backend: "filesystem",
|
|
Filesystem: config.FilesystemConfig{RootDir: "/tmp/silo-test"},
|
|
},
|
|
Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"},
|
|
Auth: config.AuthConfig{
|
|
SessionSecret: "supersecret",
|
|
Local: config.LocalAuth{Enabled: true, DefaultAdminUsername: "admin", DefaultAdminPassword: "changeme"},
|
|
LDAP: config.LDAPAuth{Enabled: false, BindPassword: "ldapsecret"},
|
|
OIDC: config.OIDCAuth{Enabled: false, ClientSecret: "oidcsecret"},
|
|
},
|
|
FreeCAD: config.FreeCADConfig{URIScheme: "silo"},
|
|
Odoo: config.OdooConfig{URL: "https://odoo.example.com", APIKey: "odoo-api-key"},
|
|
Jobs: config.JobsConfig{Directory: "/etc/silo/jobdefs", RunnerTimeout: 90, JobTimeoutCheck: 30, DefaultPriority: 100},
|
|
}
|
|
return NewServer(
|
|
zerolog.Nop(),
|
|
database,
|
|
map[string]*schema.Schema{"test": {Name: "test"}},
|
|
cfg.Schemas.Directory,
|
|
nil, // storage
|
|
nil, // authService
|
|
nil, // sessionManager
|
|
nil, // oidcBackend
|
|
nil, // authConfig
|
|
broker,
|
|
state,
|
|
nil, // jobDefs
|
|
"", // jobDefsDir
|
|
modules.NewRegistry(), // modules
|
|
cfg,
|
|
nil, // workflows
|
|
)
|
|
}
|
|
|
|
func newSettingsRouter(s *Server) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Route("/api/admin/settings", func(r chi.Router) {
|
|
r.Get("/", s.HandleGetAllSettings)
|
|
r.Get("/{module}", s.HandleGetModuleSettings)
|
|
r.Put("/{module}", s.HandleUpdateModuleSettings)
|
|
r.Post("/{module}/test", s.HandleTestModuleConnectivity)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func adminSettingsRequest(r *http.Request) *http.Request {
|
|
u := &auth.User{
|
|
ID: "admin-id",
|
|
Username: "testadmin",
|
|
Role: auth.RoleAdmin,
|
|
}
|
|
return r.WithContext(auth.ContextWithUser(r.Context(), u))
|
|
}
|
|
|
|
func viewerSettingsRequest(r *http.Request) *http.Request {
|
|
u := &auth.User{
|
|
ID: "viewer-id",
|
|
Username: "testviewer",
|
|
Role: auth.RoleViewer,
|
|
}
|
|
return r.WithContext(auth.ContextWithUser(r.Context(), u))
|
|
}
|
|
|
|
func TestGetAllSettings(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings", 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: %v", err)
|
|
}
|
|
|
|
// Verify all module keys present
|
|
expectedModules := []string{"core", "schemas", "storage", "database", "auth", "projects", "audit", "odoo", "freecad", "jobs", "dag"}
|
|
for _, mod := range expectedModules {
|
|
if _, ok := resp[mod]; !ok {
|
|
t.Errorf("missing module key: %s", mod)
|
|
}
|
|
}
|
|
|
|
// Verify secrets are redacted
|
|
dbSettings, _ := resp["database"].(map[string]any)
|
|
if dbSettings["password"] != "****" {
|
|
t.Errorf("database password not redacted: got %v", dbSettings["password"])
|
|
}
|
|
|
|
authSettings, _ := resp["auth"].(map[string]any)
|
|
if authSettings["session_secret"] != "****" {
|
|
t.Errorf("session_secret not redacted: got %v", authSettings["session_secret"])
|
|
}
|
|
|
|
ldap, _ := authSettings["ldap"].(map[string]any)
|
|
if ldap["bind_password"] != "****" {
|
|
t.Errorf("ldap bind_password not redacted: got %v", ldap["bind_password"])
|
|
}
|
|
|
|
oidc, _ := authSettings["oidc"].(map[string]any)
|
|
if oidc["client_secret"] != "****" {
|
|
t.Errorf("oidc client_secret not redacted: got %v", oidc["client_secret"])
|
|
}
|
|
|
|
odoo, _ := resp["odoo"].(map[string]any)
|
|
if odoo["api_key"] != "****" {
|
|
t.Errorf("odoo api_key not redacted: got %v", odoo["api_key"])
|
|
}
|
|
}
|
|
|
|
func TestGetModuleSettings(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/jobs", 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: %v", err)
|
|
}
|
|
|
|
if resp["directory"] != "/etc/silo/jobdefs" {
|
|
t.Errorf("jobs directory: got %v, want /etc/silo/jobdefs", resp["directory"])
|
|
}
|
|
if resp["runner_timeout"] != float64(90) {
|
|
t.Errorf("runner_timeout: got %v, want 90", resp["runner_timeout"])
|
|
}
|
|
}
|
|
|
|
func TestGetModuleSettings_Unknown(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/nonexistent", 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 TestToggleModule(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
// Projects is enabled by default; disable it
|
|
body := `{"enabled": false}`
|
|
req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/projects", strings.NewReader(body)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
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: %v", err)
|
|
}
|
|
|
|
updated, _ := resp["updated"].([]any)
|
|
if len(updated) != 1 || updated[0] != "projects.enabled" {
|
|
t.Errorf("updated: got %v, want [projects.enabled]", updated)
|
|
}
|
|
|
|
// Verify registry state
|
|
if s.modules.IsEnabled("projects") {
|
|
t.Error("projects should be disabled after toggle")
|
|
}
|
|
}
|
|
|
|
func TestToggleModule_DependencyError(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
// DAG depends on Jobs. Jobs is disabled by default.
|
|
// Enabling DAG without Jobs should fail.
|
|
body := `{"enabled": true}`
|
|
req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/dag", strings.NewReader(body)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestToggleRequiredModule(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
body := `{"enabled": false}`
|
|
req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/core", strings.NewReader(body)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTestConnectivity_Database(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/database/test", 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: %v", err)
|
|
}
|
|
|
|
if resp["success"] != true {
|
|
t.Errorf("expected success=true, got %v; message: %v", resp["success"], resp["message"])
|
|
}
|
|
if resp["latency_ms"] == nil {
|
|
t.Error("expected latency_ms in response")
|
|
}
|
|
}
|
|
|
|
func TestTestConnectivity_NotTestable(t *testing.T) {
|
|
s := newSettingsTestServer(t)
|
|
router := newSettingsRouter(s)
|
|
|
|
req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/core/test", nil))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
}
|