test(sprints): add comprehensive API route tests

Add 28 tests for sprints API covering:
- CRUD operations (create, list, get, update)
- Lifecycle management (start, complete, cancel)
- Sprint velocity endpoint
- Authorization and access control
- Pagination and filtering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 13:20:09 +01:00
parent 71c94c3b5a
commit 04c939d4c2

View File

@@ -0,0 +1,821 @@
# tests/api/routes/syndarix/test_sprints.py
"""
Comprehensive tests for the Sprints API endpoints.
Tests cover:
- CRUD operations (create, read, update, delete)
- Lifecycle operations (start, complete, cancel)
- Issue management (add/remove issues)
- Velocity metrics
- Authorization (project ownership, superuser access)
- Error handling
"""
import uuid
from datetime import date, timedelta
import pytest
import pytest_asyncio
from fastapi import status
@pytest_asyncio.fixture
async def test_project(client, user_token):
"""Create a test project for sprint tests."""
response = await client.post(
"/api/v1/projects",
json={"name": "Sprint Test Project", "slug": "sprint-test-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
return response.json()
@pytest_asyncio.fixture
async def superuser_project(client, superuser_token):
"""Create a project owned by superuser."""
response = await client.post(
"/api/v1/projects",
json={"name": "Superuser Project", "slug": "superuser-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
return response.json()
@pytest.mark.asyncio
class TestCreateSprint:
"""Tests for POST /api/v1/projects/{project_id}/sprints endpoint."""
async def test_create_sprint_success(self, client, user_token, test_project):
"""Test successful sprint creation."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint 1",
"number": 1,
"goal": "Implement core features",
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["name"] == "Sprint 1"
assert data["number"] == 1
assert data["goal"] == "Implement core features"
assert data["status"] == "planned"
assert data["project_id"] == project_id
assert "id" in data
assert "created_at" in data
async def test_create_sprint_minimal_fields(self, client, user_token, test_project):
"""Test creating sprint with only required fields."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Minimal Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["name"] == "Minimal Sprint"
assert data["goal"] is None
assert data["planned_points"] is None
async def test_create_sprint_invalid_dates(self, client, user_token, test_project):
"""Test that end date before start date is rejected."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date - timedelta(days=1)
response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Invalid Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_create_sprint_mismatched_project_id(
self, client, user_token, test_project
):
"""Test that mismatched project IDs are rejected."""
project_id = test_project["id"]
fake_project_id = str(uuid.uuid4())
start_date = date.today()
end_date = start_date + timedelta(days=14)
response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": fake_project_id, # Different from URL
"name": "Mismatched Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_create_sprint_unauthorized_project(
self, client, user_token, superuser_project
):
"""Test that users cannot create sprints in others' projects."""
project_id = superuser_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Unauthorized Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_create_sprint_nonexistent_project(self, client, user_token):
"""Test creating sprint in nonexistent project."""
fake_project_id = str(uuid.uuid4())
start_date = date.today()
end_date = start_date + timedelta(days=14)
response = await client.post(
f"/api/v1/projects/{fake_project_id}/sprints",
json={
"project_id": fake_project_id,
"name": "Orphan Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestListSprints:
"""Tests for GET /api/v1/projects/{project_id}/sprints endpoint."""
async def test_list_sprints_empty(self, client, user_token, test_project):
"""Test listing sprints when none exist."""
project_id = test_project["id"]
response = await client.get(
f"/api/v1/projects/{project_id}/sprints",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["data"] == []
assert data["pagination"]["total"] == 0
async def test_list_sprints_with_data(self, client, user_token, test_project):
"""Test listing sprints returns created sprints."""
project_id = test_project["id"]
start_date = date.today()
# Create multiple sprints
for i in range(3):
end_date = start_date + timedelta(days=14 * (i + 1))
await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": f"Sprint {i + 1}",
"number": i + 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
response = await client.get(
f"/api/v1/projects/{project_id}/sprints",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 3
assert data["pagination"]["total"] == 3
async def test_list_sprints_filter_by_status(self, client, user_token, test_project):
"""Test filtering sprints by status."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create a planned sprint
await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Planned Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
# Filter by planned status
response = await client.get(
f"/api/v1/projects/{project_id}/sprints?status=planned",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) >= 1
assert all(s["status"] == "planned" for s in data["data"])
@pytest.mark.asyncio
class TestGetSprint:
"""Tests for GET /api/v1/projects/{project_id}/sprints/{sprint_id} endpoint."""
async def test_get_sprint_success(self, client, user_token, test_project):
"""Test getting a sprint by ID."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Get Test Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Get sprint
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == sprint_id
assert data["name"] == "Get Test Sprint"
async def test_get_sprint_not_found(self, client, user_token, test_project):
"""Test getting a nonexistent sprint."""
project_id = test_project["id"]
fake_sprint_id = str(uuid.uuid4())
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/{fake_sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestGetActiveSprint:
"""Tests for GET /api/v1/projects/{project_id}/sprints/active endpoint."""
async def test_get_active_sprint_none(self, client, user_token, test_project):
"""Test getting active sprint when none exists."""
project_id = test_project["id"]
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/active",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() is None
async def test_get_active_sprint_exists(self, client, user_token, test_project):
"""Test getting active sprint when one exists."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and start a sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Active Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Start the sprint
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Get active sprint
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/active",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == sprint_id
assert data["status"] == "active"
@pytest.mark.asyncio
class TestUpdateSprint:
"""Tests for PATCH /api/v1/projects/{project_id}/sprints/{sprint_id} endpoint."""
async def test_update_sprint_success(self, client, user_token, test_project):
"""Test updating a sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Original Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Update sprint
response = await client.patch(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}",
json={"name": "Updated Sprint", "goal": "New goal"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["name"] == "Updated Sprint"
assert data["goal"] == "New goal"
async def test_update_sprint_not_found(self, client, user_token, test_project):
"""Test updating a nonexistent sprint."""
project_id = test_project["id"]
fake_sprint_id = str(uuid.uuid4())
response = await client.patch(
f"/api/v1/projects/{project_id}/sprints/{fake_sprint_id}",
json={"name": "Updated Sprint"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestSprintLifecycle:
"""Tests for sprint lifecycle endpoints (start, complete, cancel)."""
async def test_start_sprint_success(self, client, user_token, test_project):
"""Test starting a planned sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Start",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Start sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "active"
async def test_complete_sprint_success(self, client, user_token, test_project):
"""Test completing an active sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and start sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Complete",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Complete sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "completed"
async def test_cancel_sprint_success(self, client, user_token, test_project):
"""Test cancelling a planned sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Cancel",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Cancel sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "cancelled"
async def test_cannot_start_already_active_sprint(
self, client, user_token, test_project
):
"""Test that starting an already active sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and start sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Active Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to start again
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_cannot_complete_planned_sprint(
self, client, user_token, test_project
):
"""Test that completing a planned sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint (planned status)
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Planned Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Try to complete without starting
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
class TestDeleteSprint:
"""Tests for DELETE /api/v1/projects/{project_id}/sprints/{sprint_id} endpoint."""
async def test_delete_planned_sprint_success(self, client, user_token, test_project):
"""Test deleting a planned sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Delete",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Delete sprint
response = await client.delete(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["success"] is True
async def test_delete_cancelled_sprint_success(
self, client, user_token, test_project
):
"""Test deleting a cancelled sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and cancel sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Cancel and Delete",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
# Delete cancelled sprint
response = await client.delete(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["success"] is True
async def test_cannot_delete_active_sprint(self, client, user_token, test_project):
"""Test that deleting an active sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and start sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Active Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to delete active sprint
response = await client.delete(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_cannot_delete_completed_sprint(
self, client, user_token, test_project
):
"""Test that deleting a completed sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Completed Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to delete completed sprint
response = await client.delete(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
class TestSprintVelocity:
"""Tests for GET /api/v1/projects/{project_id}/sprints/velocity endpoint."""
async def test_get_velocity_empty(self, client, user_token, test_project):
"""Test getting velocity when no completed sprints exist."""
project_id = test_project["id"]
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/velocity",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
async def test_get_velocity_with_completed_sprints(
self, client, user_token, test_project
):
"""Test getting velocity with completed sprints."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete a sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint 1",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"planned_points": 20,
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
# Get velocity
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/velocity",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 1
assert data[0]["sprint_number"] == 1
@pytest.mark.asyncio
class TestSprintAuthorization:
"""Tests for sprint authorization."""
async def test_superuser_can_manage_any_project_sprints(
self, client, user_token, superuser_token, test_project
):
"""Test that superuser can manage sprints in any project."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint as superuser in user's project
response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Superuser Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
async def test_user_cannot_access_other_project_sprints(
self, client, user_token, superuser_project
):
"""Test that users cannot access sprints in others' projects."""
project_id = superuser_project["id"]
response = await client.get(
f"/api/v1/projects/{project_id}/sprints",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN