diff --git a/internal/api/dag_handlers_test.go b/internal/api/dag_handlers_test.go new file mode 100644 index 0000000..15cdae4 --- /dev/null +++ b/internal/api/dag_handlers_test.go @@ -0,0 +1,247 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/testutil" + "github.com/rs/zerolog" +) + +func newDAGTestServer(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, "", + ) +} + +func newDAGRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Route("/api/items/{partNumber}", func(r chi.Router) { + r.Get("/dag", s.HandleGetDAG) + r.Get("/dag/forward-cone/{nodeKey}", s.HandleGetForwardCone) + r.Get("/dag/dirty", s.HandleGetDirtySubgraph) + r.Put("/dag", s.HandleSyncDAG) + r.Post("/dag/mark-dirty/{nodeKey}", s.HandleMarkDirty) + }) + return r +} + +func TestHandleGetDAG_Empty(t *testing.T) { + s := newDAGTestServer(t) + r := newDAGRouter(s) + + // Create an item + item := &db.Item{PartNumber: "DAG-TEST-001", ItemType: "part", Description: "DAG test"} + if err := s.items.Create(context.Background(), item, nil); err != nil { + t.Fatalf("creating item: %v", err) + } + + req := httptest.NewRequest("GET", "/api/items/DAG-TEST-001/dag", 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 resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["part_number"] != "DAG-TEST-001" { + t.Errorf("expected part_number DAG-TEST-001, got %v", resp["part_number"]) + } +} + +func TestHandleSyncDAG(t *testing.T) { + s := newDAGTestServer(t) + r := newDAGRouter(s) + + // Create an item with a revision + item := &db.Item{PartNumber: "DAG-SYNC-001", ItemType: "part", Description: "sync test"} + if err := s.items.Create(context.Background(), item, nil); err != nil { + t.Fatalf("creating item: %v", err) + } + + // Sync a feature tree + body := `{ + "nodes": [ + {"node_key": "Sketch001", "node_type": "sketch"}, + {"node_key": "Pad001", "node_type": "pad"}, + {"node_key": "Fillet001", "node_type": "fillet"} + ], + "edges": [ + {"source_key": "Sketch001", "target_key": "Pad001", "edge_type": "depends_on"}, + {"source_key": "Pad001", "target_key": "Fillet001", "edge_type": "depends_on"} + ] + }` + + req := httptest.NewRequest("PUT", "/api/items/DAG-SYNC-001/dag", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + 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 resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["node_count"] != float64(3) { + t.Errorf("expected 3 nodes, got %v", resp["node_count"]) + } + if resp["edge_count"] != float64(2) { + t.Errorf("expected 2 edges, got %v", resp["edge_count"]) + } + + // Verify we can read the DAG back + req2 := httptest.NewRequest("GET", "/api/items/DAG-SYNC-001/dag", nil) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("GET dag: expected 200, got %d", w2.Code) + } + var dagResp map[string]any + json.Unmarshal(w2.Body.Bytes(), &dagResp) + nodes, ok := dagResp["nodes"].([]any) + if !ok || len(nodes) != 3 { + t.Errorf("expected 3 nodes in GET, got %v", dagResp["nodes"]) + } +} + +func TestHandleForwardCone(t *testing.T) { + s := newDAGTestServer(t) + r := newDAGRouter(s) + + item := &db.Item{PartNumber: "DAG-CONE-001", ItemType: "part", Description: "cone test"} + if err := s.items.Create(context.Background(), item, nil); err != nil { + t.Fatalf("creating item: %v", err) + } + + // Sync a linear chain: A -> B -> C + body := `{ + "nodes": [ + {"node_key": "A", "node_type": "sketch"}, + {"node_key": "B", "node_type": "pad"}, + {"node_key": "C", "node_type": "fillet"} + ], + "edges": [ + {"source_key": "A", "target_key": "B"}, + {"source_key": "B", "target_key": "C"} + ] + }` + req := httptest.NewRequest("PUT", "/api/items/DAG-CONE-001/dag", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("sync: %d %s", w.Code, w.Body.String()) + } + + // Forward cone from A should include B and C + req2 := httptest.NewRequest("GET", "/api/items/DAG-CONE-001/dag/forward-cone/A", nil) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("forward-cone: %d %s", w2.Code, w2.Body.String()) + } + + var resp map[string]any + json.Unmarshal(w2.Body.Bytes(), &resp) + cone, ok := resp["cone"].([]any) + if !ok || len(cone) != 2 { + t.Errorf("expected 2 nodes in forward cone, got %v", resp["cone"]) + } +} + +func TestHandleMarkDirty(t *testing.T) { + s := newDAGTestServer(t) + r := newDAGRouter(s) + + item := &db.Item{PartNumber: "DAG-DIRTY-001", ItemType: "part", Description: "dirty test"} + if err := s.items.Create(context.Background(), item, nil); err != nil { + t.Fatalf("creating item: %v", err) + } + + // Sync: A -> B -> C + body := `{ + "nodes": [ + {"node_key": "X", "node_type": "sketch"}, + {"node_key": "Y", "node_type": "pad"}, + {"node_key": "Z", "node_type": "fillet"} + ], + "edges": [ + {"source_key": "X", "target_key": "Y"}, + {"source_key": "Y", "target_key": "Z"} + ] + }` + req := httptest.NewRequest("PUT", "/api/items/DAG-DIRTY-001/dag", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("sync: %d %s", w.Code, w.Body.String()) + } + + // Mark X dirty — should propagate to Y and Z + req2 := httptest.NewRequest("POST", "/api/items/DAG-DIRTY-001/dag/mark-dirty/X", nil) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("mark-dirty: %d %s", w2.Code, w2.Body.String()) + } + + var resp map[string]any + json.Unmarshal(w2.Body.Bytes(), &resp) + affected := resp["nodes_affected"].(float64) + if affected != 3 { + t.Errorf("expected 3 nodes affected, got %v", affected) + } + + // Verify dirty subgraph + req3 := httptest.NewRequest("GET", "/api/items/DAG-DIRTY-001/dag/dirty", nil) + w3 := httptest.NewRecorder() + r.ServeHTTP(w3, req3) + + if w3.Code != http.StatusOK { + t.Fatalf("dirty: %d %s", w3.Code, w3.Body.String()) + } + var dirtyResp map[string]any + json.Unmarshal(w3.Body.Bytes(), &dirtyResp) + dirtyNodes, ok := dirtyResp["nodes"].([]any) + if !ok || len(dirtyNodes) != 3 { + t.Errorf("expected 3 dirty nodes, got %v", dirtyResp["nodes"]) + } +} + +func TestHandleGetDAG_NotFound(t *testing.T) { + s := newDAGTestServer(t) + r := newDAGRouter(s) + + req := httptest.NewRequest("GET", "/api/items/NONEXISTENT-999/dag", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} diff --git a/internal/api/job_handlers_test.go b/internal/api/job_handlers_test.go new file mode 100644 index 0000000..a26be57 --- /dev/null +++ b/internal/api/job_handlers_test.go @@ -0,0 +1,338 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/db" + "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, "", + ) +} + +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()) + } +} + +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") + } +}