- Custom Fields (FEAT-001): - CustomField and TaskCustomValue models with formula support - CRUD API for custom field management - Formula engine for calculated fields - Frontend: CustomFieldEditor, CustomFieldInput, ProjectSettings page - Task list API now includes custom_values - KanbanBoard displays custom field values - Gantt View (FEAT-003): - TaskDependency model with FS/SS/FF/SF dependency types - Dependency CRUD API with cycle detection - start_date field added to tasks - GanttChart component with Frappe Gantt integration - Dependency type selector in UI - Calendar View (FEAT-004): - CalendarView component with FullCalendar integration - Date range filtering API for tasks - Drag-and-drop date updates - View mode switching in Tasks page - File Encryption (FEAT-010): - AES-256-GCM encryption service - EncryptionKey model with key rotation support - Admin API for key management - Encrypted upload/download for confidential projects - Migrations: 011 (custom fields), 012 (encryption keys), 013 (task dependencies) - Updated issues.md with completion status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1434 lines
44 KiB
Python
1434 lines
44 KiB
Python
"""
|
|
Tests for Task Dependencies (Gantt View Feature)
|
|
|
|
Tests cover:
|
|
- TaskDependency CRUD operations
|
|
- Circular dependency detection
|
|
- Date validation (start_date <= due_date)
|
|
- Dependency constraint validation
|
|
"""
|
|
import pytest
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import MagicMock
|
|
|
|
from app.models import Task, TaskDependency, Space, Project, TaskStatus
|
|
from app.services.dependency_service import DependencyService, DependencyValidationError
|
|
|
|
|
|
class TestDependencyService:
|
|
"""Test DependencyService validation logic."""
|
|
|
|
def test_self_reference_rejected(self, db):
|
|
"""Test that a task cannot depend on itself."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-dep-1",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-dep-1",
|
|
space_id="test-space-dep-1",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-dep-1",
|
|
project_id="test-project-dep-1",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
task = Task(
|
|
id="task-self-ref",
|
|
project_id="test-project-dep-1",
|
|
title="Self Reference Task",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-dep-1",
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
# Attempt self-reference
|
|
with pytest.raises(DependencyValidationError) as exc_info:
|
|
DependencyService.validate_dependency(
|
|
db,
|
|
predecessor_id="task-self-ref",
|
|
successor_id="task-self-ref"
|
|
)
|
|
|
|
assert exc_info.value.error_type == "self_reference"
|
|
assert "cannot depend on itself" in exc_info.value.message
|
|
|
|
def test_cross_project_rejected(self, db):
|
|
"""Test that dependencies cannot be created across different projects."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-cross",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project1 = Project(
|
|
id="test-project-cross-1",
|
|
space_id="test-space-cross",
|
|
title="Project 1",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
project2 = Project(
|
|
id="test-project-cross-2",
|
|
space_id="test-space-cross",
|
|
title="Project 2",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add_all([project1, project2])
|
|
|
|
status1 = TaskStatus(
|
|
id="test-status-cross-1",
|
|
project_id="test-project-cross-1",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
status2 = TaskStatus(
|
|
id="test-status-cross-2",
|
|
project_id="test-project-cross-2",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add_all([status1, status2])
|
|
|
|
task1 = Task(
|
|
id="task-cross-1",
|
|
project_id="test-project-cross-1",
|
|
title="Task in Project 1",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-cross-1",
|
|
)
|
|
task2 = Task(
|
|
id="task-cross-2",
|
|
project_id="test-project-cross-2",
|
|
title="Task in Project 2",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-cross-2",
|
|
)
|
|
db.add_all([task1, task2])
|
|
db.commit()
|
|
|
|
# Attempt cross-project dependency
|
|
with pytest.raises(DependencyValidationError) as exc_info:
|
|
DependencyService.validate_dependency(
|
|
db,
|
|
predecessor_id="task-cross-1",
|
|
successor_id="task-cross-2"
|
|
)
|
|
|
|
assert exc_info.value.error_type == "cross_project"
|
|
|
|
def test_duplicate_dependency_rejected(self, db):
|
|
"""Test that duplicate dependencies are rejected."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-dup",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-dup",
|
|
space_id="test-space-dup",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-dup",
|
|
project_id="test-project-dup",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
task1 = Task(
|
|
id="task-dup-1",
|
|
project_id="test-project-dup",
|
|
title="Task 1",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-dup",
|
|
)
|
|
task2 = Task(
|
|
id="task-dup-2",
|
|
project_id="test-project-dup",
|
|
title="Task 2",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-dup",
|
|
)
|
|
db.add_all([task1, task2])
|
|
|
|
# Create existing dependency
|
|
dep = TaskDependency(
|
|
id="dep-dup",
|
|
predecessor_id="task-dup-1",
|
|
successor_id="task-dup-2",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep)
|
|
db.commit()
|
|
|
|
# Attempt to create duplicate
|
|
with pytest.raises(DependencyValidationError) as exc_info:
|
|
DependencyService.validate_dependency(
|
|
db,
|
|
predecessor_id="task-dup-1",
|
|
successor_id="task-dup-2"
|
|
)
|
|
|
|
assert exc_info.value.error_type == "duplicate"
|
|
|
|
def test_dependency_limit_exceeded(self, db):
|
|
"""Test that dependency limit (10 direct dependencies) is enforced."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-limit",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-limit",
|
|
space_id="test-space-limit",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-limit",
|
|
project_id="test-project-limit",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
# Create successor task
|
|
successor = Task(
|
|
id="task-limit-successor",
|
|
project_id="test-project-limit",
|
|
title="Successor Task",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-limit",
|
|
)
|
|
db.add(successor)
|
|
|
|
# Create 10 predecessor tasks with dependencies
|
|
for i in range(10):
|
|
pred = Task(
|
|
id=f"task-limit-pred-{i}",
|
|
project_id="test-project-limit",
|
|
title=f"Predecessor Task {i}",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-limit",
|
|
)
|
|
db.add(pred)
|
|
|
|
dep = TaskDependency(
|
|
id=f"dep-limit-{i}",
|
|
predecessor_id=f"task-limit-pred-{i}",
|
|
successor_id="task-limit-successor",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep)
|
|
|
|
# Create one more predecessor
|
|
extra_pred = Task(
|
|
id="task-limit-pred-extra",
|
|
project_id="test-project-limit",
|
|
title="Extra Predecessor",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-limit",
|
|
)
|
|
db.add(extra_pred)
|
|
db.commit()
|
|
|
|
# Attempt to add 11th dependency
|
|
with pytest.raises(DependencyValidationError) as exc_info:
|
|
DependencyService.validate_dependency(
|
|
db,
|
|
predecessor_id="task-limit-pred-extra",
|
|
successor_id="task-limit-successor"
|
|
)
|
|
|
|
assert exc_info.value.error_type == "limit_exceeded"
|
|
|
|
|
|
class TestCircularDependencyDetection:
|
|
"""Test circular dependency detection."""
|
|
|
|
def test_simple_circular_dependency(self, db):
|
|
"""Test detection of A -> B -> A circular dependency."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-circ-1",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-circ-1",
|
|
space_id="test-space-circ-1",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-circ-1",
|
|
project_id="test-project-circ-1",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
taskA = Task(
|
|
id="task-circ-A",
|
|
project_id="test-project-circ-1",
|
|
title="Task A",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-circ-1",
|
|
)
|
|
taskB = Task(
|
|
id="task-circ-B",
|
|
project_id="test-project-circ-1",
|
|
title="Task B",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-circ-1",
|
|
)
|
|
db.add_all([taskA, taskB])
|
|
|
|
# A -> B (A is predecessor, B is successor)
|
|
dep_AB = TaskDependency(
|
|
id="dep-circ-AB",
|
|
predecessor_id="task-circ-A",
|
|
successor_id="task-circ-B",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep_AB)
|
|
db.commit()
|
|
|
|
# Attempt B -> A (would create cycle)
|
|
with pytest.raises(DependencyValidationError) as exc_info:
|
|
DependencyService.validate_dependency(
|
|
db,
|
|
predecessor_id="task-circ-B",
|
|
successor_id="task-circ-A"
|
|
)
|
|
|
|
assert exc_info.value.error_type == "circular"
|
|
assert "cycle" in exc_info.value.details
|
|
|
|
def test_transitive_circular_dependency(self, db):
|
|
"""Test detection of A -> B -> C -> A circular dependency."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-circ-2",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-circ-2",
|
|
space_id="test-space-circ-2",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-circ-2",
|
|
project_id="test-project-circ-2",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
taskA = Task(
|
|
id="task-circ2-A",
|
|
project_id="test-project-circ-2",
|
|
title="Task A",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-circ-2",
|
|
)
|
|
taskB = Task(
|
|
id="task-circ2-B",
|
|
project_id="test-project-circ-2",
|
|
title="Task B",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-circ-2",
|
|
)
|
|
taskC = Task(
|
|
id="task-circ2-C",
|
|
project_id="test-project-circ-2",
|
|
title="Task C",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-circ-2",
|
|
)
|
|
db.add_all([taskA, taskB, taskC])
|
|
|
|
# A -> B
|
|
dep_AB = TaskDependency(
|
|
id="dep-circ2-AB",
|
|
predecessor_id="task-circ2-A",
|
|
successor_id="task-circ2-B",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
# B -> C
|
|
dep_BC = TaskDependency(
|
|
id="dep-circ2-BC",
|
|
predecessor_id="task-circ2-B",
|
|
successor_id="task-circ2-C",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add_all([dep_AB, dep_BC])
|
|
db.commit()
|
|
|
|
# Attempt C -> A (would create cycle A -> B -> C -> A)
|
|
with pytest.raises(DependencyValidationError) as exc_info:
|
|
DependencyService.validate_dependency(
|
|
db,
|
|
predecessor_id="task-circ2-C",
|
|
successor_id="task-circ2-A"
|
|
)
|
|
|
|
assert exc_info.value.error_type == "circular"
|
|
|
|
def test_valid_dependency_chain(self, db):
|
|
"""Test that valid dependency chains are accepted."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-valid",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-valid",
|
|
space_id="test-space-valid",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-valid",
|
|
project_id="test-project-valid",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
taskA = Task(
|
|
id="task-valid-A",
|
|
project_id="test-project-valid",
|
|
title="Task A",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-valid",
|
|
)
|
|
taskB = Task(
|
|
id="task-valid-B",
|
|
project_id="test-project-valid",
|
|
title="Task B",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-valid",
|
|
)
|
|
taskC = Task(
|
|
id="task-valid-C",
|
|
project_id="test-project-valid",
|
|
title="Task C",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-valid",
|
|
)
|
|
db.add_all([taskA, taskB, taskC])
|
|
|
|
# A -> B
|
|
dep_AB = TaskDependency(
|
|
id="dep-valid-AB",
|
|
predecessor_id="task-valid-A",
|
|
successor_id="task-valid-B",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep_AB)
|
|
db.commit()
|
|
|
|
# B -> C should be valid (no cycle)
|
|
# This should NOT raise an exception
|
|
DependencyService.validate_dependency(
|
|
db,
|
|
predecessor_id="task-valid-B",
|
|
successor_id="task-valid-C"
|
|
)
|
|
|
|
|
|
class TestDateValidation:
|
|
"""Test date validation logic."""
|
|
|
|
def test_start_date_after_due_date_rejected(self, db):
|
|
"""Test that start_date > due_date is rejected."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-date-1",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-date-1",
|
|
space_id="test-space-date-1",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-date-1",
|
|
project_id="test-project-date-1",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
now = datetime.now()
|
|
task = Task(
|
|
id="task-date-invalid",
|
|
project_id="test-project-date-1",
|
|
title="Invalid Date Task",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-date-1",
|
|
start_date=now + timedelta(days=10), # Start after due
|
|
due_date=now,
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
# Validate dates - start > due should fail
|
|
violations = DependencyService.validate_date_constraints(
|
|
task,
|
|
start_date=now + timedelta(days=10),
|
|
due_date=now,
|
|
db=db
|
|
)
|
|
|
|
assert len(violations) > 0
|
|
assert violations[0]["type"] == "date_order"
|
|
|
|
def test_valid_date_range_accepted(self, db):
|
|
"""Test that valid date ranges are accepted."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-date-2",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-date-2",
|
|
space_id="test-space-date-2",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-date-2",
|
|
project_id="test-project-date-2",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
now = datetime.now()
|
|
task = Task(
|
|
id="task-date-valid",
|
|
project_id="test-project-date-2",
|
|
title="Valid Date Task",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-date-2",
|
|
start_date=now,
|
|
due_date=now + timedelta(days=10),
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
# Validate dates - start <= due should pass
|
|
violations = DependencyService.validate_date_constraints(
|
|
task,
|
|
start_date=now,
|
|
due_date=now + timedelta(days=10),
|
|
db=db
|
|
)
|
|
|
|
assert len(violations) == 0
|
|
|
|
def test_fs_dependency_date_constraint(self, db):
|
|
"""Test Finish-to-Start dependency date validation."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-fs",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-fs",
|
|
space_id="test-space-fs",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-fs",
|
|
project_id="test-project-fs",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
now = datetime.now()
|
|
|
|
# Predecessor: ends on day 5
|
|
predecessor = Task(
|
|
id="task-fs-pred",
|
|
project_id="test-project-fs",
|
|
title="Predecessor",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-fs",
|
|
start_date=now,
|
|
due_date=now + timedelta(days=5),
|
|
)
|
|
|
|
# Successor: starts on day 3 (before predecessor ends - INVALID for FS)
|
|
successor = Task(
|
|
id="task-fs-succ",
|
|
project_id="test-project-fs",
|
|
title="Successor",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-fs",
|
|
start_date=now + timedelta(days=3),
|
|
due_date=now + timedelta(days=10),
|
|
)
|
|
db.add_all([predecessor, successor])
|
|
|
|
# Create FS dependency
|
|
dep = TaskDependency(
|
|
id="dep-fs",
|
|
predecessor_id="task-fs-pred",
|
|
successor_id="task-fs-succ",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep)
|
|
db.commit()
|
|
|
|
# Validate successor's dates - should fail because start < predecessor's due
|
|
violations = DependencyService.validate_date_constraints(
|
|
successor,
|
|
start_date=now + timedelta(days=3),
|
|
due_date=now + timedelta(days=10),
|
|
db=db
|
|
)
|
|
|
|
assert len(violations) > 0
|
|
assert any(v["type"] == "dependency_constraint" for v in violations)
|
|
|
|
def test_fs_dependency_with_lag(self, db):
|
|
"""Test Finish-to-Start dependency with lag days."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-fs-lag",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-fs-lag",
|
|
space_id="test-space-fs-lag",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-fs-lag",
|
|
project_id="test-project-fs-lag",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
now = datetime.now()
|
|
|
|
# Predecessor: ends on day 5
|
|
predecessor = Task(
|
|
id="task-fs-lag-pred",
|
|
project_id="test-project-fs-lag",
|
|
title="Predecessor",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-fs-lag",
|
|
start_date=now,
|
|
due_date=now + timedelta(days=5),
|
|
)
|
|
|
|
# Successor: starts on day 6 (only 1 day after predecessor ends)
|
|
# With 2 days lag, this is INVALID (should start on day 7)
|
|
successor = Task(
|
|
id="task-fs-lag-succ",
|
|
project_id="test-project-fs-lag",
|
|
title="Successor",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-fs-lag",
|
|
start_date=now + timedelta(days=6),
|
|
due_date=now + timedelta(days=10),
|
|
)
|
|
db.add_all([predecessor, successor])
|
|
|
|
# Create FS dependency with 2 days lag
|
|
dep = TaskDependency(
|
|
id="dep-fs-lag",
|
|
predecessor_id="task-fs-lag-pred",
|
|
successor_id="task-fs-lag-succ",
|
|
dependency_type="FS",
|
|
lag_days=2,
|
|
)
|
|
db.add(dep)
|
|
db.commit()
|
|
|
|
# Validate successor's dates - should fail because start < predecessor's due + lag
|
|
violations = DependencyService.validate_date_constraints(
|
|
successor,
|
|
start_date=now + timedelta(days=6),
|
|
due_date=now + timedelta(days=10),
|
|
db=db
|
|
)
|
|
|
|
assert len(violations) > 0
|
|
|
|
|
|
class TestDependencyCRUDAPI:
|
|
"""Test dependency CRUD API endpoints."""
|
|
|
|
def test_create_dependency(self, client, db, admin_token):
|
|
"""Test creating a dependency via API."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-api-1",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-api-1",
|
|
space_id="test-space-api-1",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-api-1",
|
|
project_id="test-project-api-1",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
task1 = Task(
|
|
id="task-api-1",
|
|
project_id="test-project-api-1",
|
|
title="Task 1",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-1",
|
|
)
|
|
task2 = Task(
|
|
id="task-api-2",
|
|
project_id="test-project-api-1",
|
|
title="Task 2",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-1",
|
|
)
|
|
db.add_all([task1, task2])
|
|
db.commit()
|
|
|
|
# Create dependency via API
|
|
response = client.post(
|
|
"/api/tasks/task-api-2/dependencies",
|
|
json={
|
|
"predecessor_id": "task-api-1",
|
|
"dependency_type": "FS",
|
|
"lag_days": 0
|
|
},
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["predecessor_id"] == "task-api-1"
|
|
assert data["successor_id"] == "task-api-2"
|
|
assert data["dependency_type"] == "FS"
|
|
|
|
def test_list_task_dependencies(self, client, db, admin_token):
|
|
"""Test listing dependencies for a task."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-api-2",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-api-2",
|
|
space_id="test-space-api-2",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-api-2",
|
|
project_id="test-project-api-2",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
task1 = Task(
|
|
id="task-api-list-1",
|
|
project_id="test-project-api-2",
|
|
title="Task 1",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-2",
|
|
)
|
|
task2 = Task(
|
|
id="task-api-list-2",
|
|
project_id="test-project-api-2",
|
|
title="Task 2",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-2",
|
|
)
|
|
db.add_all([task1, task2])
|
|
|
|
dep = TaskDependency(
|
|
id="dep-api-list",
|
|
predecessor_id="task-api-list-1",
|
|
successor_id="task-api-list-2",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep)
|
|
db.commit()
|
|
|
|
# List dependencies
|
|
response = client.get(
|
|
"/api/tasks/task-api-list-2/dependencies",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] >= 1
|
|
assert any(d["predecessor_id"] == "task-api-list-1" for d in data["dependencies"])
|
|
|
|
def test_delete_dependency(self, client, db, admin_token):
|
|
"""Test deleting a dependency."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-api-3",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-api-3",
|
|
space_id="test-space-api-3",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-api-3",
|
|
project_id="test-project-api-3",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
task1 = Task(
|
|
id="task-api-del-1",
|
|
project_id="test-project-api-3",
|
|
title="Task 1",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-3",
|
|
)
|
|
task2 = Task(
|
|
id="task-api-del-2",
|
|
project_id="test-project-api-3",
|
|
title="Task 2",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-3",
|
|
)
|
|
db.add_all([task1, task2])
|
|
|
|
dep = TaskDependency(
|
|
id="dep-api-del",
|
|
predecessor_id="task-api-del-1",
|
|
successor_id="task-api-del-2",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep)
|
|
db.commit()
|
|
|
|
# Delete dependency
|
|
response = client.delete(
|
|
"/api/task-dependencies/dep-api-del",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
|
|
# Verify it's deleted
|
|
dep_check = db.query(TaskDependency).filter(
|
|
TaskDependency.id == "dep-api-del"
|
|
).first()
|
|
assert dep_check is None
|
|
|
|
def test_circular_dependency_rejected_via_api(self, client, db, admin_token):
|
|
"""Test that circular dependencies are rejected via API."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-api-circ",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-api-circ",
|
|
space_id="test-space-api-circ",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-api-circ",
|
|
project_id="test-project-api-circ",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
task1 = Task(
|
|
id="task-api-circ-1",
|
|
project_id="test-project-api-circ",
|
|
title="Task 1",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-circ",
|
|
)
|
|
task2 = Task(
|
|
id="task-api-circ-2",
|
|
project_id="test-project-api-circ",
|
|
title="Task 2",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-api-circ",
|
|
)
|
|
db.add_all([task1, task2])
|
|
|
|
# Create dependency: task1 -> task2
|
|
dep = TaskDependency(
|
|
id="dep-api-circ",
|
|
predecessor_id="task-api-circ-1",
|
|
successor_id="task-api-circ-2",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add(dep)
|
|
db.commit()
|
|
|
|
# Try to create circular dependency: task2 -> task1
|
|
response = client.post(
|
|
"/api/tasks/task-api-circ-1/dependencies",
|
|
json={
|
|
"predecessor_id": "task-api-circ-2",
|
|
"dependency_type": "FS",
|
|
"lag_days": 0
|
|
},
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert data["detail"]["error_type"] == "circular"
|
|
|
|
|
|
class TestTaskDateValidationAPI:
|
|
"""Test task date validation in task API."""
|
|
|
|
def test_create_task_with_invalid_dates_rejected(self, client, db, admin_token):
|
|
"""Test that creating a task with start_date > due_date is rejected."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-task-date-1",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-task-date-1",
|
|
space_id="test-space-task-date-1",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-task-date-1",
|
|
project_id="test-project-task-date-1",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
db.commit()
|
|
|
|
now = datetime.now()
|
|
|
|
# Try to create task with invalid dates
|
|
response = client.post(
|
|
"/api/projects/test-project-task-date-1/tasks",
|
|
json={
|
|
"title": "Invalid Date Task",
|
|
"start_date": (now + timedelta(days=10)).isoformat(),
|
|
"due_date": now.isoformat(),
|
|
},
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "Start date cannot be after due date" in response.json()["detail"]
|
|
|
|
def test_update_task_with_invalid_dates_rejected(self, client, db, admin_token):
|
|
"""Test that updating a task to have start_date > due_date is rejected."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-task-date-2",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-task-date-2",
|
|
space_id="test-space-task-date-2",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-task-date-2",
|
|
project_id="test-project-task-date-2",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
now = datetime.now()
|
|
task = Task(
|
|
id="task-update-date",
|
|
project_id="test-project-task-date-2",
|
|
title="Valid Date Task",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-task-date-2",
|
|
start_date=now,
|
|
due_date=now + timedelta(days=10),
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
# Try to update with invalid dates
|
|
response = client.patch(
|
|
"/api/tasks/task-update-date",
|
|
json={
|
|
"start_date": (now + timedelta(days=20)).isoformat(),
|
|
},
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
def test_create_task_with_valid_dates_accepted(self, client, db, admin_token):
|
|
"""Test that creating a task with valid dates is accepted."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-task-date-3",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-task-date-3",
|
|
space_id="test-space-task-date-3",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-task-date-3",
|
|
project_id="test-project-task-date-3",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
db.commit()
|
|
|
|
now = datetime.now()
|
|
|
|
# Create task with valid dates
|
|
response = client.post(
|
|
"/api/projects/test-project-task-date-3/tasks",
|
|
json={
|
|
"title": "Valid Date Task",
|
|
"start_date": now.isoformat(),
|
|
"due_date": (now + timedelta(days=10)).isoformat(),
|
|
},
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["title"] == "Valid Date Task"
|
|
|
|
|
|
class TestDependencyTypes:
|
|
"""Test different dependency types."""
|
|
|
|
def test_dependency_type_values(self):
|
|
"""Test that all dependency types are valid."""
|
|
from app.schemas.task_dependency import DependencyType
|
|
|
|
assert DependencyType.FS.value == "FS"
|
|
assert DependencyType.SS.value == "SS"
|
|
assert DependencyType.FF.value == "FF"
|
|
assert DependencyType.SF.value == "SF"
|
|
|
|
def test_create_dependency_with_different_types(self, client, db, admin_token):
|
|
"""Test creating dependencies with different types via API."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-dep-types",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-dep-types",
|
|
space_id="test-space-dep-types",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-dep-types",
|
|
project_id="test-project-dep-types",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
# Create multiple tasks
|
|
for i in range(5):
|
|
task = Task(
|
|
id=f"task-dep-type-{i}",
|
|
project_id="test-project-dep-types",
|
|
title=f"Task {i}",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-dep-types",
|
|
)
|
|
db.add(task)
|
|
db.commit()
|
|
|
|
# Test each dependency type
|
|
dep_types = ["FS", "SS", "FF", "SF"]
|
|
for i, dep_type in enumerate(dep_types):
|
|
response = client.post(
|
|
f"/api/tasks/task-dep-type-{i+1}/dependencies",
|
|
json={
|
|
"predecessor_id": "task-dep-type-0",
|
|
"dependency_type": dep_type,
|
|
"lag_days": i
|
|
},
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["dependency_type"] == dep_type
|
|
assert data["lag_days"] == i
|
|
|
|
|
|
class TestTransitiveDependencies:
|
|
"""Test transitive dependency queries."""
|
|
|
|
def test_get_all_predecessors(self, db):
|
|
"""Test getting all transitive predecessors of a task."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-trans",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-trans",
|
|
space_id="test-space-trans",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-trans",
|
|
project_id="test-project-trans",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
# Create chain: A -> B -> C -> D
|
|
for task_id in ["A", "B", "C", "D"]:
|
|
task = Task(
|
|
id=f"task-trans-{task_id}",
|
|
project_id="test-project-trans",
|
|
title=f"Task {task_id}",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-trans",
|
|
)
|
|
db.add(task)
|
|
|
|
# A -> B
|
|
dep1 = TaskDependency(
|
|
id="dep-trans-AB",
|
|
predecessor_id="task-trans-A",
|
|
successor_id="task-trans-B",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
# B -> C
|
|
dep2 = TaskDependency(
|
|
id="dep-trans-BC",
|
|
predecessor_id="task-trans-B",
|
|
successor_id="task-trans-C",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
# C -> D
|
|
dep3 = TaskDependency(
|
|
id="dep-trans-CD",
|
|
predecessor_id="task-trans-C",
|
|
successor_id="task-trans-D",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add_all([dep1, dep2, dep3])
|
|
db.commit()
|
|
|
|
# Get all predecessors of D
|
|
predecessors = DependencyService.get_all_predecessors(db, "task-trans-D")
|
|
|
|
# D depends on C, C depends on B, B depends on A
|
|
assert "task-trans-C" in predecessors
|
|
assert "task-trans-B" in predecessors
|
|
assert "task-trans-A" in predecessors
|
|
assert len(predecessors) == 3
|
|
|
|
def test_get_all_successors(self, db):
|
|
"""Test getting all transitive successors of a task."""
|
|
# Create test data
|
|
space = Space(
|
|
id="test-space-succ",
|
|
name="Test Space",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
)
|
|
db.add(space)
|
|
|
|
project = Project(
|
|
id="test-project-succ",
|
|
space_id="test-space-succ",
|
|
title="Test Project",
|
|
owner_id="00000000-0000-0000-0000-000000000001",
|
|
security_level="public",
|
|
)
|
|
db.add(project)
|
|
|
|
status = TaskStatus(
|
|
id="test-status-succ",
|
|
project_id="test-project-succ",
|
|
name="To Do",
|
|
color="#808080",
|
|
position=0,
|
|
)
|
|
db.add(status)
|
|
|
|
# Create chain: A -> B -> C -> D
|
|
for task_id in ["A", "B", "C", "D"]:
|
|
task = Task(
|
|
id=f"task-succ-{task_id}",
|
|
project_id="test-project-succ",
|
|
title=f"Task {task_id}",
|
|
priority="medium",
|
|
created_by="00000000-0000-0000-0000-000000000001",
|
|
status_id="test-status-succ",
|
|
)
|
|
db.add(task)
|
|
|
|
# A -> B
|
|
dep1 = TaskDependency(
|
|
id="dep-succ-AB",
|
|
predecessor_id="task-succ-A",
|
|
successor_id="task-succ-B",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
# B -> C
|
|
dep2 = TaskDependency(
|
|
id="dep-succ-BC",
|
|
predecessor_id="task-succ-B",
|
|
successor_id="task-succ-C",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
# C -> D
|
|
dep3 = TaskDependency(
|
|
id="dep-succ-CD",
|
|
predecessor_id="task-succ-C",
|
|
successor_id="task-succ-D",
|
|
dependency_type="FS",
|
|
lag_days=0,
|
|
)
|
|
db.add_all([dep1, dep2, dep3])
|
|
db.commit()
|
|
|
|
# Get all successors of A
|
|
successors = DependencyService.get_all_successors(db, "task-succ-A")
|
|
|
|
# A is predecessor of B, B is predecessor of C, C is predecessor of D
|
|
assert "task-succ-B" in successors
|
|
assert "task-succ-C" in successors
|
|
assert "task-succ-D" in successors
|
|
assert len(successors) == 3
|