## Security Enhancements (P0) - Add input validation with max_length and numeric range constraints - Implement WebSocket token authentication via first message - Add path traversal prevention in file storage service ## Permission Enhancements (P0) - Add project member management for cross-department access - Implement is_department_manager flag for workload visibility ## Cycle Detection (P0) - Add DFS-based cycle detection for task dependencies - Add formula field circular reference detection - Display user-friendly cycle path visualization ## Concurrency & Reliability (P1) - Implement optimistic locking with version field (409 Conflict on mismatch) - Add trigger retry mechanism with exponential backoff (1s, 2s, 4s) - Implement cascade restore for soft-deleted tasks ## Rate Limiting (P1) - Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min) - Apply rate limits to tasks, reports, attachments, and comments ## Frontend Improvements (P1) - Add responsive sidebar with hamburger menu for mobile - Improve touch-friendly UI with proper tap target sizes - Complete i18n translations for all components ## Backend Reliability (P2) - Configure database connection pool (size=10, overflow=20) - Add Redis fallback mechanism with message queue - Add blocker check before task deletion ## API Enhancements (P3) - Add standardized response wrapper utility - Add /health/ready and /health/live endpoints - Implement project templates with status/field copying ## Tests Added - test_input_validation.py - Schema and path traversal tests - test_concurrency_reliability.py - Optimistic locking and retry tests - test_backend_reliability.py - Connection pool and Redis tests - test_api_enhancements.py - Health check and template tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
733 lines
24 KiB
Python
733 lines
24 KiB
Python
"""
|
|
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() == ""
|