Files
PROJECT-CONTORL/backend/tests/test_concurrency_reliability.py
beabigegg 2cb591ef23 spec: update api consistency
Align optimistic locking conflict payload, update websocket docs, and adjust tests.
2026-01-11 16:54:28 +08:00

315 lines
11 KiB
Python

"""
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, 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}"}
)
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, 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}"}
)
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, 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}"}
)
# 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, 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}"}
)
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, 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}"}
)
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