""" Tests for concurrency handling and reliability improvements. Tests cover: - Optimistic locking with version conflicts - Trigger retry mechanism - Cascade restore for soft-deleted tasks """ import os os.environ["TESTING"] = "true" import pytest from unittest.mock import patch, MagicMock from datetime import datetime, timedelta class TestOptimisticLocking: """Test optimistic locking for concurrent updates.""" def test_version_increments_on_update(self, client, admin_token, csrf_token, db): """Test that task version increments on successful update.""" from app.models import Space, Project, Task, TaskStatus # Create test data space = Space(id="space-1", name="Test Space", owner_id="00000000-0000-0000-0000-000000000001") db.add(space) project = Project(id="project-1", name="Test Project", space_id="space-1", owner_id="00000000-0000-0000-0000-000000000001") db.add(project) status = TaskStatus(id="status-1", name="To Do", project_id="project-1", position=0) db.add(status) task = Task( id="task-1", title="Test Task", project_id="project-1", status_id="status-1", created_by="00000000-0000-0000-0000-000000000001", version=1 ) db.add(task) db.commit() # Update task with correct version response = client.patch( "/api/tasks/task-1", json={"title": "Updated Task", "version": 1}, headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token} ) assert response.status_code == 200 data = response.json() assert data["title"] == "Updated Task" assert data["version"] == 2 # Version should increment def test_version_conflict_returns_409(self, client, admin_token, csrf_token, db): """Test that stale version returns 409 Conflict.""" from app.models import Space, Project, Task, TaskStatus # Create test data space = Space(id="space-2", name="Test Space 2", owner_id="00000000-0000-0000-0000-000000000001") db.add(space) project = Project(id="project-2", name="Test Project 2", space_id="space-2", owner_id="00000000-0000-0000-0000-000000000001") db.add(project) status = TaskStatus(id="status-2", name="To Do", project_id="project-2", position=0) db.add(status) task = Task( id="task-2", title="Test Task", project_id="project-2", status_id="status-2", created_by="00000000-0000-0000-0000-000000000001", version=5 # Task is at version 5 ) db.add(task) db.commit() # Try to update with stale version (1) response = client.patch( "/api/tasks/task-2", json={"title": "Stale Update", "version": 1}, headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token} ) assert response.status_code == 409 detail = response.json().get("detail") assert isinstance(detail, dict) assert detail.get("error") == "conflict" assert detail.get("current_version") == 5 assert detail.get("provided_version") == 1 def test_update_without_version_succeeds(self, client, admin_token, csrf_token, db): """Test that update without version (for backward compatibility) still works.""" from app.models import Space, Project, Task, TaskStatus # Create test data space = Space(id="space-3", name="Test Space 3", owner_id="00000000-0000-0000-0000-000000000001") db.add(space) project = Project(id="project-3", name="Test Project 3", space_id="space-3", owner_id="00000000-0000-0000-0000-000000000001") db.add(project) status = TaskStatus(id="status-3", name="To Do", project_id="project-3", position=0) db.add(status) task = Task( id="task-3", title="Test Task", project_id="project-3", status_id="status-3", created_by="00000000-0000-0000-0000-000000000001", version=1 ) db.add(task) db.commit() # Update without version field response = client.patch( "/api/tasks/task-3", json={"title": "No Version Update"}, headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token} ) # Should succeed (backward compatibility) assert response.status_code == 200 class TestTriggerRetryMechanism: """Test trigger retry with exponential backoff.""" def test_trigger_scheduler_has_retry_config(self): """Test that trigger scheduler has retry configuration.""" from app.services.trigger_scheduler import MAX_RETRIES, BASE_DELAY_SECONDS # Verify configuration exists assert MAX_RETRIES == 3 assert BASE_DELAY_SECONDS == 1 def test_retry_mechanism_structure(self): """Test that retry mechanism follows exponential backoff pattern.""" from app.services.trigger_scheduler import TriggerSchedulerService # The service should have the retry method assert hasattr(TriggerSchedulerService, '_execute_trigger_with_retry') def test_exponential_backoff_calculation(self): """Test exponential backoff delay calculation.""" from app.services.trigger_scheduler import BASE_DELAY_SECONDS # Verify backoff pattern (1s, 2s, 4s) delays = [BASE_DELAY_SECONDS * (2 ** i) for i in range(3)] assert delays == [1, 2, 4] def test_retry_on_failure_mock(self, db): """Test retry behavior using mock.""" from app.services.trigger_scheduler import TriggerSchedulerService from app.models import ScheduleTrigger service = TriggerSchedulerService() call_count = [0] def mock_execute(*args, **kwargs): call_count[0] += 1 if call_count[0] < 3: raise Exception("Transient failure") return {"success": True} # Test the retry logic conceptually # The actual retry happens internally, we verify the config exists assert hasattr(service, 'execute_trigger') or hasattr(TriggerSchedulerService, '_execute_trigger_with_retry') class TestCascadeRestore: """Test cascade restore for soft-deleted tasks.""" def test_restore_parent_with_children(self, client, admin_token, csrf_token, db): """Test restoring parent task also restores children deleted at same time.""" from app.models import Space, Project, Task, TaskStatus from datetime import datetime # Create test data space = Space(id="space-4", name="Test Space 4", owner_id="00000000-0000-0000-0000-000000000001") db.add(space) project = Project(id="project-4", name="Test Project 4", space_id="space-4", owner_id="00000000-0000-0000-0000-000000000001") db.add(project) status = TaskStatus(id="status-4", name="To Do", project_id="project-4", position=0) db.add(status) deleted_time = datetime.utcnow() parent_task = Task( id="parent-task", title="Parent Task", project_id="project-4", status_id="status-4", created_by="00000000-0000-0000-0000-000000000001", is_deleted=True, deleted_at=deleted_time ) db.add(parent_task) child_task1 = Task( id="child-task-1", title="Child Task 1", project_id="project-4", status_id="status-4", parent_task_id="parent-task", created_by="00000000-0000-0000-0000-000000000001", is_deleted=True, deleted_at=deleted_time ) db.add(child_task1) child_task2 = Task( id="child-task-2", title="Child Task 2", project_id="project-4", status_id="status-4", parent_task_id="parent-task", created_by="00000000-0000-0000-0000-000000000001", is_deleted=True, deleted_at=deleted_time ) db.add(child_task2) db.commit() # Restore parent with cascade=True response = client.post( "/api/tasks/parent-task/restore", json={"cascade": True}, headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token} ) assert response.status_code == 200 data = response.json() assert data["restored_children_count"] == 2 assert "child-task-1" in data["restored_children_ids"] assert "child-task-2" in data["restored_children_ids"] # Verify tasks are restored db.refresh(parent_task) db.refresh(child_task1) db.refresh(child_task2) assert parent_task.is_deleted is False assert child_task1.is_deleted is False assert child_task2.is_deleted is False def test_restore_parent_only(self, client, admin_token, csrf_token, db): """Test restoring parent task without cascade leaves children deleted.""" from app.models import Space, Project, Task, TaskStatus from datetime import datetime # Create test data space = Space(id="space-5", name="Test Space 5", owner_id="00000000-0000-0000-0000-000000000001") db.add(space) project = Project(id="project-5", name="Test Project 5", space_id="space-5", owner_id="00000000-0000-0000-0000-000000000001") db.add(project) status = TaskStatus(id="status-5", name="To Do", project_id="project-5", position=0) db.add(status) deleted_time = datetime.utcnow() parent_task = Task( id="parent-task-2", title="Parent Task 2", project_id="project-5", status_id="status-5", created_by="00000000-0000-0000-0000-000000000001", is_deleted=True, deleted_at=deleted_time ) db.add(parent_task) child_task = Task( id="child-task-3", title="Child Task 3", project_id="project-5", status_id="status-5", parent_task_id="parent-task-2", created_by="00000000-0000-0000-0000-000000000001", is_deleted=True, deleted_at=deleted_time ) db.add(child_task) db.commit() # Restore parent with cascade=False response = client.post( "/api/tasks/parent-task-2/restore", json={"cascade": False}, headers={"Authorization": f"Bearer {admin_token}", "X-CSRF-Token": csrf_token} ) assert response.status_code == 200 data = response.json() assert data["restored_children_count"] == 0 # Verify parent restored but child still deleted db.refresh(parent_task) db.refresh(child_task) assert parent_task.is_deleted is False assert child_task.is_deleted is True