diff --git a/backend/app/faros/runtime/graph_builder.py b/backend/app/faros/runtime/graph_builder.py index c8f5cea9..56249bbc 100644 --- a/backend/app/faros/runtime/graph_builder.py +++ b/backend/app/faros/runtime/graph_builder.py @@ -12,9 +12,20 @@ def build(self, blueprint: Blueprint) -> List[WorkflowNode]: def dependency_map(self, blueprint: Blueprint) -> Dict[str, Dict[str, List[str]]]: mapping = {node.id: {'upstream': [], 'downstream': []} for node in blueprint.workflow} + node_ids = set(mapping.keys()) for edge in blueprint.edges: - mapping.setdefault(edge.target, {'upstream': [], 'downstream': []})['upstream'].append(edge.source) - mapping.setdefault(edge.source, {'upstream': [], 'downstream': []})['downstream'].append(edge.target) + invalid = [] + if edge.source not in node_ids: + invalid.append(f"source '{edge.source}'") + if edge.target not in node_ids: + invalid.append(f"target '{edge.target}'") + if invalid: + raise ValueError( + f"Edge {edge.source} -> {edge.target} references " + f"non-existent node(s): {', '.join(invalid)}" + ) + mapping[edge.target]['upstream'].append(edge.source) + mapping[edge.source]['downstream'].append(edge.target) return mapping def initial_step_states(self, blueprint: Blueprint) -> List[StepState]: diff --git a/backend/tests/test_pr_12_graph_builder_edges.py b/backend/tests/test_pr_12_graph_builder_edges.py new file mode 100644 index 00000000..67b34efa --- /dev/null +++ b/backend/tests/test_pr_12_graph_builder_edges.py @@ -0,0 +1,74 @@ +""" +PR-B12: graph_builder dependency_map validates edge endpoints. +""" + +import sys +import os +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.faros.models.blueprint import Blueprint, WorkflowNode, WorkflowEdge +from app.faros.runtime.graph_builder import GraphBuilder + + +def _make_node(nid): + return WorkflowNode(id=nid, capability=nid, name=nid) + + +class TestDependencyMapEdgeValidation: + + def setup_method(self): + self.builder = GraphBuilder() + + def test_valid_edges(self): + bp = Blueprint( + id="bp1", name="test", version="1", + workflow=[_make_node("a"), _make_node("b"), _make_node("c")], + edges=[ + WorkflowEdge(**{"from": "a", "to": "b"}), + WorkflowEdge(**{"from": "b", "to": "c"}), + ], + ) + result = self.builder.dependency_map(bp) + assert result["a"]["downstream"] == ["b"] + assert result["b"]["upstream"] == ["a"] + assert result["b"]["downstream"] == ["c"] + assert result["c"]["upstream"] == ["b"] + + def test_edge_with_nonexistent_source_raises(self): + bp = Blueprint( + id="bp2", name="test", version="1", + workflow=[_make_node("a")], + edges=[WorkflowEdge(**{"from": "ghost", "to": "a"})], + ) + with pytest.raises(ValueError, match="non-existent node"): + self.builder.dependency_map(bp) + + def test_edge_with_nonexistent_target_raises(self): + bp = Blueprint( + id="bp3", name="test", version="1", + workflow=[_make_node("a")], + edges=[WorkflowEdge(**{"from": "a", "to": "ghost"})], + ) + with pytest.raises(ValueError, match="non-existent node"): + self.builder.dependency_map(bp) + + def test_edge_with_both_endpoints_missing_raises(self): + bp = Blueprint( + id="bp4", name="test", version="1", + workflow=[_make_node("a")], + edges=[WorkflowEdge(**{"from": "x", "to": "y"})], + ) + with pytest.raises(ValueError, match="non-existent node"): + self.builder.dependency_map(bp) + + def test_no_edges(self): + bp = Blueprint( + id="bp5", name="test", version="1", + workflow=[_make_node("a"), _make_node("b")], + edges=[], + ) + result = self.builder.dependency_map(bp) + assert result["a"]["upstream"] == [] + assert result["a"]["downstream"] == []