""" Tests for Cycle Detection in Task Dependencies and Formula Fields Tests cover: - Task dependency cycle detection (direct and indirect) - Bulk dependency validation with cycle detection - Formula field circular reference detection - Detailed cycle path reporting """ import pytest from unittest.mock import MagicMock from app.models import Task, TaskDependency, Space, Project, TaskStatus, CustomField from app.services.dependency_service import ( DependencyService, DependencyValidationError, CycleDetectionResult ) from app.services.formula_service import ( FormulaService, CircularReferenceError ) class TestTaskDependencyCycleDetection: """Test task dependency cycle detection.""" def setup_project(self, db, project_id: str, space_id: str): """Create a space and project for testing.""" space = Space( id=space_id, name="Test Space", owner_id="00000000-0000-0000-0000-000000000001", ) db.add(space) project = Project( id=project_id, space_id=space_id, title="Test Project", owner_id="00000000-0000-0000-0000-000000000001", security_level="public", ) db.add(project) status = TaskStatus( id=f"status-{project_id}", project_id=project_id, name="To Do", color="#808080", position=0, ) db.add(status) db.commit() return project, status def create_task(self, db, task_id: str, project_id: str, status_id: str, title: str): """Create a task for testing.""" task = Task( id=task_id, project_id=project_id, title=title, priority="medium", created_by="00000000-0000-0000-0000-000000000001", status_id=status_id, ) db.add(task) return task def test_direct_circular_dependency_A_B_A(self, db): """Test detection of direct cycle: A -> B -> A.""" project, status = self.setup_project(db, "proj-cycle-1", "space-cycle-1") task_a = self.create_task(db, "task-a-1", project.id, status.id, "Task A") task_b = self.create_task(db, "task-b-1", project.id, status.id, "Task B") db.commit() # Create A -> B dependency dep = TaskDependency( id="dep-ab-1", predecessor_id="task-a-1", successor_id="task-b-1", dependency_type="FS", lag_days=0, ) db.add(dep) db.commit() # Try to create B -> A (would create cycle) result = DependencyService.detect_circular_dependency_detailed( db, "task-b-1", "task-a-1", project.id ) assert result.has_cycle is True assert len(result.cycle_path) > 0 assert "task-a-1" in result.cycle_path assert "task-b-1" in result.cycle_path assert "Task A" in result.cycle_task_titles assert "Task B" in result.cycle_task_titles def test_indirect_circular_dependency_A_B_C_A(self, db): """Test detection of indirect cycle: A -> B -> C -> A.""" project, status = self.setup_project(db, "proj-cycle-2", "space-cycle-2") task_a = self.create_task(db, "task-a-2", project.id, status.id, "Task A") task_b = self.create_task(db, "task-b-2", project.id, status.id, "Task B") task_c = self.create_task(db, "task-c-2", project.id, status.id, "Task C") db.commit() # Create A -> B and B -> C dependencies dep_ab = TaskDependency( id="dep-ab-2", predecessor_id="task-a-2", successor_id="task-b-2", dependency_type="FS", lag_days=0, ) dep_bc = TaskDependency( id="dep-bc-2", predecessor_id="task-b-2", successor_id="task-c-2", dependency_type="FS", lag_days=0, ) db.add_all([dep_ab, dep_bc]) db.commit() # Try to create C -> A (would create cycle A -> B -> C -> A) result = DependencyService.detect_circular_dependency_detailed( db, "task-c-2", "task-a-2", project.id ) assert result.has_cycle is True cycle_desc = result.get_cycle_description() assert "Task A" in cycle_desc assert "Task B" in cycle_desc assert "Task C" in cycle_desc def test_longer_cycle_path(self, db): """Test detection of longer cycle: A -> B -> C -> D -> E -> A.""" project, status = self.setup_project(db, "proj-cycle-3", "space-cycle-3") tasks = [] for letter in ["A", "B", "C", "D", "E"]: task = self.create_task( db, f"task-{letter.lower()}-3", project.id, status.id, f"Task {letter}" ) tasks.append(task) db.commit() # Create chain: A -> B -> C -> D -> E deps = [] task_ids = [f"task-{l.lower()}-3" for l in ["A", "B", "C", "D", "E"]] for i in range(len(task_ids) - 1): dep = TaskDependency( id=f"dep-{i}-3", predecessor_id=task_ids[i], successor_id=task_ids[i + 1], dependency_type="FS", lag_days=0, ) deps.append(dep) db.add_all(deps) db.commit() # Try to create E -> A (would create cycle) result = DependencyService.detect_circular_dependency_detailed( db, "task-e-3", "task-a-3", project.id ) assert result.has_cycle is True assert len(result.cycle_path) >= 5 # Should contain all 5 tasks + repeat def test_no_cycle_valid_dependency(self, db): """Test that valid dependency chains are accepted.""" project, status = self.setup_project(db, "proj-valid-1", "space-valid-1") task_a = self.create_task(db, "task-a-v1", project.id, status.id, "Task A") task_b = self.create_task(db, "task-b-v1", project.id, status.id, "Task B") task_c = self.create_task(db, "task-c-v1", project.id, status.id, "Task C") db.commit() # Create A -> B dep = TaskDependency( id="dep-ab-v1", predecessor_id="task-a-v1", successor_id="task-b-v1", dependency_type="FS", lag_days=0, ) db.add(dep) db.commit() # B -> C should be valid (no cycle) result = DependencyService.detect_circular_dependency_detailed( db, "task-b-v1", "task-c-v1", project.id ) assert result.has_cycle is False assert len(result.cycle_path) == 0 def test_cycle_description_format(self, db): """Test that cycle description is formatted correctly.""" project, status = self.setup_project(db, "proj-desc-1", "space-desc-1") task_a = self.create_task(db, "task-a-d1", project.id, status.id, "Alpha Task") task_b = self.create_task(db, "task-b-d1", project.id, status.id, "Beta Task") db.commit() # Create A -> B dep = TaskDependency( id="dep-ab-d1", predecessor_id="task-a-d1", successor_id="task-b-d1", dependency_type="FS", lag_days=0, ) db.add(dep) db.commit() # Try B -> A result = DependencyService.detect_circular_dependency_detailed( db, "task-b-d1", "task-a-d1", project.id ) description = result.get_cycle_description() assert " -> " in description # Should use arrow format class TestBulkDependencyValidation: """Test bulk dependency validation with cycle detection.""" def setup_project_with_tasks(self, db, project_id: str, space_id: str, task_count: int): """Create a project with multiple tasks.""" space = Space( id=space_id, name="Test Space", owner_id="00000000-0000-0000-0000-000000000001", ) db.add(space) project = Project( id=project_id, space_id=space_id, title="Test Project", owner_id="00000000-0000-0000-0000-000000000001", security_level="public", ) db.add(project) status = TaskStatus( id=f"status-{project_id}", project_id=project_id, name="To Do", color="#808080", position=0, ) db.add(status) tasks = [] for i in range(task_count): task = Task( id=f"task-{project_id}-{i}", project_id=project_id, title=f"Task {i}", priority="medium", created_by="00000000-0000-0000-0000-000000000001", status_id=f"status-{project_id}", ) db.add(task) tasks.append(task) db.commit() return project, tasks def test_bulk_validation_detects_cycle_in_batch(self, db): """Test that bulk validation detects cycles created by the batch itself.""" project, tasks = self.setup_project_with_tasks(db, "proj-bulk-1", "space-bulk-1", 3) # Create A -> B -> C -> A in a single batch dependencies = [ (tasks[0].id, tasks[1].id), # A -> B (tasks[1].id, tasks[2].id), # B -> C (tasks[2].id, tasks[0].id), # C -> A (creates cycle) ] errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id) # Should detect the cycle assert len(errors) > 0 cycle_errors = [e for e in errors if e.get("error_type") == "circular"] assert len(cycle_errors) > 0 def test_bulk_validation_accepts_valid_chain(self, db): """Test that bulk validation accepts valid dependency chains.""" project, tasks = self.setup_project_with_tasks(db, "proj-bulk-2", "space-bulk-2", 4) # Create A -> B -> C -> D (valid chain) dependencies = [ (tasks[0].id, tasks[1].id), # A -> B (tasks[1].id, tasks[2].id), # B -> C (tasks[2].id, tasks[3].id), # C -> D ] errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id) assert len(errors) == 0 def test_bulk_validation_detects_self_reference(self, db): """Test that bulk validation detects self-references.""" project, tasks = self.setup_project_with_tasks(db, "proj-bulk-3", "space-bulk-3", 2) dependencies = [ (tasks[0].id, tasks[0].id), # Self-reference ] errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id) assert len(errors) > 0 assert errors[0]["error_type"] == "self_reference" def test_bulk_validation_detects_duplicate_in_existing(self, db): """Test that bulk validation detects duplicates with existing dependencies.""" project, tasks = self.setup_project_with_tasks(db, "proj-bulk-4", "space-bulk-4", 2) # Create existing dependency dep = TaskDependency( id="dep-existing-bulk-4", predecessor_id=tasks[0].id, successor_id=tasks[1].id, dependency_type="FS", lag_days=0, ) db.add(dep) db.commit() # Try to add same dependency in bulk dependencies = [ (tasks[0].id, tasks[1].id), # Duplicate ] errors = DependencyService.validate_bulk_dependencies(db, dependencies, project.id) assert len(errors) > 0 assert errors[0]["error_type"] == "duplicate" class TestFormulaFieldCycleDetection: """Test formula field circular reference detection.""" def setup_project_with_fields(self, db, project_id: str, space_id: str): """Create a project with custom fields.""" space = Space( id=space_id, name="Test Space", owner_id="00000000-0000-0000-0000-000000000001", ) db.add(space) project = Project( id=project_id, space_id=space_id, title="Test Project", owner_id="00000000-0000-0000-0000-000000000001", security_level="public", ) db.add(project) status = TaskStatus( id=f"status-{project_id}", project_id=project_id, name="To Do", color="#808080", position=0, ) db.add(status) db.commit() return project def test_formula_self_reference_detected(self, db): """Test that a formula referencing itself is detected.""" project = self.setup_project_with_fields(db, "proj-formula-1", "space-formula-1") # Create a formula field field = CustomField( id="field-self-ref", project_id=project.id, name="self_ref_field", field_type="formula", formula="{self_ref_field} + 1", # References itself position=0, ) db.add(field) db.commit() # Validate the formula is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details( "{self_ref_field} + 1", project.id, db, field.id ) assert is_valid is False assert "self_ref_field" in error_msg or (cycle_path and "self_ref_field" in cycle_path) def test_formula_indirect_cycle_detected(self, db): """Test detection of indirect cycle: A -> B -> A.""" project = self.setup_project_with_fields(db, "proj-formula-2", "space-formula-2") # Create field B that references field A field_a = CustomField( id="field-a-f2", project_id=project.id, name="field_a", field_type="number", position=0, ) db.add(field_a) field_b = CustomField( id="field-b-f2", project_id=project.id, name="field_b", field_type="formula", formula="{field_a} * 2", position=1, ) db.add(field_b) db.commit() # Now try to update field_a to reference field_b (would create cycle) field_a.field_type = "formula" field_a.formula = "{field_b} + 1" db.commit() is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details( "{field_b} + 1", project.id, db, field_a.id ) assert is_valid is False assert "Circular" in error_msg or (cycle_path is not None and len(cycle_path) > 0) def test_formula_long_cycle_detected(self, db): """Test detection of longer cycle: A -> B -> C -> A.""" project = self.setup_project_with_fields(db, "proj-formula-3", "space-formula-3") # Create a chain: field_a (number), field_b = {field_a}, field_c = {field_b} field_a = CustomField( id="field-a-f3", project_id=project.id, name="field_a", field_type="number", position=0, ) field_b = CustomField( id="field-b-f3", project_id=project.id, name="field_b", field_type="formula", formula="{field_a} * 2", position=1, ) field_c = CustomField( id="field-c-f3", project_id=project.id, name="field_c", field_type="formula", formula="{field_b} + 10", position=2, ) db.add_all([field_a, field_b, field_c]) db.commit() # Now try to make field_a reference field_c (would create cycle) field_a.field_type = "formula" field_a.formula = "{field_c} / 2" db.commit() is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details( "{field_c} / 2", project.id, db, field_a.id ) assert is_valid is False # Should have a cycle path if cycle_path: assert len(cycle_path) >= 3 def test_valid_formula_chain_accepted(self, db): """Test that valid formula chains are accepted.""" project = self.setup_project_with_fields(db, "proj-formula-4", "space-formula-4") # Create valid chain: field_a (number), field_b = {field_a} field_a = CustomField( id="field-a-f4", project_id=project.id, name="field_a", field_type="number", position=0, ) db.add(field_a) db.commit() # Validate formula for field_b referencing field_a is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details( "{field_a} * 2", project.id, db ) assert is_valid is True assert error_msg is None assert cycle_path is None def test_builtin_fields_not_cause_cycle(self, db): """Test that builtin fields don't cause false cycle detection.""" project = self.setup_project_with_fields(db, "proj-formula-5", "space-formula-5") # Create formula using builtin fields field = CustomField( id="field-builtin-f5", project_id=project.id, name="progress", field_type="formula", formula="{time_spent} / {original_estimate} * 100", position=0, ) db.add(field) db.commit() is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details( "{time_spent} / {original_estimate} * 100", project.id, db, field.id ) assert is_valid is True class TestCycleDetectionInGraph: """Test cycle detection in existing graphs.""" def test_detect_cycles_in_graph_finds_existing_cycle(self, db): """Test that detect_cycles_in_graph finds existing cycles.""" # Create project space = Space( id="space-graph-1", name="Test Space", owner_id="00000000-0000-0000-0000-000000000001", ) db.add(space) project = Project( id="proj-graph-1", space_id="space-graph-1", title="Test Project", owner_id="00000000-0000-0000-0000-000000000001", security_level="public", ) db.add(project) status = TaskStatus( id="status-graph-1", project_id="proj-graph-1", name="To Do", color="#808080", position=0, ) db.add(status) # Create tasks task_a = Task( id="task-a-graph", project_id="proj-graph-1", title="Task A", priority="medium", created_by="00000000-0000-0000-0000-000000000001", status_id="status-graph-1", ) task_b = Task( id="task-b-graph", project_id="proj-graph-1", title="Task B", priority="medium", created_by="00000000-0000-0000-0000-000000000001", status_id="status-graph-1", ) db.add_all([task_a, task_b]) # Manually create a cycle (bypassing validation for testing) dep_ab = TaskDependency( id="dep-ab-graph", predecessor_id="task-a-graph", successor_id="task-b-graph", dependency_type="FS", lag_days=0, ) dep_ba = TaskDependency( id="dep-ba-graph", predecessor_id="task-b-graph", successor_id="task-a-graph", dependency_type="FS", lag_days=0, ) db.add_all([dep_ab, dep_ba]) db.commit() # Detect cycles cycles = DependencyService.detect_cycles_in_graph(db, "proj-graph-1") assert len(cycles) > 0 assert cycles[0].has_cycle is True def test_detect_cycles_in_graph_empty_when_no_cycles(self, db): """Test that detect_cycles_in_graph returns empty when no cycles.""" # Create project space = Space( id="space-graph-2", name="Test Space", owner_id="00000000-0000-0000-0000-000000000001", ) db.add(space) project = Project( id="proj-graph-2", space_id="space-graph-2", title="Test Project", owner_id="00000000-0000-0000-0000-000000000001", security_level="public", ) db.add(project) status = TaskStatus( id="status-graph-2", project_id="proj-graph-2", name="To Do", color="#808080", position=0, ) db.add(status) # Create tasks with valid chain task_a = Task( id="task-a-graph-2", project_id="proj-graph-2", title="Task A", priority="medium", created_by="00000000-0000-0000-0000-000000000001", status_id="status-graph-2", ) task_b = Task( id="task-b-graph-2", project_id="proj-graph-2", title="Task B", priority="medium", created_by="00000000-0000-0000-0000-000000000001", status_id="status-graph-2", ) task_c = Task( id="task-c-graph-2", project_id="proj-graph-2", title="Task C", priority="medium", created_by="00000000-0000-0000-0000-000000000001", status_id="status-graph-2", ) db.add_all([task_a, task_b, task_c]) # Create valid chain A -> B -> C dep_ab = TaskDependency( id="dep-ab-graph-2", predecessor_id="task-a-graph-2", successor_id="task-b-graph-2", dependency_type="FS", lag_days=0, ) dep_bc = TaskDependency( id="dep-bc-graph-2", predecessor_id="task-b-graph-2", successor_id="task-c-graph-2", dependency_type="FS", lag_days=0, ) db.add_all([dep_ab, dep_bc]) db.commit() # Detect cycles cycles = DependencyService.detect_cycles_in_graph(db, "proj-graph-2") assert len(cycles) == 0 class TestCycleDetectionResultClass: """Test CycleDetectionResult class methods.""" def test_cycle_detection_result_no_cycle(self): """Test CycleDetectionResult when no cycle.""" result = CycleDetectionResult(has_cycle=False) assert result.has_cycle is False assert result.cycle_path == [] assert result.get_cycle_description() == "" def test_cycle_detection_result_with_cycle(self): """Test CycleDetectionResult when cycle exists.""" result = CycleDetectionResult( has_cycle=True, cycle_path=["task-a", "task-b", "task-a"], cycle_task_titles=["Task A", "Task B", "Task A"] ) assert result.has_cycle is True assert result.cycle_path == ["task-a", "task-b", "task-a"] description = result.get_cycle_description() assert "Task A" in description assert "Task B" in description assert " -> " in description class TestCircularReferenceErrorClass: """Test CircularReferenceError class methods.""" def test_circular_reference_error_with_path(self): """Test CircularReferenceError with cycle path.""" error = CircularReferenceError( "Test error", cycle_path=["field_a", "field_b", "field_a"] ) assert error.message == "Test error" assert error.cycle_path == ["field_a", "field_b", "field_a"] description = error.get_cycle_description() assert "field_a" in description assert "field_b" in description assert " -> " in description def test_circular_reference_error_without_path(self): """Test CircularReferenceError without cycle path.""" error = CircularReferenceError("Test error") assert error.message == "Test error" assert error.cycle_path == [] assert error.get_cycle_description() == ""