Files
fast-next-template/backend/tests/api/routes/syndarix/test_sprints.py
Felipe Cardoso 664415111a test(backend): add comprehensive tests for OAuth and agent endpoints
- Added tests for OAuth provider admin and consent endpoints covering edge cases.
- Extended agent-related tests to handle incorrect project associations and lifecycle state transitions.
- Introduced tests for sprint status transitions and validation checks.
- Improved multiline formatting consistency across all test functions.
2026-01-03 01:44:11 +01:00

1542 lines
55 KiB
Python

# 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
@pytest.mark.asyncio
class TestSprintIssues:
"""Tests for sprint issue management endpoints."""
async def test_list_sprint_issues_empty(self, client, user_token, test_project):
"""Test listing issues in a sprint with no issues."""
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": "Empty 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"]
# List issues
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues",
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_sprint_issues_with_data(self, client, user_token, test_project):
"""Test listing issues in a sprint with issues."""
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 with Issues",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Create issue in the sprint
await client.post(
f"/api/v1/projects/{project_id}/issues",
json={
"project_id": project_id,
"title": "Sprint Issue 1",
"sprint_id": sprint_id,
},
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
f"/api/v1/projects/{project_id}/issues",
json={
"project_id": project_id,
"title": "Sprint Issue 2",
"sprint_id": sprint_id,
},
headers={"Authorization": f"Bearer {user_token}"},
)
# List issues
response = await client.get(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 2
assert data["pagination"]["total"] == 2
async def test_list_sprint_issues_not_found(self, client, user_token, test_project):
"""Test listing issues in 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}/issues",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_add_issue_to_sprint(self, client, user_token, test_project):
"""Test adding an issue to a sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint
sprint_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Target Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Create issue without sprint
issue_response = await client.post(
f"/api/v1/projects/{project_id}/issues",
json={
"project_id": project_id,
"title": "Issue to Add",
},
headers={"Authorization": f"Bearer {user_token}"},
)
issue_id = issue_response.json()["id"]
# Add issue to sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues",
params={"issue_id": issue_id},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
async def test_add_nonexistent_issue_to_sprint(
self, client, user_token, test_project
):
"""Test adding a nonexistent issue to a sprint."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint
sprint_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Target Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
fake_issue_id = str(uuid.uuid4())
# Try to add nonexistent issue
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues",
params={"issue_id": fake_issue_id},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestSprintCrossProjectValidation:
"""Tests for cross-project validation (IDOR prevention)."""
async def test_sprint_not_in_project(self, client, user_token):
"""Test accessing sprint that exists but not in the specified project."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Project 1 Sprint", "slug": "project-1-sprint-idor"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Project 2 Sprint", "slug": "project-2-sprint-idor"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Project 1 Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to access sprint via project2 (IDOR attempt)
response = await client.get(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_update_sprint_wrong_project(self, client, user_token):
"""Test updating sprint through wrong project."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Project A Sprint", "slug": "project-a-sprint-idor"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Project B Sprint", "slug": "project-b-sprint-idor"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Project A Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to update sprint via project2 (IDOR attempt)
response = await client.patch(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}",
json={"name": "Hacked Name"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_start_sprint_wrong_project(self, client, user_token):
"""Test starting sprint through wrong project."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Project X Sprint", "slug": "project-x-sprint-idor"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Project Y Sprint", "slug": "project-y-sprint-idor"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Project X Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to start sprint via project2 (IDOR attempt)
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestSprintStatusTransitions:
"""Tests for invalid sprint status transitions."""
async def test_cancel_completed_sprint(self, client, user_token, test_project):
"""Test that cancelling 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": "Sprint to Complete Then 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"]
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 cancel completed 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_422_UNPROCESSABLE_ENTITY
async def test_cancel_already_cancelled_sprint(
self, client, user_token, test_project
):
"""Test that cancelling an already cancelled sprint fails."""
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": "Double Cancel 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"]
# Cancel once
first_cancel = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_cancel.status_code == status.HTTP_200_OK
# Try to cancel again
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_422_UNPROCESSABLE_ENTITY
async def test_complete_already_completed_sprint(
self, client, user_token, test_project
):
"""Test that completing an already 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": "Double Complete 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}"},
)
# Complete once
first_complete = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_complete.status_code == status.HTTP_200_OK
# Try to complete again
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
async def test_complete_cancelled_sprint(self, client, user_token, test_project):
"""Test that completing a cancelled sprint fails."""
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": "Complete Cancelled 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}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to complete cancelled 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_422_UNPROCESSABLE_ENTITY
async def test_start_cancelled_sprint(self, client, user_token, test_project):
"""Test that starting a cancelled sprint fails."""
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": "Start Cancelled 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}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to start cancelled 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_422_UNPROCESSABLE_ENTITY
async def test_start_completed_sprint(self, client, user_token, test_project):
"""Test that starting 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": "Start 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 start completed 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_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
class TestSprintWrongProject:
"""Tests for sprint operations when sprint belongs to different project."""
async def test_complete_sprint_wrong_project(self, client, user_token):
"""Test completing a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Complete P1", "slug": f"complete-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Complete P2", "slug": f"complete-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and start sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Complete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
await client.post(
f"/api/v1/projects/{project1_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to complete via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_cancel_sprint_wrong_project(self, client, user_token):
"""Test cancelling a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Cancel P1", "slug": f"cancel-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Cancel P2", "slug": f"cancel-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Cancel Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to cancel via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_delete_sprint_wrong_project(self, client, user_token):
"""Test deleting a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Delete P1", "slug": f"delete-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Delete P2", "slug": f"delete-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Delete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to delete via wrong project
response = await client.delete(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_add_issue_to_sprint_wrong_project(self, client, user_token):
"""Test adding issue to sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={
"name": "Add Issue P1",
"slug": f"add-issue-p1-{uuid.uuid4().hex[:6]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={
"name": "Add Issue P2",
"slug": f"add-issue-p2-{uuid.uuid4().hex[:6]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Add Issue Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Create issue in project1
issue_response = await client.post(
f"/api/v1/projects/{project1_id}/issues",
json={
"project_id": project1_id,
"title": "Test Issue",
},
headers={"Authorization": f"Bearer {user_token}"},
)
issue_id = issue_response.json()["id"]
# Try to add issue via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/issues",
params={"issue_id": issue_id},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND