diff --git a/backend/tests/api/routes/syndarix/__init__.py b/backend/tests/api/routes/syndarix/__init__.py new file mode 100644 index 0000000..c94bd06 --- /dev/null +++ b/backend/tests/api/routes/syndarix/__init__.py @@ -0,0 +1,2 @@ +# tests/api/routes/syndarix/__init__.py +"""Syndarix API route tests.""" diff --git a/backend/tests/api/routes/syndarix/test_projects.py b/backend/tests/api/routes/syndarix/test_projects.py new file mode 100644 index 0000000..f057f3c --- /dev/null +++ b/backend/tests/api/routes/syndarix/test_projects.py @@ -0,0 +1,1040 @@ +# 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