# 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