Files
fast-next-template/backend/tests/api/routes/syndarix/test_projects.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

1049 lines
38 KiB
Python

# tests/api/routes/syndarix/test_projects.py
"""
Comprehensive tests for the Projects API endpoints.
Tests cover:
- CRUD operations (create, read, update, archive)
- Lifecycle operations (pause, resume)
- Authorization (ownership checks, superuser access)
- Pagination and filtering
- Error handling (not found, validation, duplicates)
"""
import uuid
import pytest
from fastapi import status
@pytest.mark.asyncio
class TestCreateProject:
"""Tests for POST /api/v1/projects endpoint."""
async def test_create_project_success(self, client, user_token):
"""Test successful project creation."""
response = await client.post(
"/api/v1/projects",
json={
"name": "Test Project",
"slug": "test-project",
"description": "A test project description",
"autonomy_level": "milestone",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["name"] == "Test Project"
assert data["slug"] == "test-project"
assert data["description"] == "A test project description"
assert data["autonomy_level"] == "milestone"
assert data["status"] == "active"
assert data["agent_count"] == 0
assert data["issue_count"] == 0
assert data["active_sprint_name"] is None
assert "id" in data
assert "created_at" in data
assert "updated_at" in data
async def test_create_project_minimal_fields(self, client, user_token):
"""Test creating project with only required fields."""
response = await client.post(
"/api/v1/projects",
json={
"name": "Minimal Project",
"slug": "minimal-project",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["name"] == "Minimal Project"
assert data["slug"] == "minimal-project"
assert data["autonomy_level"] == "milestone" # Default
assert data["status"] == "active" # Default
async def test_create_project_with_settings(self, client, user_token):
"""Test creating project with custom settings."""
response = await client.post(
"/api/v1/projects",
json={
"name": "Project With Settings",
"slug": "project-with-settings",
"settings": {"theme": "dark", "notifications": True},
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["settings"]["theme"] == "dark"
assert data["settings"]["notifications"] is True
async def test_create_project_duplicate_slug(self, client, user_token):
"""Test that duplicate slugs are rejected."""
# Create first project
await client.post(
"/api/v1/projects",
json={"name": "First Project", "slug": "duplicate-slug"},
headers={"Authorization": f"Bearer {user_token}"},
)
# Attempt duplicate
response = await client.post(
"/api/v1/projects",
json={"name": "Second Project", "slug": "duplicate-slug"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_409_CONFLICT
data = response.json()
assert "already exists" in data["errors"][0]["message"].lower()
async def test_create_project_invalid_slug_format(self, client, user_token):
"""Test that invalid slug formats are rejected."""
# Uppercase letters
response = await client.post(
"/api/v1/projects",
json={"name": "Bad Slug", "slug": "Invalid-Slug"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Starting with hyphen
response = await client.post(
"/api/v1/projects",
json={"name": "Bad Slug", "slug": "-invalid"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Consecutive hyphens
response = await client.post(
"/api/v1/projects",
json={"name": "Bad Slug", "slug": "invalid--slug"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_create_project_empty_name(self, client, user_token):
"""Test that empty name is rejected."""
response = await client.post(
"/api/v1/projects",
json={"name": "", "slug": "empty-name"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_create_project_unauthenticated(self, client):
"""Test that unauthenticated requests are rejected."""
response = await client.post(
"/api/v1/projects",
json={"name": "Test Project", "slug": "test-project"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_create_project_all_autonomy_levels(self, client, user_token):
"""Test creating projects with all autonomy levels."""
levels = ["full_control", "milestone", "autonomous"]
for level in levels:
# Slugs must use hyphens, not underscores
slug = f"project-{level.replace('_', '-')}"
response = await client.post(
"/api/v1/projects",
json={
"name": f"Project {level}",
"slug": slug,
"autonomy_level": level,
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["autonomy_level"] == level
@pytest.mark.asyncio
class TestListProjects:
"""Tests for GET /api/v1/projects endpoint."""
async def test_list_projects_empty(self, client, user_token):
"""Test listing projects when none exist."""
response = await client.get(
"/api/v1/projects",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "data" in data
assert "pagination" in data
assert data["data"] == []
assert data["pagination"]["total"] == 0
async def test_list_projects_with_data(self, client, user_token):
"""Test listing projects returns created projects."""
# Create projects
for i in range(3):
await client.post(
"/api/v1/projects",
json={"name": f"Project {i}", "slug": f"project-{i}"},
headers={"Authorization": f"Bearer {user_token}"},
)
response = await client.get(
"/api/v1/projects",
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_projects_pagination(self, client, user_token):
"""Test pagination works correctly."""
# Create 5 projects
for i in range(5):
await client.post(
"/api/v1/projects",
json={"name": f"Project {i}", "slug": f"project-{i}"},
headers={"Authorization": f"Bearer {user_token}"},
)
# Get first page (2 items)
response = await client.get(
"/api/v1/projects?page=1&limit=2",
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"] == 5
assert data["pagination"]["page"] == 1
assert data["pagination"]["total_pages"] == 3
# Get second page
response = await client.get(
"/api/v1/projects?page=2&limit=2",
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"]["page"] == 2
async def test_list_projects_filter_by_status(self, client, user_token):
"""Test filtering projects by status."""
# Create active project
await client.post(
"/api/v1/projects",
json={
"name": "Active Project",
"slug": "active-project",
"status": "active",
},
headers={"Authorization": f"Bearer {user_token}"},
)
# Create paused project
await client.post(
"/api/v1/projects",
json={
"name": "Paused Project",
"slug": "paused-project",
"status": "paused",
},
headers={"Authorization": f"Bearer {user_token}"},
)
# Filter by active
response = await client.get(
"/api/v1/projects?status=active",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 1
assert data["data"][0]["status"] == "active"
async def test_list_projects_search(self, client, user_token):
"""Test searching projects by name."""
await client.post(
"/api/v1/projects",
json={"name": "Alpha Project", "slug": "alpha-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
"/api/v1/projects",
json={"name": "Beta Project", "slug": "beta-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
response = await client.get(
"/api/v1/projects?search=alpha",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 1
assert "alpha" in data["data"][0]["name"].lower()
async def test_list_projects_user_isolation(
self, client, user_token, superuser_token, async_test_user
):
"""Test that users only see their own projects."""
# Create project as regular user
await client.post(
"/api/v1/projects",
json={"name": "User Project", "slug": "user-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
# Create project as superuser
await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Regular user should only see their project
response = await client.get(
"/api/v1/projects",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 1
assert data["data"][0]["slug"] == "user-project"
async def test_list_projects_superuser_all_projects(
self, client, user_token, superuser_token
):
"""Test that superuser can see all projects with all_projects flag."""
# Create project as regular user
await client.post(
"/api/v1/projects",
json={"name": "User Project", "slug": "user-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
# Create project as superuser
await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Superuser with all_projects=true should see both
response = await client.get(
"/api/v1/projects?all_projects=true",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 2
@pytest.mark.asyncio
class TestGetProject:
"""Tests for GET /api/v1/projects/{project_id} endpoint."""
async def test_get_project_success(self, client, user_token):
"""Test getting a project by ID."""
# Create project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Get Test Project", "slug": "get-test-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Get project
response = await client.get(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == project_id
assert data["name"] == "Get Test Project"
async def test_get_project_not_found(self, client, user_token):
"""Test getting a non-existent project."""
fake_id = str(uuid.uuid4())
response = await client.get(
f"/api/v1/projects/{fake_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "not found" in response.json()["errors"][0]["message"].lower()
async def test_get_project_unauthorized(self, client, user_token, superuser_token):
"""Test that users cannot access other users' projects."""
# Create project as superuser
create_response = await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
project_id = create_response.json()["id"]
# Try to access as regular user
response = await client.get(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_get_project_superuser_can_access_any(
self, client, user_token, superuser_token
):
"""Test that superuser can access any project."""
# Create project as regular user
create_response = await client.post(
"/api/v1/projects",
json={"name": "User Project", "slug": "user-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Access as superuser
response = await client.get(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.asyncio
class TestGetProjectBySlug:
"""Tests for GET /api/v1/projects/slug/{slug} endpoint."""
async def test_get_project_by_slug_success(self, client, user_token):
"""Test getting a project by slug."""
# Create project
await client.post(
"/api/v1/projects",
json={"name": "Slug Test Project", "slug": "slug-test-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
# Get by slug
response = await client.get(
"/api/v1/projects/slug/slug-test-project",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["slug"] == "slug-test-project"
assert data["name"] == "Slug Test Project"
async def test_get_project_by_slug_not_found(self, client, user_token):
"""Test getting a non-existent project by slug."""
response = await client.get(
"/api/v1/projects/slug/non-existent-slug",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_get_project_by_slug_unauthorized(
self, client, user_token, superuser_token
):
"""Test that users cannot access other users' projects by slug."""
# Create project as superuser
await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Try to access as regular user
response = await client.get(
"/api/v1/projects/slug/admin-project",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
class TestUpdateProject:
"""Tests for PATCH /api/v1/projects/{project_id} endpoint."""
async def test_update_project_success(self, client, user_token):
"""Test updating a project."""
# Create project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Original Name", "slug": "original-slug"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Update project
response = await client.patch(
f"/api/v1/projects/{project_id}",
json={"name": "Updated Name", "description": "New description"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["name"] == "Updated Name"
assert data["description"] == "New description"
assert data["slug"] == "original-slug" # Unchanged
async def test_update_project_slug(self, client, user_token):
"""Test updating project slug."""
# Create project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Test Project", "slug": "old-slug"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Update slug
response = await client.patch(
f"/api/v1/projects/{project_id}",
json={"slug": "new-slug"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["slug"] == "new-slug"
async def test_update_project_autonomy_level(self, client, user_token):
"""Test updating project autonomy level."""
# Create project with milestone autonomy
create_response = await client.post(
"/api/v1/projects",
json={
"name": "Test Project",
"slug": "test-project",
"autonomy_level": "milestone",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Update to autonomous
response = await client.patch(
f"/api/v1/projects/{project_id}",
json={"autonomy_level": "autonomous"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["autonomy_level"] == "autonomous"
async def test_update_project_not_found(self, client, user_token):
"""Test updating a non-existent project."""
fake_id = str(uuid.uuid4())
response = await client.patch(
f"/api/v1/projects/{fake_id}",
json={"name": "New Name"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_update_project_unauthorized(
self, client, user_token, superuser_token
):
"""Test that users cannot update other users' projects."""
# Create project as superuser
create_response = await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
project_id = create_response.json()["id"]
# Try to update as regular user
response = await client.patch(
f"/api/v1/projects/{project_id}",
json={"name": "Hacked Name"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_update_project_duplicate_slug(self, client, user_token):
"""Test that updating to a duplicate slug is rejected."""
# Create two projects
await client.post(
"/api/v1/projects",
json={"name": "First Project", "slug": "first-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
create_response = await client.post(
"/api/v1/projects",
json={"name": "Second Project", "slug": "second-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Try to update second project's slug to first's
response = await client.patch(
f"/api/v1/projects/{project_id}",
json={"slug": "first-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.asyncio
class TestArchiveProject:
"""Tests for DELETE /api/v1/projects/{project_id} endpoint."""
async def test_archive_project_success(self, client, user_token):
"""Test archiving a project."""
# Create project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Archive Test", "slug": "archive-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Archive project
response = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "archived" in data["message"].lower()
# Verify project is archived
get_response = await client.get(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert get_response.json()["status"] == "archived"
async def test_archive_project_already_archived(self, client, user_token):
"""Test archiving an already archived project."""
# Create and archive project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Archive Test", "slug": "archive-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to archive again
response = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert "already archived" in response.json()["message"].lower()
async def test_archive_project_not_found(self, client, user_token):
"""Test archiving a non-existent project."""
fake_id = str(uuid.uuid4())
response = await client.delete(
f"/api/v1/projects/{fake_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_archive_project_unauthorized(
self, client, user_token, superuser_token
):
"""Test that users cannot archive other users' projects."""
# Create project as superuser
create_response = await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
project_id = create_response.json()["id"]
# Try to archive as regular user
response = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
class TestPauseProject:
"""Tests for POST /api/v1/projects/{project_id}/pause endpoint."""
async def test_pause_project_success(self, client, user_token):
"""Test pausing an active project."""
# Create project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Pause Test", "slug": "pause-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Pause project
response = await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "paused"
async def test_pause_already_paused_project(self, client, user_token):
"""Test that pausing an already paused project returns validation error."""
# Create and pause project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Pause Test", "slug": "pause-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to pause again
response = await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert "already paused" in response.json()["errors"][0]["message"].lower()
async def test_pause_archived_project(self, client, user_token):
"""Test that pausing an archived project returns validation error."""
# Create and archive project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Archive Test", "slug": "archive-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to pause
response = await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert "archived" in response.json()["errors"][0]["message"].lower()
async def test_pause_completed_project(self, client, user_token):
"""Test that pausing a completed project returns validation error."""
# Create project with completed status
create_response = await client.post(
"/api/v1/projects",
json={
"name": "Completed Test",
"slug": "completed-test",
"status": "completed",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Try to pause
response = await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert "completed" in response.json()["errors"][0]["message"].lower()
async def test_pause_project_not_found(self, client, user_token):
"""Test pausing a non-existent project."""
fake_id = str(uuid.uuid4())
response = await client.post(
f"/api/v1/projects/{fake_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_pause_project_unauthorized(
self, client, user_token, superuser_token
):
"""Test that users cannot pause other users' projects."""
# Create project as superuser
create_response = await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
project_id = create_response.json()["id"]
# Try to pause as regular user
response = await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
class TestResumeProject:
"""Tests for POST /api/v1/projects/{project_id}/resume endpoint."""
async def test_resume_project_success(self, client, user_token):
"""Test resuming a paused project."""
# Create and pause project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Resume Test", "slug": "resume-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
# Resume project
response = await client.post(
f"/api/v1/projects/{project_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "active"
async def test_resume_already_active_project(self, client, user_token):
"""Test that resuming an active project returns validation error."""
# Create project (active by default)
create_response = await client.post(
"/api/v1/projects",
json={"name": "Active Test", "slug": "active-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Try to resume
response = await client.post(
f"/api/v1/projects/{project_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert "already active" in response.json()["errors"][0]["message"].lower()
async def test_resume_archived_project(self, client, user_token):
"""Test that resuming an archived project returns validation error."""
# Create and archive project
create_response = await client.post(
"/api/v1/projects",
json={"name": "Archive Test", "slug": "archive-test"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to resume
response = await client.post(
f"/api/v1/projects/{project_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert "archived" in response.json()["errors"][0]["message"].lower()
async def test_resume_completed_project(self, client, user_token):
"""Test that resuming a completed project returns validation error."""
# Create project with completed status
create_response = await client.post(
"/api/v1/projects",
json={
"name": "Completed Test",
"slug": "completed-test",
"status": "completed",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Try to resume
response = await client.post(
f"/api/v1/projects/{project_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert "completed" in response.json()["errors"][0]["message"].lower()
async def test_resume_project_not_found(self, client, user_token):
"""Test resuming a non-existent project."""
fake_id = str(uuid.uuid4())
response = await client.post(
f"/api/v1/projects/{fake_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_resume_project_unauthorized(
self, client, user_token, superuser_token
):
"""Test that users cannot resume other users' projects."""
# Create and pause project as superuser
create_response = await client.post(
"/api/v1/projects",
json={"name": "Admin Project", "slug": "admin-project"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
project_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Try to resume as regular user
response = await client.post(
f"/api/v1/projects/{project_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
class TestProjectWorkflow:
"""Integration tests for project lifecycle workflows."""
async def test_full_project_lifecycle(self, client, user_token):
"""Test complete project lifecycle: create -> pause -> resume -> archive."""
# Create
create_response = await client.post(
"/api/v1/projects",
json={
"name": "Lifecycle Test",
"slug": "lifecycle-test",
"description": "Testing full lifecycle",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
project_id = create_response.json()["id"]
# Pause
pause_response = await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert pause_response.status_code == status.HTTP_200_OK
assert pause_response.json()["status"] == "paused"
# Resume
resume_response = await client.post(
f"/api/v1/projects/{project_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert resume_response.status_code == status.HTTP_200_OK
assert resume_response.json()["status"] == "active"
# Archive
archive_response = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert archive_response.status_code == status.HTTP_200_OK
# Verify final state
get_response = await client.get(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert get_response.json()["status"] == "archived"
async def test_superuser_can_manage_any_project(
self, client, user_token, superuser_token
):
"""Test that superuser can perform all operations on any project."""
# Create project as regular user
create_response = await client.post(
"/api/v1/projects",
json={"name": "User Project", "slug": "user-project"},
headers={"Authorization": f"Bearer {user_token}"},
)
project_id = create_response.json()["id"]
# Superuser can read
get_response = await client.get(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert get_response.status_code == status.HTTP_200_OK
# Superuser can update
update_response = await client.patch(
f"/api/v1/projects/{project_id}",
json={"description": "Updated by admin"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert update_response.status_code == status.HTTP_200_OK
# Superuser can pause
pause_response = await client.post(
f"/api/v1/projects/{project_id}/pause",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert pause_response.status_code == status.HTTP_200_OK
# Superuser can resume
resume_response = await client.post(
f"/api/v1/projects/{project_id}/resume",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert resume_response.status_code == status.HTTP_200_OK
# Superuser can archive
archive_response = await client.delete(
f"/api/v1/projects/{project_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert archive_response.status_code == status.HTTP_200_OK