Files
PROJECT-CONTORL/backend/tests/test_task_dependencies.py
beabigegg 35c90fe76b feat: implement 5 QA-driven security and quality proposals
Implemented proposals from comprehensive QA review:

1. extend-csrf-protection
   - Add POST to CSRF protected methods in frontend
   - Global CSRF middleware for all state-changing operations
   - Update tests with CSRF token fixtures

2. tighten-cors-websocket-security
   - Replace wildcard CORS with explicit method/header lists
   - Disable query parameter auth in production (code 4002)
   - Add per-user WebSocket connection limit (max 5, code 4005)

3. shorten-jwt-expiry
   - Reduce JWT expiry from 7 days to 60 minutes
   - Add refresh token support with 7-day expiry
   - Implement token rotation on refresh
   - Frontend auto-refresh when token near expiry (<5 min)

4. fix-frontend-quality
   - Add React.lazy() code splitting for all pages
   - Fix useCallback dependency arrays (Dashboard, Comments)
   - Add localStorage data validation in AuthContext
   - Complete i18n for AttachmentUpload component

5. enhance-backend-validation
   - Add SecurityAuditMiddleware for access denied logging
   - Add ErrorSanitizerMiddleware for production error messages
   - Protect /health/detailed with admin authentication
   - Add input length validation (comment 5000, desc 10000)

All 521 backend tests passing. Frontend builds successfully.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:19:05 +08:00

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, csrf_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}", "X-CSRF-Token": csrf_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, csrf_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}", "X-CSRF-Token": csrf_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, csrf_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}", "X-CSRF-Token": csrf_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, csrf_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}", "X-CSRF-Token": csrf_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, csrf_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}", "X-CSRF-Token": csrf_token},
)
assert response.status_code == 400
def test_create_task_with_valid_dates_accepted(self, client, db, admin_token, csrf_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}", "X-CSRF-Token": csrf_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, csrf_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}", "X-CSRF-Token": csrf_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