feat(jobs): wire auto-triggering on bom_changed events, add module guard

- Add IsEnabled("jobs") guard to triggerJobs() to skip when module disabled
- Fire bom_changed trigger from HandleAddBOMEntry, HandleUpdateBOMEntry,
  HandleDeleteBOMEntry (matching existing HandleMergeBOM pattern)
- Add 4 integration tests: revision trigger, BOM trigger, filter mismatch,
  module disabled
- Fix AppShell overflow: hidden -> auto so Settings page scrolls
- Clean old frontend assets in deploy script before extracting

Closes #107
This commit is contained in:
Forbes
2026-02-15 09:43:05 -06:00
parent 71603bb6d7
commit 747bae8354
5 changed files with 269 additions and 3 deletions

View File

@@ -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()