forked from cardosofelipe/fast-next-template
- 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.
996 lines
34 KiB
Python
996 lines
34 KiB
Python
# 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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestIssueAssignment:
|
|
"""Tests for issue assignment endpoints."""
|
|
|
|
async def test_assign_issue_to_human(self, client, user_token, test_project):
|
|
"""Test assigning an issue to a human."""
|
|
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": "Issue to Assign",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = create_response.json()["id"]
|
|
|
|
# Assign to human
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
|
json={"human_assignee": "john.doe@example.com"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_200_OK
|
|
data = response.json()
|
|
assert data["human_assignee"] == "john.doe@example.com"
|
|
|
|
async def test_unassign_issue(self, client, user_token, test_project):
|
|
"""Test unassigning an issue."""
|
|
project_id = test_project["id"]
|
|
|
|
# Create issue and assign
|
|
create_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue to Unassign",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = create_response.json()["id"]
|
|
|
|
await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
|
json={"human_assignee": "john.doe@example.com"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Unassign
|
|
response = await client.delete(
|
|
f"/api/v1/projects/{project_id}/issues/{issue_id}/assignment",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_200_OK
|
|
data = response.json()
|
|
# After unassign, assigned_agent_id should be None
|
|
# Note: human_assignee may or may not be cleared depending on implementation
|
|
assert data["assigned_agent_id"] is None
|
|
|
|
async def test_assign_issue_not_found(self, client, user_token, test_project):
|
|
"""Test assigning a nonexistent issue."""
|
|
project_id = test_project["id"]
|
|
fake_issue_id = str(uuid.uuid4())
|
|
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}/assign",
|
|
json={"human_assignee": "john.doe@example.com"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_unassign_issue_not_found(self, client, user_token, test_project):
|
|
"""Test unassigning 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}/assignment",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_assign_issue_clears_assignment(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""Test that assigning to null clears both assignments."""
|
|
project_id = test_project["id"]
|
|
|
|
# Create issue and assign
|
|
create_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue to Clear",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = create_response.json()["id"]
|
|
|
|
await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
|
json={"human_assignee": "john.doe@example.com"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Clear assignment by sending empty object
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{issue_id}/assign",
|
|
json={},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_200_OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestIssueSync:
|
|
"""Tests for issue sync endpoint."""
|
|
|
|
async def test_sync_issue_no_tracker(self, client, user_token, test_project):
|
|
"""Test syncing an issue without external tracker."""
|
|
project_id = test_project["id"]
|
|
|
|
# Create issue without external tracker
|
|
create_response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue without Tracker",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = create_response.json()["id"]
|
|
|
|
# Try to sync
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{issue_id}/sync",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
# Should fail because no external tracker configured
|
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
|
|
async def test_sync_issue_not_found(self, client, user_token, test_project):
|
|
"""Test syncing a nonexistent issue."""
|
|
project_id = test_project["id"]
|
|
fake_issue_id = str(uuid.uuid4())
|
|
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues/{fake_issue_id}/sync",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestIssueCrossProjectValidation:
|
|
"""Tests for cross-project validation (IDOR prevention)."""
|
|
|
|
async def test_issue_not_in_project(self, client, user_token):
|
|
"""Test accessing issue that exists but not in the specified project."""
|
|
# Create two projects
|
|
project1 = await client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Project 1", "slug": "project-1-idor"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
project2 = await client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Project 2", "slug": "project-2-idor"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
project1_id = project1.json()["id"]
|
|
project2_id = project2.json()["id"]
|
|
|
|
# Create issue in project1
|
|
issue_response = await client.post(
|
|
f"/api/v1/projects/{project1_id}/issues",
|
|
json={
|
|
"project_id": project1_id,
|
|
"title": "Project 1 Issue",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = issue_response.json()["id"]
|
|
|
|
# Try to access issue via project2 (IDOR attempt)
|
|
response = await client.get(
|
|
f"/api/v1/projects/{project2_id}/issues/{issue_id}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_update_issue_wrong_project(self, client, user_token):
|
|
"""Test updating issue through wrong project."""
|
|
# Create two projects
|
|
project1 = await client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Project A", "slug": "project-a-idor"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
project2 = await client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Project B", "slug": "project-b-idor"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
project1_id = project1.json()["id"]
|
|
project2_id = project2.json()["id"]
|
|
|
|
# Create issue in project1
|
|
issue_response = await client.post(
|
|
f"/api/v1/projects/{project1_id}/issues",
|
|
json={
|
|
"project_id": project1_id,
|
|
"title": "Project A Issue",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = issue_response.json()["id"]
|
|
|
|
# Try to update issue via project2 (IDOR attempt)
|
|
response = await client.patch(
|
|
f"/api/v1/projects/{project2_id}/issues/{issue_id}",
|
|
json={"title": "Hacked Title"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_delete_issue_wrong_project(self, client, user_token):
|
|
"""Test deleting issue through wrong project."""
|
|
# Create two projects
|
|
project1 = await client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Project X", "slug": "project-x-idor"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
project2 = await client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Project Y", "slug": "project-y-idor"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
project1_id = project1.json()["id"]
|
|
project2_id = project2.json()["id"]
|
|
|
|
# Create issue in project1
|
|
issue_response = await client.post(
|
|
f"/api/v1/projects/{project1_id}/issues",
|
|
json={
|
|
"project_id": project1_id,
|
|
"title": "Project X Issue",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = issue_response.json()["id"]
|
|
|
|
# Try to delete issue via project2 (IDOR attempt)
|
|
response = await client.delete(
|
|
f"/api/v1/projects/{project2_id}/issues/{issue_id}",
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestIssueValidation:
|
|
"""Tests for issue validation during create/update."""
|
|
|
|
async def test_create_issue_invalid_priority(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""Test creating issue with invalid priority."""
|
|
project_id = test_project["id"]
|
|
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue with Invalid Priority",
|
|
"priority": "invalid_priority",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
|
|
async def test_create_issue_invalid_status(self, client, user_token, test_project):
|
|
"""Test creating issue with invalid status."""
|
|
project_id = test_project["id"]
|
|
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue with Invalid Status",
|
|
"status": "invalid_status",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
|
|
async def test_update_issue_invalid_priority(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""Test updating issue with invalid priority."""
|
|
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": "Issue to Update",
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
issue_id = create_response.json()["id"]
|
|
|
|
# Update with invalid priority
|
|
response = await client.patch(
|
|
f"/api/v1/projects/{project_id}/issues/{issue_id}",
|
|
json={"priority": "invalid_priority"},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
|
|
async def test_create_issue_with_nonexistent_sprint(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""Test creating issue with nonexistent sprint ID."""
|
|
project_id = test_project["id"]
|
|
fake_sprint_id = str(uuid.uuid4())
|
|
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue with Fake Sprint",
|
|
"sprint_id": fake_sprint_id,
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_create_issue_with_nonexistent_agent(
|
|
self, client, user_token, test_project
|
|
):
|
|
"""Test creating issue with nonexistent agent ID."""
|
|
project_id = test_project["id"]
|
|
fake_agent_id = str(uuid.uuid4())
|
|
|
|
response = await client.post(
|
|
f"/api/v1/projects/{project_id}/issues",
|
|
json={
|
|
"project_id": project_id,
|
|
"title": "Issue with Fake Agent",
|
|
"assigned_agent_id": fake_agent_id,
|
|
},
|
|
headers={"Authorization": f"Bearer {user_token}"},
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|