diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go index f93d52e..35dd142 100644 --- a/internal/api/bom_handlers.go +++ b/internal/api/bom_handlers.go @@ -285,6 +285,8 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusCreated, entry) + + go s.triggerJobs(context.Background(), "bom_changed", parent.ID, parent) } // HandleUpdateBOMEntry updates an existing BOM relationship. @@ -353,6 +355,8 @@ func (s *Server) HandleUpdateBOMEntry(w http.ResponseWriter, r *http.Request) { return } + go s.triggerJobs(context.Background(), "bom_changed", parent.ID, parent) + // Reload and return updated entry entries, err := s.relationships.GetBOM(ctx, parent.ID) if err == nil { @@ -419,6 +423,8 @@ func (s *Server) HandleDeleteBOMEntry(w http.ResponseWriter, r *http.Request) { Msg("BOM entry removed") w.WriteHeader(http.StatusNoContent) + + go s.triggerJobs(context.Background(), "bom_changed", parent.ID, parent) } // Helper functions diff --git a/internal/api/job_handlers.go b/internal/api/job_handlers.go index ebeb01e..5bf5f74 100644 --- a/internal/api/job_handlers.go +++ b/internal/api/job_handlers.go @@ -326,6 +326,10 @@ func (s *Server) HandleDeleteRunner(w http.ResponseWriter, r *http.Request) { // triggerJobs creates jobs for all enabled definitions matching the trigger type. // It applies trigger filters (e.g. item_type) before creating each job. func (s *Server) triggerJobs(ctx context.Context, triggerType string, itemID string, item *db.Item) { + if !s.modules.IsEnabled("jobs") { + return + } + defs, err := s.jobs.GetDefinitionsByTrigger(ctx, triggerType) if err != nil { s.logger.Error().Err(err).Str("trigger", triggerType).Msg("failed to get job definitions for trigger") diff --git a/internal/api/job_handlers_test.go b/internal/api/job_handlers_test.go index b9899f1..d7eab3b 100644 --- a/internal/api/job_handlers_test.go +++ b/internal/api/job_handlers_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/db" @@ -319,6 +320,260 @@ func TestHandleDeleteRunner(t *testing.T) { } } +// --- 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() diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c6998a0..8df1bc8 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -77,6 +77,9 @@ if systemctl is-active --quiet silod 2>/dev/null; then sudo systemctl stop silod fi +# Clean old frontend assets before extracting +sudo rm -rf "$DEPLOY_DIR/web/dist/assets" + # Extract echo " Extracting..." sudo tar -xzf /tmp/silo-deploy.tar.gz -C "$DEPLOY_DIR" diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 2d19da2..4646340 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -144,9 +144,7 @@ export function AppShell() { )} -
+