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).
597 lines
17 KiB
Go
597 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"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 newJobTestServer(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{},
|
|
"",
|
|
nil, nil, nil, nil, nil,
|
|
broker, state,
|
|
nil, "",
|
|
modules.NewRegistry(), nil,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
func newJobRouter(s *Server) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Route("/api/jobs", func(r chi.Router) {
|
|
r.Get("/", s.HandleListJobs)
|
|
r.Get("/{jobID}", s.HandleGetJob)
|
|
r.Get("/{jobID}/logs", s.HandleGetJobLogs)
|
|
r.Post("/", s.HandleCreateJob)
|
|
r.Post("/{jobID}/cancel", s.HandleCancelJob)
|
|
})
|
|
r.Route("/api/job-definitions", func(r chi.Router) {
|
|
r.Get("/", s.HandleListJobDefinitions)
|
|
r.Get("/{name}", s.HandleGetJobDefinition)
|
|
})
|
|
r.Route("/api/runners", func(r chi.Router) {
|
|
r.Get("/", s.HandleListRunners)
|
|
r.Post("/", s.HandleRegisterRunner)
|
|
r.Delete("/{runnerID}", s.HandleDeleteRunner)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func seedJobDefinition(t *testing.T, s *Server) *db.JobDefinitionRecord {
|
|
t.Helper()
|
|
rec := &db.JobDefinitionRecord{
|
|
Name: "test-validate",
|
|
Version: 1,
|
|
TriggerType: "manual",
|
|
ScopeType: "item",
|
|
ComputeType: "validate",
|
|
RunnerTags: []string{"create"},
|
|
TimeoutSeconds: 300,
|
|
MaxRetries: 1,
|
|
Priority: 100,
|
|
Definition: map[string]any{"compute": map[string]any{"command": "create-validate"}},
|
|
Enabled: true,
|
|
}
|
|
if err := s.jobs.UpsertDefinition(context.Background(), rec); err != nil {
|
|
t.Fatalf("seeding job definition: %v", err)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func TestHandleListJobDefinitions(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
req := httptest.NewRequest("GET", "/api/job-definitions", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var defs []map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &defs)
|
|
if len(defs) == 0 {
|
|
t.Error("expected at least one definition")
|
|
}
|
|
}
|
|
|
|
func TestHandleGetJobDefinition(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
req := httptest.NewRequest("GET", "/api/job-definitions/test-validate", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var def map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &def)
|
|
if def["name"] != "test-validate" {
|
|
t.Errorf("expected name test-validate, got %v", def["name"])
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateAndGetJob(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var job map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &job)
|
|
jobID := job["ID"].(string)
|
|
if jobID == "" {
|
|
t.Fatal("job ID is empty")
|
|
}
|
|
|
|
// Get the job
|
|
req2 := httptest.NewRequest("GET", "/api/jobs/"+jobID, nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("get: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleCancelJob(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
var job map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &job)
|
|
jobID := job["ID"].(string)
|
|
|
|
// Cancel the job
|
|
req2 := httptest.NewRequest("POST", "/api/jobs/"+jobID+"/cancel", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("cancel: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleListJobs(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
// List jobs
|
|
req2 := httptest.NewRequest("GET", "/api/jobs", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("list: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
var jobs []map[string]any
|
|
json.Unmarshal(w2.Body.Bytes(), &jobs)
|
|
if len(jobs) == 0 {
|
|
t.Error("expected at least one job")
|
|
}
|
|
}
|
|
|
|
func TestHandleListJobs_FilterByStatus(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Filter by pending
|
|
req2 := httptest.NewRequest("GET", "/api/jobs?status=pending", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w2.Code)
|
|
}
|
|
|
|
var jobs []map[string]any
|
|
json.Unmarshal(w2.Body.Bytes(), &jobs)
|
|
if len(jobs) == 0 {
|
|
t.Error("expected pending jobs")
|
|
}
|
|
|
|
// Filter by completed (should be empty)
|
|
req3 := httptest.NewRequest("GET", "/api/jobs?status=completed", nil)
|
|
w3 := httptest.NewRecorder()
|
|
r.ServeHTTP(w3, req3)
|
|
|
|
var completedJobs []map[string]any
|
|
json.Unmarshal(w3.Body.Bytes(), &completedJobs)
|
|
if len(completedJobs) != 0 {
|
|
t.Errorf("expected no completed jobs, got %d", len(completedJobs))
|
|
}
|
|
}
|
|
|
|
func TestHandleRegisterAndListRunners(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
// Register a runner
|
|
body := `{"name": "test-runner-1", "tags": ["create", "linux"]}`
|
|
req := httptest.NewRequest("POST", "/api/runners", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("register: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["token"] == nil || resp["token"] == "" {
|
|
t.Error("expected a token in response")
|
|
}
|
|
if !strings.HasPrefix(resp["token"].(string), "silo_runner_") {
|
|
t.Errorf("expected token to start with silo_runner_, got %s", resp["token"])
|
|
}
|
|
|
|
// List runners
|
|
req2 := httptest.NewRequest("GET", "/api/runners", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("list: expected 200, got %d", w2.Code)
|
|
}
|
|
|
|
var runners []map[string]any
|
|
json.Unmarshal(w2.Body.Bytes(), &runners)
|
|
if len(runners) == 0 {
|
|
t.Error("expected at least one runner")
|
|
}
|
|
// Token hash should not be exposed
|
|
for _, runner := range runners {
|
|
if runner["token_hash"] != nil {
|
|
t.Error("token_hash should not be in response")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteRunner(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
// Register a runner
|
|
body := `{"name": "test-runner-delete", "tags": ["create"]}`
|
|
req := httptest.NewRequest("POST", "/api/runners", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
var resp map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
runnerID := resp["id"].(string)
|
|
|
|
// Delete the runner
|
|
req2 := httptest.NewRequest("DELETE", "/api/runners/"+runnerID, nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: expected 204, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- Trigger integration tests ---
|
|
|
|
// newTriggerRouter builds a router with items, revisions, BOM, and jobs routes
|
|
// so that HTTP-based actions can fire triggerJobs via goroutine.
|
|
func newTriggerRouter(s *Server) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Route("/api/items", func(r chi.Router) {
|
|
r.Post("/", s.HandleCreateItem)
|
|
r.Route("/{partNumber}", func(r chi.Router) {
|
|
r.Post("/revisions", s.HandleCreateRevision)
|
|
r.Post("/bom", s.HandleAddBOMEntry)
|
|
r.Put("/bom/{childPartNumber}", s.HandleUpdateBOMEntry)
|
|
r.Delete("/bom/{childPartNumber}", s.HandleDeleteBOMEntry)
|
|
})
|
|
})
|
|
r.Route("/api/jobs", func(r chi.Router) {
|
|
r.Get("/", s.HandleListJobs)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func waitForJobs(t *testing.T, s *Server, itemID string, wantCount int) []*db.Job {
|
|
t.Helper()
|
|
// triggerJobs runs in a goroutine; poll up to 2 seconds.
|
|
for i := 0; i < 20; i++ {
|
|
jobs, err := s.jobs.ListJobs(context.Background(), "", itemID, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("listing jobs: %v", err)
|
|
}
|
|
if len(jobs) >= wantCount {
|
|
return jobs
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
jobs, _ := s.jobs.ListJobs(context.Background(), "", itemID, 50, 0)
|
|
return jobs
|
|
}
|
|
|
|
func TestTriggerJobsOnRevisionCreate(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
if err := s.modules.SetEnabled("jobs", true); err != nil {
|
|
t.Fatalf("enabling jobs module: %v", err)
|
|
}
|
|
router := newTriggerRouter(s)
|
|
|
|
// Create an item.
|
|
createItemDirect(t, s, "TRIG-REV-001", "trigger test item", nil)
|
|
|
|
// Seed a job definition that triggers on revision_created.
|
|
def := &db.JobDefinitionRecord{
|
|
Name: "rev-trigger-test",
|
|
Version: 1,
|
|
TriggerType: "revision_created",
|
|
ScopeType: "item",
|
|
ComputeType: "validate",
|
|
RunnerTags: []string{"test"},
|
|
TimeoutSeconds: 60,
|
|
MaxRetries: 0,
|
|
Priority: 100,
|
|
Enabled: true,
|
|
}
|
|
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
|
|
t.Fatalf("seeding definition: %v", err)
|
|
}
|
|
|
|
// Create a revision via HTTP (fires triggerJobs in goroutine).
|
|
body := `{"properties":{"material":"steel"},"comment":"trigger test"}`
|
|
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-REV-001/revisions", strings.NewReader(body)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create revision: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Get the item ID to filter jobs.
|
|
item, _ := s.items.GetByPartNumber(context.Background(), "TRIG-REV-001")
|
|
if item == nil {
|
|
t.Fatal("item not found after creation")
|
|
}
|
|
|
|
jobs := waitForJobs(t, s, item.ID, 1)
|
|
if len(jobs) == 0 {
|
|
t.Fatal("expected at least 1 triggered job, got 0")
|
|
}
|
|
if jobs[0].DefinitionName != "rev-trigger-test" {
|
|
t.Errorf("expected definition name rev-trigger-test, got %s", jobs[0].DefinitionName)
|
|
}
|
|
}
|
|
|
|
func TestTriggerJobsOnBOMChange(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
if err := s.modules.SetEnabled("jobs", true); err != nil {
|
|
t.Fatalf("enabling jobs module: %v", err)
|
|
}
|
|
router := newTriggerRouter(s)
|
|
|
|
// Create parent and child items.
|
|
createItemDirect(t, s, "TRIG-BOM-P", "parent", nil)
|
|
createItemDirect(t, s, "TRIG-BOM-C", "child", nil)
|
|
|
|
// Seed a bom_changed job definition.
|
|
def := &db.JobDefinitionRecord{
|
|
Name: "bom-trigger-test",
|
|
Version: 1,
|
|
TriggerType: "bom_changed",
|
|
ScopeType: "item",
|
|
ComputeType: "validate",
|
|
RunnerTags: []string{"test"},
|
|
TimeoutSeconds: 60,
|
|
MaxRetries: 0,
|
|
Priority: 100,
|
|
Enabled: true,
|
|
}
|
|
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
|
|
t.Fatalf("seeding definition: %v", err)
|
|
}
|
|
|
|
// Add a BOM entry via HTTP.
|
|
body := `{"child_part_number":"TRIG-BOM-C","rel_type":"component","quantity":2}`
|
|
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-BOM-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("add BOM entry: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Get the parent item ID.
|
|
parent, _ := s.items.GetByPartNumber(context.Background(), "TRIG-BOM-P")
|
|
if parent == nil {
|
|
t.Fatal("parent item not found")
|
|
}
|
|
|
|
jobs := waitForJobs(t, s, parent.ID, 1)
|
|
if len(jobs) == 0 {
|
|
t.Fatal("expected at least 1 triggered job, got 0")
|
|
}
|
|
if jobs[0].DefinitionName != "bom-trigger-test" {
|
|
t.Errorf("expected definition name bom-trigger-test, got %s", jobs[0].DefinitionName)
|
|
}
|
|
}
|
|
|
|
func TestTriggerJobsFilterMismatch(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
if err := s.modules.SetEnabled("jobs", true); err != nil {
|
|
t.Fatalf("enabling jobs module: %v", err)
|
|
}
|
|
router := newTriggerRouter(s)
|
|
|
|
// Create a "part" type item (not "assembly").
|
|
createItemDirect(t, s, "TRIG-FILT-P", "filter parent", nil)
|
|
createItemDirect(t, s, "TRIG-FILT-C", "filter child", nil)
|
|
|
|
// Seed a definition that only triggers for assembly items.
|
|
def := &db.JobDefinitionRecord{
|
|
Name: "assembly-only-test",
|
|
Version: 1,
|
|
TriggerType: "bom_changed",
|
|
ScopeType: "item",
|
|
ComputeType: "validate",
|
|
RunnerTags: []string{"test"},
|
|
TimeoutSeconds: 60,
|
|
MaxRetries: 0,
|
|
Priority: 100,
|
|
Enabled: true,
|
|
Definition: map[string]any{
|
|
"trigger": map[string]any{
|
|
"filter": map[string]any{
|
|
"item_type": "assembly",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
|
|
t.Fatalf("seeding definition: %v", err)
|
|
}
|
|
|
|
// Add a BOM entry on a "part" item (should NOT match assembly filter).
|
|
body := `{"child_part_number":"TRIG-FILT-C","rel_type":"component","quantity":1}`
|
|
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-FILT-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("add BOM entry: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Wait briefly, then verify no jobs were created.
|
|
parent, _ := s.items.GetByPartNumber(context.Background(), "TRIG-FILT-P")
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
jobs, err := s.jobs.ListJobs(context.Background(), "", parent.ID, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("listing jobs: %v", err)
|
|
}
|
|
if len(jobs) != 0 {
|
|
t.Errorf("expected 0 jobs (filter mismatch), got %d", len(jobs))
|
|
}
|
|
}
|
|
|
|
func TestTriggerJobsModuleDisabled(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
// Jobs module is disabled by default in NewRegistry().
|
|
router := newTriggerRouter(s)
|
|
|
|
// Create items.
|
|
createItemDirect(t, s, "TRIG-DIS-P", "disabled parent", nil)
|
|
createItemDirect(t, s, "TRIG-DIS-C", "disabled child", nil)
|
|
|
|
// Seed a bom_changed definition (it exists in DB but module is off).
|
|
def := &db.JobDefinitionRecord{
|
|
Name: "disabled-trigger-test",
|
|
Version: 1,
|
|
TriggerType: "bom_changed",
|
|
ScopeType: "item",
|
|
ComputeType: "validate",
|
|
RunnerTags: []string{"test"},
|
|
TimeoutSeconds: 60,
|
|
MaxRetries: 0,
|
|
Priority: 100,
|
|
Enabled: true,
|
|
}
|
|
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
|
|
t.Fatalf("seeding definition: %v", err)
|
|
}
|
|
|
|
// Add a BOM entry with jobs module disabled.
|
|
body := `{"child_part_number":"TRIG-DIS-C","rel_type":"component","quantity":1}`
|
|
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-DIS-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("add BOM entry: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Wait briefly, then verify no jobs were created.
|
|
parent, _ := s.items.GetByPartNumber(context.Background(), "TRIG-DIS-P")
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
jobs, err := s.jobs.ListJobs(context.Background(), "", parent.ID, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("listing jobs: %v", err)
|
|
}
|
|
if len(jobs) != 0 {
|
|
t.Errorf("expected 0 jobs (module disabled), got %d", len(jobs))
|
|
}
|
|
}
|
|
|
|
func TestGenerateRunnerToken(t *testing.T) {
|
|
raw, hash, prefix := generateRunnerToken()
|
|
|
|
if !strings.HasPrefix(raw, "silo_runner_") {
|
|
t.Errorf("raw token should start with silo_runner_, got %s", raw[:20])
|
|
}
|
|
if len(hash) != 64 {
|
|
t.Errorf("hash should be 64 hex chars, got %d", len(hash))
|
|
}
|
|
if len(prefix) != 20 {
|
|
t.Errorf("prefix should be 20 chars, got %d: %s", len(prefix), prefix)
|
|
}
|
|
|
|
// Two tokens should be different
|
|
raw2, _, _ := generateRunnerToken()
|
|
if raw == raw2 {
|
|
t.Error("two generated tokens should be different")
|
|
}
|
|
}
|