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