forked from cardosofelipe/fast-next-template
test(issues): add comprehensive API route tests
Add 24 tests for issues API covering: - CRUD operations (create, list, get, update, delete) - Status and priority filtering - Search functionality - Issue statistics - Authorization and access control 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
615
backend/tests/api/routes/syndarix/test_issues.py
Normal file
615
backend/tests/api/routes/syndarix/test_issues.py
Normal file
@@ -0,0 +1,615 @@
|
||||
# tests/api/routes/syndarix/test_issues.py
|
||||
"""
|
||||
Comprehensive tests for the Issues API endpoints.
|
||||
|
||||
Tests cover:
|
||||
- CRUD operations (create, read, update, delete)
|
||||
- Issue filtering and search
|
||||
- Issue assignment
|
||||
- Issue statistics
|
||||
- Authorization checks
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import status
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(client, user_token):
|
||||
"""Create a test project for issue tests."""
|
||||
response = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "Issue Test Project", "slug": "issue-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 TestCreateIssue:
|
||||
"""Tests for POST /api/v1/projects/{project_id}/issues endpoint."""
|
||||
|
||||
async def test_create_issue_success(self, client, user_token, test_project):
|
||||
"""Test successful issue creation."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Test Issue",
|
||||
"body": "This is a test issue description",
|
||||
"priority": "medium",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
|
||||
assert data["title"] == "Test Issue"
|
||||
assert data["body"] == "This is a test issue description"
|
||||
assert data["priority"] == "medium"
|
||||
assert data["status"] == "open"
|
||||
assert data["project_id"] == project_id
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
async def test_create_issue_minimal_fields(self, client, user_token, test_project):
|
||||
"""Test creating issue with only required fields."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Minimal Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["title"] == "Minimal Issue"
|
||||
assert data["body"] == "" # Body defaults to empty string
|
||||
assert data["status"] == "open"
|
||||
|
||||
async def test_create_issue_with_labels(self, client, user_token, test_project):
|
||||
"""Test creating issue with labels."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Labeled Issue",
|
||||
"labels": ["bug", "urgent", "frontend"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert "bug" in data["labels"]
|
||||
assert "urgent" in data["labels"]
|
||||
assert "frontend" in data["labels"]
|
||||
|
||||
async def test_create_issue_with_story_points(self, client, user_token, test_project):
|
||||
"""Test creating issue with story points."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Story Points Issue",
|
||||
"story_points": 5,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["story_points"] == 5
|
||||
|
||||
async def test_create_issue_unauthorized_project(
|
||||
self, client, user_token, superuser_project
|
||||
):
|
||||
"""Test that users cannot create issues in others' projects."""
|
||||
project_id = superuser_project["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Unauthorized Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
async def test_create_issue_nonexistent_project(self, client, user_token):
|
||||
"""Test creating issue in nonexistent project."""
|
||||
fake_project_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{fake_project_id}/issues",
|
||||
json={
|
||||
"project_id": fake_project_id,
|
||||
"title": "Orphan Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListIssues:
|
||||
"""Tests for GET /api/v1/projects/{project_id}/issues endpoint."""
|
||||
|
||||
async def test_list_issues_empty(self, client, user_token, test_project):
|
||||
"""Test listing issues when none exist."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_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_issues_with_data(self, client, user_token, test_project):
|
||||
"""Test listing issues returns created issues."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create multiple issues
|
||||
for i in range(3):
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": f"Issue {i + 1}",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
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_issues_filter_by_status(self, client, user_token, test_project):
|
||||
"""Test filtering issues by status."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issues with different statuses
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Open Issue",
|
||||
"status": "open",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Closed Issue",
|
||||
"status": "closed",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Filter by open
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues?status=open",
|
||||
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"] == "open"
|
||||
|
||||
async def test_list_issues_filter_by_priority(self, client, user_token, test_project):
|
||||
"""Test filtering issues by priority."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issues with different priorities
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "High Priority Issue",
|
||||
"priority": "high",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Low Priority Issue",
|
||||
"priority": "low",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Filter by high priority
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues?priority=high",
|
||||
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]["priority"] == "high"
|
||||
|
||||
async def test_list_issues_search(self, client, user_token, test_project):
|
||||
"""Test searching issues by title/body."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Authentication Bug",
|
||||
"body": "Users cannot login",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "UI Enhancement",
|
||||
"body": "Improve dashboard layout",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Search for authentication
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues?search=authentication",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data["data"]) == 1
|
||||
assert "Authentication" in data["data"][0]["title"]
|
||||
|
||||
async def test_list_issues_pagination(self, client, user_token, test_project):
|
||||
"""Test pagination works correctly."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create 5 issues
|
||||
for i in range(5):
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": f"Issue {i + 1}",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Get first page (2 items)
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues?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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetIssue:
|
||||
"""Tests for GET /api/v1/projects/{project_id}/issues/{issue_id} endpoint."""
|
||||
|
||||
async def test_get_issue_success(self, client, user_token, test_project):
|
||||
"""Test getting an issue by ID."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Get Test Issue",
|
||||
"body": "Test description",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Get issue
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["id"] == issue_id
|
||||
assert data["title"] == "Get Test Issue"
|
||||
|
||||
async def test_get_issue_not_found(self, client, user_token, test_project):
|
||||
"""Test getting a nonexistent issue."""
|
||||
project_id = test_project["id"]
|
||||
fake_issue_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateIssue:
|
||||
"""Tests for PATCH /api/v1/projects/{project_id}/issues/{issue_id} endpoint."""
|
||||
|
||||
async def test_update_issue_success(self, client, user_token, test_project):
|
||||
"""Test updating an issue."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Original Title",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Update issue
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}",
|
||||
json={"title": "Updated Title", "body": "New description"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["title"] == "Updated Title"
|
||||
assert data["body"] == "New description"
|
||||
|
||||
async def test_update_issue_status(self, client, user_token, test_project):
|
||||
"""Test updating issue status."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Status Test Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Update status to in_progress
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}",
|
||||
json={"status": "in_progress"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["status"] == "in_progress"
|
||||
|
||||
async def test_update_issue_priority(self, client, user_token, test_project):
|
||||
"""Test updating issue priority."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue with low priority
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Priority Test Issue",
|
||||
"priority": "low",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Update to critical
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_id}",
|
||||
json={"priority": "critical"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["priority"] == "critical"
|
||||
|
||||
async def test_update_issue_not_found(self, client, user_token, test_project):
|
||||
"""Test updating a nonexistent issue."""
|
||||
project_id = test_project["id"]
|
||||
fake_issue_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}",
|
||||
json={"title": "Updated Title"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteIssue:
|
||||
"""Tests for DELETE /api/v1/projects/{project_id}/issues/{issue_id} endpoint."""
|
||||
|
||||
async def test_delete_issue_success(self, client, user_token, test_project):
|
||||
"""Test deleting an issue."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Delete Test Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
issue_id = create_response.json()["id"]
|
||||
|
||||
# Delete issue
|
||||
response = await client.delete(
|
||||
f"/api/v1/projects/{project_id}/issues/{issue_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_issue_not_found(self, client, user_token, test_project):
|
||||
"""Test deleting a nonexistent issue."""
|
||||
project_id = test_project["id"]
|
||||
fake_issue_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestIssueStats:
|
||||
"""Tests for GET /api/v1/projects/{project_id}/issues/stats endpoint."""
|
||||
|
||||
async def test_get_issue_stats_empty(self, client, user_token, test_project):
|
||||
"""Test getting stats when no issues exist."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues/stats",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["open"] == 0
|
||||
assert data["in_progress"] == 0
|
||||
assert data["in_review"] == 0
|
||||
assert data["blocked"] == 0
|
||||
assert data["closed"] == 0
|
||||
|
||||
async def test_get_issue_stats_with_data(self, client, user_token, test_project):
|
||||
"""Test getting stats with issues."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issues with different statuses and priorities
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Open High Issue",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"story_points": 5,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Closed Low Issue",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"story_points": 3,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues/stats",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert data["open"] == 1
|
||||
assert data["closed"] == 1
|
||||
assert data["by_priority"]["high"] == 1
|
||||
assert data["by_priority"]["low"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestIssueAuthorization:
|
||||
"""Tests for issue authorization."""
|
||||
|
||||
async def test_superuser_can_manage_any_project_issues(
|
||||
self, client, user_token, superuser_token, test_project
|
||||
):
|
||||
"""Test that superuser can manage issues in any project."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
# Create issue as superuser in user's project
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"title": "Superuser Issue",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
async def test_user_cannot_access_other_project_issues(
|
||||
self, client, user_token, superuser_project
|
||||
):
|
||||
"""Test that users cannot access issues in others' projects."""
|
||||
project_id = superuser_project["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/issues",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
Reference in New Issue
Block a user