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/modules" "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, "", modules.NewRegistry(), 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) } }