forked from cardosofelipe/pragma-stack
Compare commits
10 Commits
f0b04d53af
...
63066c50ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63066c50ba | ||
|
|
ddf9b5fe25 | ||
|
|
c3b66cccfc | ||
|
|
896f0d92e5 | ||
|
|
2ccaeb23f2 | ||
|
|
04c939d4c2 | ||
|
|
71c94c3b5a | ||
|
|
d71891ac4e | ||
|
|
3492941aec | ||
|
|
81e8d7e73d |
@@ -363,6 +363,73 @@ async def list_project_agents(
|
||||
raise
|
||||
|
||||
|
||||
# ===== Project Agent Metrics Endpoint =====
|
||||
# NOTE: This endpoint MUST be defined before /{agent_id} routes
|
||||
# to prevent FastAPI from trying to parse "metrics" as a UUID
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/agents/metrics",
|
||||
response_model=AgentInstanceMetrics,
|
||||
summary="Get Project Agent Metrics",
|
||||
description="Get aggregated usage metrics for all agents in a project.",
|
||||
operation_id="get_project_agent_metrics",
|
||||
)
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_project_agent_metrics(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Get aggregated usage metrics for all agents in a project.
|
||||
|
||||
Returns aggregated metrics across all agents including total
|
||||
tasks completed, tokens used, and cost incurred.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object (for rate limiting)
|
||||
project_id: UUID of the project
|
||||
current_user: Current authenticated user
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
AgentInstanceMetrics: Aggregated project agent metrics
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the project is not found
|
||||
AuthorizationError: If the user lacks access to the project
|
||||
"""
|
||||
try:
|
||||
# Verify project access
|
||||
project = await verify_project_access(db, project_id, current_user)
|
||||
|
||||
# Get aggregated metrics for the project
|
||||
metrics = await agent_instance_crud.get_project_metrics(
|
||||
db, project_id=project_id
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"User {current_user.email} retrieved project metrics for {project.slug}"
|
||||
)
|
||||
|
||||
return AgentInstanceMetrics(
|
||||
total_instances=metrics["total_instances"],
|
||||
active_instances=metrics["active_instances"],
|
||||
idle_instances=metrics["idle_instances"],
|
||||
total_tasks_completed=metrics["total_tasks_completed"],
|
||||
total_tokens_used=metrics["total_tokens_used"],
|
||||
total_cost_incurred=metrics["total_cost_incurred"],
|
||||
)
|
||||
|
||||
except (NotFoundError, AuthorizationError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project agent metrics: {e!s}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/agents/{agent_id}",
|
||||
response_model=AgentInstanceResponse,
|
||||
@@ -908,65 +975,3 @@ async def get_agent_metrics(
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting agent metrics: {e!s}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/agents/metrics",
|
||||
response_model=AgentInstanceMetrics,
|
||||
summary="Get Project Agent Metrics",
|
||||
description="Get aggregated usage metrics for all agents in a project.",
|
||||
operation_id="get_project_agent_metrics",
|
||||
)
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_project_agent_metrics(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Get aggregated usage metrics for all agents in a project.
|
||||
|
||||
Returns aggregated metrics across all agents including total
|
||||
tasks completed, tokens used, and cost incurred.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object (for rate limiting)
|
||||
project_id: UUID of the project
|
||||
current_user: Current authenticated user
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
AgentInstanceMetrics: Aggregated project agent metrics
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the project is not found
|
||||
AuthorizationError: If the user lacks access to the project
|
||||
"""
|
||||
try:
|
||||
# Verify project access
|
||||
project = await verify_project_access(db, project_id, current_user)
|
||||
|
||||
# Get aggregated metrics for the project
|
||||
metrics = await agent_instance_crud.get_project_metrics(
|
||||
db, project_id=project_id
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"User {current_user.email} retrieved project metrics for {project.slug}"
|
||||
)
|
||||
|
||||
return AgentInstanceMetrics(
|
||||
total_instances=metrics["total_instances"],
|
||||
active_instances=metrics["active_instances"],
|
||||
idle_instances=metrics["idle_instances"],
|
||||
total_tasks_completed=metrics["total_tasks_completed"],
|
||||
total_tokens_used=metrics["total_tokens_used"],
|
||||
total_cost_incurred=metrics["total_cost_incurred"],
|
||||
)
|
||||
|
||||
except (NotFoundError, AuthorizationError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project agent metrics: {e!s}", exc_info=True)
|
||||
raise
|
||||
|
||||
@@ -350,6 +350,58 @@ async def list_issues(
|
||||
raise
|
||||
|
||||
|
||||
# ===== Issue Statistics Endpoint =====
|
||||
# NOTE: This endpoint MUST be defined before /{issue_id} routes
|
||||
# to prevent FastAPI from trying to parse "stats" as a UUID
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/issues/stats",
|
||||
response_model=IssueStats,
|
||||
summary="Get Issue Statistics",
|
||||
description="Get aggregated issue statistics for a project",
|
||||
operation_id="get_issue_stats",
|
||||
)
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_issue_stats(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Get aggregated statistics for issues in a project.
|
||||
|
||||
Returns counts by status and priority, along with story point totals.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
project_id: Project UUID
|
||||
current_user: Authenticated user
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Issue statistics including counts by status/priority and story points
|
||||
|
||||
Raises:
|
||||
NotFoundError: If project not found
|
||||
AuthorizationError: If user lacks access
|
||||
"""
|
||||
# Verify project access
|
||||
await verify_project_ownership(db, project_id, current_user)
|
||||
|
||||
try:
|
||||
stats = await issue_crud.get_project_stats(db, project_id=project_id)
|
||||
return IssueStats(**stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting issue stats for project {project_id}: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/issues/{issue_id}",
|
||||
response_model=IssueResponse,
|
||||
@@ -535,7 +587,7 @@ async def update_issue(
|
||||
"/projects/{project_id}/issues/{issue_id}",
|
||||
response_model=MessageResponse,
|
||||
summary="Delete Issue",
|
||||
description="Soft delete an issue",
|
||||
description="Delete an issue permanently",
|
||||
operation_id="delete_issue",
|
||||
)
|
||||
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
||||
@@ -547,10 +599,9 @@ async def delete_issue(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Soft delete an issue.
|
||||
Delete an issue permanently.
|
||||
|
||||
The issue will be marked as deleted but retained in the database.
|
||||
This preserves historical data and allows potential recovery.
|
||||
The issue will be permanently removed from the database.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
@@ -585,15 +636,16 @@ async def delete_issue(
|
||||
)
|
||||
|
||||
try:
|
||||
await issue_crud.soft_delete(db, id=issue_id)
|
||||
issue_title = issue.title
|
||||
await issue_crud.remove(db, id=issue_id)
|
||||
logger.info(
|
||||
f"User {current_user.email} deleted issue {issue_id} "
|
||||
f"('{issue.title}') from project {project_id}"
|
||||
f"('{issue_title}') from project {project_id}"
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message=f"Issue '{issue.title}' has been deleted",
|
||||
message=f"Issue '{issue_title}' has been deleted",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -887,53 +939,3 @@ async def sync_issue(
|
||||
message=f"Sync triggered for issue '{issue.title}'. "
|
||||
f"Status will update when complete.",
|
||||
)
|
||||
|
||||
|
||||
# ===== Issue Statistics Endpoint =====
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/issues/stats",
|
||||
response_model=IssueStats,
|
||||
summary="Get Issue Statistics",
|
||||
description="Get aggregated issue statistics for a project",
|
||||
operation_id="get_issue_stats",
|
||||
)
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_issue_stats(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Get aggregated statistics for issues in a project.
|
||||
|
||||
Returns counts by status and priority, along with story point totals.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
project_id: Project UUID
|
||||
current_user: Authenticated user
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Issue statistics including counts by status/priority and story points
|
||||
|
||||
Raises:
|
||||
NotFoundError: If project not found
|
||||
AuthorizationError: If user lacks access
|
||||
"""
|
||||
# Verify project access
|
||||
await verify_project_ownership(db, project_id, current_user)
|
||||
|
||||
try:
|
||||
stats = await issue_crud.get_project_stats(db, project_id=project_id)
|
||||
return IssueStats(**stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting issue stats for project {project_id}: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -384,6 +384,68 @@ async def get_active_sprint(
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/velocity",
|
||||
response_model=list[SprintVelocity],
|
||||
summary="Get Project Velocity",
|
||||
description="""
|
||||
Get velocity metrics for completed sprints in the project.
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
**Authorization**: Project owner or superuser
|
||||
|
||||
Returns velocity data for the last N completed sprints (default 5).
|
||||
Useful for capacity planning and sprint estimation.
|
||||
|
||||
**Rate Limit**: 60 requests/minute
|
||||
""",
|
||||
operation_id="get_project_velocity",
|
||||
)
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_project_velocity(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
limit: int = Query(
|
||||
default=5,
|
||||
ge=1,
|
||||
le=20,
|
||||
description="Number of completed sprints to include",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Get velocity metrics for completed sprints.
|
||||
|
||||
Returns planned points, actual velocity, and velocity ratio
|
||||
for the last N completed sprints, ordered chronologically.
|
||||
"""
|
||||
# Verify project access
|
||||
await verify_project_ownership(db, project_id, current_user)
|
||||
|
||||
try:
|
||||
velocity_data = await sprint_crud.get_velocity(
|
||||
db, project_id=project_id, limit=limit
|
||||
)
|
||||
|
||||
return [
|
||||
SprintVelocity(
|
||||
sprint_number=item["sprint_number"],
|
||||
sprint_name=item["sprint_name"],
|
||||
planned_points=item["planned_points"],
|
||||
velocity=item["velocity"],
|
||||
velocity_ratio=item["velocity_ratio"],
|
||||
)
|
||||
for item in velocity_data
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting velocity for project {project_id}: {e!s}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{sprint_id}",
|
||||
response_model=SprintResponse,
|
||||
@@ -1116,70 +1178,3 @@ async def remove_issue_from_sprint(
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Sprint Metrics Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/velocity",
|
||||
response_model=list[SprintVelocity],
|
||||
summary="Get Project Velocity",
|
||||
description="""
|
||||
Get velocity metrics for completed sprints in the project.
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
**Authorization**: Project owner or superuser
|
||||
|
||||
Returns velocity data for the last N completed sprints (default 5).
|
||||
Useful for capacity planning and sprint estimation.
|
||||
|
||||
**Rate Limit**: 60 requests/minute
|
||||
""",
|
||||
operation_id="get_project_velocity",
|
||||
)
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_project_velocity(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
limit: int = Query(
|
||||
default=5,
|
||||
ge=1,
|
||||
le=20,
|
||||
description="Number of completed sprints to include",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Get velocity metrics for completed sprints.
|
||||
|
||||
Returns planned points, actual velocity, and velocity ratio
|
||||
for the last N completed sprints, ordered chronologically.
|
||||
"""
|
||||
# Verify project access
|
||||
await verify_project_ownership(db, project_id, current_user)
|
||||
|
||||
try:
|
||||
velocity_data = await sprint_crud.get_velocity(
|
||||
db, project_id=project_id, limit=limit
|
||||
)
|
||||
|
||||
return [
|
||||
SprintVelocity(
|
||||
sprint_number=item["sprint_number"],
|
||||
sprint_name=item["sprint_name"],
|
||||
planned_points=item["planned_points"],
|
||||
velocity=item["velocity"],
|
||||
velocity_ratio=item["velocity_ratio"],
|
||||
)
|
||||
for item in velocity_data
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting velocity for project {project_id}: {e!s}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
2
backend/tests/api/routes/syndarix/__init__.py
Normal file
2
backend/tests/api/routes/syndarix/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# tests/api/routes/syndarix/__init__.py
|
||||
"""Syndarix API route tests."""
|
||||
751
backend/tests/api/routes/syndarix/test_agent_types.py
Normal file
751
backend/tests/api/routes/syndarix/test_agent_types.py
Normal file
@@ -0,0 +1,751 @@
|
||||
# tests/api/routes/syndarix/test_agent_types.py
|
||||
"""
|
||||
Comprehensive tests for the AgentTypes API endpoints.
|
||||
|
||||
Tests cover:
|
||||
- CRUD operations (create, read, update, deactivate)
|
||||
- Authorization (superuser vs regular user)
|
||||
- Pagination and filtering
|
||||
- Error handling (not found, validation, duplicates)
|
||||
- Slug lookup functionality
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import status
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_agent_type(client, superuser_token):
|
||||
"""Create a test agent type for tests."""
|
||||
unique_slug = f"test-type-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Test Agent Type",
|
||||
"slug": unique_slug,
|
||||
"description": "A test agent type for testing",
|
||||
"expertise": ["python", "testing"],
|
||||
"personality_prompt": "You are a helpful test agent.",
|
||||
"primary_model": "claude-3-opus",
|
||||
"fallback_models": ["claude-3-sonnet"],
|
||||
"model_params": {"temperature": 0.7},
|
||||
"mcp_servers": [],
|
||||
"tool_permissions": {"read": True, "write": False},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def multiple_agent_types(client, superuser_token):
|
||||
"""Create multiple agent types for pagination tests."""
|
||||
types = []
|
||||
for i in range(5):
|
||||
unique_slug = f"multi-type-{i}-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": f"Agent Type {i}",
|
||||
"slug": unique_slug,
|
||||
"description": f"Description for type {i}",
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": f"Personality prompt {i}",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
types.append(response.json())
|
||||
return types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateAgentType:
|
||||
"""Tests for POST /api/v1/agent-types endpoint."""
|
||||
|
||||
async def test_create_agent_type_success(self, client, superuser_token):
|
||||
"""Test successful agent type creation by superuser."""
|
||||
unique_slug = f"created-type-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "New Agent Type",
|
||||
"slug": unique_slug,
|
||||
"description": "A newly created agent type",
|
||||
"expertise": ["python", "fastapi"],
|
||||
"personality_prompt": "You are a backend developer.",
|
||||
"primary_model": "claude-3-opus",
|
||||
"fallback_models": ["claude-3-sonnet"],
|
||||
"model_params": {"temperature": 0.5},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
|
||||
assert data["name"] == "New Agent Type"
|
||||
assert data["slug"] == unique_slug
|
||||
assert data["description"] == "A newly created agent type"
|
||||
assert data["expertise"] == ["python", "fastapi"]
|
||||
assert data["personality_prompt"] == "You are a backend developer."
|
||||
assert data["primary_model"] == "claude-3-opus"
|
||||
assert data["fallback_models"] == ["claude-3-sonnet"]
|
||||
assert data["model_params"]["temperature"] == 0.5
|
||||
assert data["is_active"] is True
|
||||
assert data["instance_count"] == 0
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert "updated_at" in data
|
||||
|
||||
async def test_create_agent_type_minimal_fields(self, client, superuser_token):
|
||||
"""Test creating agent type with only required fields."""
|
||||
unique_slug = f"minimal-type-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Minimal Agent Type",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["general"],
|
||||
"personality_prompt": "You are a general assistant.",
|
||||
"primary_model": "claude-3-sonnet",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["name"] == "Minimal Agent Type"
|
||||
assert data["slug"] == unique_slug
|
||||
assert data["is_active"] is True
|
||||
|
||||
async def test_create_agent_type_duplicate_slug(
|
||||
self, client, superuser_token, test_agent_type
|
||||
):
|
||||
"""Test that duplicate slugs are rejected."""
|
||||
existing_slug = test_agent_type["slug"]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Another Type",
|
||||
"slug": existing_slug, # Duplicate slug
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
data = response.json()
|
||||
assert data["errors"][0]["code"] == "SYS_005" # ALREADY_EXISTS
|
||||
assert data["errors"][0]["field"] == "slug"
|
||||
|
||||
async def test_create_agent_type_regular_user_forbidden(self, client, user_token):
|
||||
"""Test that regular users cannot create agent types."""
|
||||
unique_slug = f"forbidden-type-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Forbidden Type",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
async def test_create_agent_type_unauthenticated(self, client):
|
||||
"""Test that unauthenticated users cannot create agent types."""
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Unauth Type",
|
||||
"slug": "unauth-type",
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
async def test_create_agent_type_validation_missing_name(
|
||||
self, client, superuser_token
|
||||
):
|
||||
"""Test validation error when name is missing."""
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"slug": "no-name-type",
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_create_agent_type_validation_missing_primary_model(
|
||||
self, client, superuser_token
|
||||
):
|
||||
"""Test validation error when primary_model is missing."""
|
||||
unique_slug = f"no-model-type-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "No Model Type",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
# Missing primary_model
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListAgentTypes:
|
||||
"""Tests for GET /api/v1/agent-types endpoint."""
|
||||
|
||||
async def test_list_agent_types_success(
|
||||
self, client, user_token, multiple_agent_types
|
||||
):
|
||||
"""Test successful listing of agent types."""
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types",
|
||||
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 len(data["data"]) >= 5
|
||||
assert data["pagination"]["total"] >= 5
|
||||
assert data["pagination"]["page"] == 1
|
||||
|
||||
async def test_list_agent_types_pagination(
|
||||
self, client, user_token, multiple_agent_types
|
||||
):
|
||||
"""Test pagination of agent types."""
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types",
|
||||
params={"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"]["page_size"] <= 2
|
||||
assert data["pagination"]["page"] == 1
|
||||
|
||||
async def test_list_agent_types_filter_active(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test filtering by active status."""
|
||||
# Default: only active types
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types",
|
||||
params={"is_active": True},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
# All returned types should be active
|
||||
for agent_type in data["data"]:
|
||||
assert agent_type["is_active"] is True
|
||||
|
||||
async def test_list_agent_types_search(
|
||||
self, client, user_token, multiple_agent_types
|
||||
):
|
||||
"""Test search functionality."""
|
||||
# Search for a specific type
|
||||
search_term = multiple_agent_types[0]["name"]
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types",
|
||||
params={"search": search_term},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data["data"]) >= 1
|
||||
|
||||
async def test_list_agent_types_unauthenticated(self, client):
|
||||
"""Test that unauthenticated users cannot list agent types."""
|
||||
response = await client.get("/api/v1/agent-types")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetAgentType:
|
||||
"""Tests for GET /api/v1/agent-types/{agent_type_id} endpoint."""
|
||||
|
||||
async def test_get_agent_type_success(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test successful retrieval of agent type by ID."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
assert data["id"] == agent_type_id
|
||||
assert data["name"] == test_agent_type["name"]
|
||||
assert data["slug"] == test_agent_type["slug"]
|
||||
assert "instance_count" in data
|
||||
|
||||
async def test_get_agent_type_not_found(self, client, user_token):
|
||||
"""Test retrieval of non-existent agent type."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/agent-types/{fake_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
data = response.json()
|
||||
assert data["errors"][0]["code"] == "SYS_002" # NOT_FOUND
|
||||
|
||||
async def test_get_agent_type_invalid_uuid(self, client, user_token):
|
||||
"""Test retrieval with invalid UUID format."""
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types/not-a-uuid",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_get_agent_type_unauthenticated(self, client, test_agent_type):
|
||||
"""Test that unauthenticated users cannot get agent types."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.get(f"/api/v1/agent-types/{agent_type_id}")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetAgentTypeBySlug:
|
||||
"""Tests for GET /api/v1/agent-types/slug/{slug} endpoint."""
|
||||
|
||||
async def test_get_agent_type_by_slug_success(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test successful retrieval of agent type by slug."""
|
||||
slug = test_agent_type["slug"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/agent-types/slug/{slug}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
assert data["slug"] == slug
|
||||
assert data["id"] == test_agent_type["id"]
|
||||
assert data["name"] == test_agent_type["name"]
|
||||
|
||||
async def test_get_agent_type_by_slug_not_found(self, client, user_token):
|
||||
"""Test retrieval of non-existent slug."""
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types/slug/non-existent-slug",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
data = response.json()
|
||||
assert data["errors"][0]["code"] == "SYS_002" # NOT_FOUND
|
||||
assert "non-existent-slug" in data["errors"][0]["message"]
|
||||
|
||||
async def test_get_agent_type_by_slug_unauthenticated(self, client, test_agent_type):
|
||||
"""Test that unauthenticated users cannot get agent types by slug."""
|
||||
slug = test_agent_type["slug"]
|
||||
|
||||
response = await client.get(f"/api/v1/agent-types/slug/{slug}")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateAgentType:
|
||||
"""Tests for PATCH /api/v1/agent-types/{agent_type_id} endpoint."""
|
||||
|
||||
async def test_update_agent_type_success(
|
||||
self, client, superuser_token, test_agent_type
|
||||
):
|
||||
"""Test successful update of agent type."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
json={
|
||||
"name": "Updated Agent Type",
|
||||
"description": "Updated description",
|
||||
"expertise": ["python", "fastapi", "testing"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
assert data["id"] == agent_type_id
|
||||
assert data["name"] == "Updated Agent Type"
|
||||
assert data["description"] == "Updated description"
|
||||
assert data["expertise"] == ["python", "fastapi", "testing"]
|
||||
# Slug should remain unchanged
|
||||
assert data["slug"] == test_agent_type["slug"]
|
||||
|
||||
async def test_update_agent_type_partial(
|
||||
self, client, superuser_token, test_agent_type
|
||||
):
|
||||
"""Test partial update of agent type."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
json={"description": "Only description updated"},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
assert data["description"] == "Only description updated"
|
||||
# Other fields remain unchanged
|
||||
assert data["name"] == test_agent_type["name"]
|
||||
|
||||
async def test_update_agent_type_slug(
|
||||
self, client, superuser_token, test_agent_type
|
||||
):
|
||||
"""Test updating agent type slug."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
new_slug = f"updated-slug-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
json={"slug": new_slug},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["slug"] == new_slug
|
||||
|
||||
async def test_update_agent_type_duplicate_slug(
|
||||
self, client, superuser_token, multiple_agent_types
|
||||
):
|
||||
"""Test that updating to an existing slug fails."""
|
||||
# Try to update first type's slug to second type's slug
|
||||
first_type_id = multiple_agent_types[0]["id"]
|
||||
second_type_slug = multiple_agent_types[1]["slug"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{first_type_id}",
|
||||
json={"slug": second_type_slug},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
data = response.json()
|
||||
assert data["errors"][0]["code"] == "SYS_005" # ALREADY_EXISTS
|
||||
|
||||
async def test_update_agent_type_not_found(self, client, superuser_token):
|
||||
"""Test updating non-existent agent type."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{fake_id}",
|
||||
json={"name": "Updated Name"},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
data = response.json()
|
||||
assert data["errors"][0]["code"] == "SYS_002" # NOT_FOUND
|
||||
|
||||
async def test_update_agent_type_regular_user_forbidden(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test that regular users cannot update agent types."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
json={"name": "Forbidden Update"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
async def test_update_agent_type_unauthenticated(self, client, test_agent_type):
|
||||
"""Test that unauthenticated users cannot update agent types."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
json={"name": "Unauth Update"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeactivateAgentType:
|
||||
"""Tests for DELETE /api/v1/agent-types/{agent_type_id} endpoint."""
|
||||
|
||||
async def test_deactivate_agent_type_success(self, client, superuser_token):
|
||||
"""Test successful deactivation of agent type."""
|
||||
# Create a type to deactivate
|
||||
unique_slug = f"deactivate-type-{uuid.uuid4().hex[:8]}"
|
||||
create_response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Type to Deactivate",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
agent_type_id = create_response.json()["id"]
|
||||
|
||||
# Deactivate it
|
||||
response = await client.delete(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "deactivated" in data["message"].lower()
|
||||
|
||||
# Verify it's deactivated by checking is_active filter
|
||||
get_response = await client.get(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert get_response.status_code == status.HTTP_200_OK
|
||||
assert get_response.json()["is_active"] is False
|
||||
|
||||
async def test_deactivate_agent_type_not_found(self, client, superuser_token):
|
||||
"""Test deactivating non-existent agent type."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/agent-types/{fake_id}",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
data = response.json()
|
||||
assert data["errors"][0]["code"] == "SYS_002" # NOT_FOUND
|
||||
|
||||
async def test_deactivate_agent_type_regular_user_forbidden(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test that regular users cannot deactivate agent types."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
async def test_deactivate_agent_type_unauthenticated(self, client, test_agent_type):
|
||||
"""Test that unauthenticated users cannot deactivate agent types."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.delete(f"/api/v1/agent-types/{agent_type_id}")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
async def test_deactivate_agent_type_idempotent(self, client, superuser_token):
|
||||
"""Test that deactivating an already deactivated type returns 404."""
|
||||
# Create and deactivate a type
|
||||
unique_slug = f"idempotent-type-{uuid.uuid4().hex[:8]}"
|
||||
create_response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Type to Deactivate Twice",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
agent_type_id = create_response.json()["id"]
|
||||
|
||||
# First deactivation
|
||||
await client.delete(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
# Second deactivation should fail (already deactivated)
|
||||
response = await client.delete(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
# Depending on implementation, this might return 404 or 200
|
||||
# Check implementation for expected behavior
|
||||
assert response.status_code in [
|
||||
status.HTTP_200_OK,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentTypeModelParams:
|
||||
"""Tests for model configuration fields."""
|
||||
|
||||
async def test_create_with_full_model_config(self, client, superuser_token):
|
||||
"""Test creating agent type with complete model configuration."""
|
||||
unique_slug = f"full-config-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Full Config Type",
|
||||
"slug": unique_slug,
|
||||
"description": "Type with full model config",
|
||||
"expertise": ["coding", "architecture"],
|
||||
"personality_prompt": "You are an expert architect.",
|
||||
"primary_model": "claude-3-opus",
|
||||
"fallback_models": ["claude-3-sonnet", "claude-3-haiku"],
|
||||
"model_params": {
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
"top_p": 0.9,
|
||||
},
|
||||
"mcp_servers": ["filesystem", "git"], # List of strings, not objects
|
||||
"tool_permissions": {
|
||||
"read_files": True,
|
||||
"write_files": True,
|
||||
"execute_code": False,
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
|
||||
assert data["primary_model"] == "claude-3-opus"
|
||||
assert data["fallback_models"] == ["claude-3-sonnet", "claude-3-haiku"]
|
||||
assert data["model_params"]["temperature"] == 0.3
|
||||
assert data["model_params"]["max_tokens"] == 4096
|
||||
assert len(data["mcp_servers"]) == 2
|
||||
assert data["tool_permissions"]["read_files"] is True
|
||||
assert data["tool_permissions"]["execute_code"] is False
|
||||
|
||||
async def test_update_model_params(
|
||||
self, client, superuser_token, test_agent_type
|
||||
):
|
||||
"""Test updating model parameters."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
json={
|
||||
"model_params": {"temperature": 0.9, "max_tokens": 2048},
|
||||
"fallback_models": ["claude-3-haiku"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
assert data["model_params"]["temperature"] == 0.9
|
||||
assert data["fallback_models"] == ["claude-3-haiku"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentTypeInstanceCount:
|
||||
"""Tests for instance count tracking."""
|
||||
|
||||
async def test_new_agent_type_has_zero_instances(
|
||||
self, client, superuser_token
|
||||
):
|
||||
"""Test that newly created agent types have zero instances."""
|
||||
unique_slug = f"zero-instances-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Zero Instances Type",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Prompt",
|
||||
"primary_model": "claude-3-opus",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["instance_count"] == 0
|
||||
|
||||
async def test_get_agent_type_includes_instance_count(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test that getting an agent type includes instance count."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert "instance_count" in data
|
||||
assert isinstance(data["instance_count"], int)
|
||||
|
||||
async def test_list_agent_types_includes_instance_counts(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test that listing agent types includes instance counts."""
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
for agent_type in data["data"]:
|
||||
assert "instance_count" in agent_type
|
||||
assert isinstance(agent_type["instance_count"], int)
|
||||
619
backend/tests/api/routes/syndarix/test_agents.py
Normal file
619
backend/tests/api/routes/syndarix/test_agents.py
Normal file
@@ -0,0 +1,619 @@
|
||||
# tests/api/routes/syndarix/test_agents.py
|
||||
"""Tests for agent instance management endpoints.
|
||||
|
||||
Tests cover:
|
||||
- Agent instance CRUD operations
|
||||
- Agent lifecycle management (pause, resume)
|
||||
- Agent status filtering
|
||||
- Agent metrics
|
||||
- Authorization and access control
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from starlette import status
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(client, user_token):
|
||||
"""Create a test project for agent tests."""
|
||||
response = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={
|
||||
"name": "Agent Test Project",
|
||||
"slug": "agent-test-project",
|
||||
"autonomy_level": "milestone",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_agent_type(client, superuser_token):
|
||||
"""Create a test agent type for spawning agents."""
|
||||
import uuid as uuid_mod
|
||||
|
||||
unique_slug = f"test-developer-agent-{uuid_mod.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Test Developer Agent",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python", "testing"],
|
||||
"primary_model": "claude-3-opus",
|
||||
"personality_prompt": "You are a helpful developer agent for testing.",
|
||||
"description": "A test developer agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Failed: {response.json()}"
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSpawnAgent:
|
||||
"""Tests for POST /api/v1/projects/{project_id}/agents endpoint."""
|
||||
|
||||
async def test_spawn_agent_success(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test successfully spawning a new agent."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "My Developer Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["name"] == "My Developer Agent"
|
||||
assert data["status"] == "idle"
|
||||
assert data["project_id"] == project_id
|
||||
|
||||
async def test_spawn_agent_with_initial_memory(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test spawning agent with initial short-term memory."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Memory Agent",
|
||||
"short_term_memory": {"context": "test setup"},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["short_term_memory"]["context"] == "test setup"
|
||||
|
||||
async def test_spawn_agent_nonexistent_project(
|
||||
self, client, user_token, test_agent_type
|
||||
):
|
||||
"""Test spawning agent in nonexistent project."""
|
||||
fake_project_id = str(uuid.uuid4())
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{fake_project_id}/agents",
|
||||
json={
|
||||
"project_id": fake_project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Orphan Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_spawn_agent_nonexistent_type(
|
||||
self, client, user_token, test_project
|
||||
):
|
||||
"""Test spawning agent with nonexistent agent type."""
|
||||
project_id = test_project["id"]
|
||||
fake_type_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": fake_type_id,
|
||||
"name": "Invalid Type Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_spawn_agent_mismatched_project_id(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test spawning agent with mismatched project_id in body."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
different_project_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": different_project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Mismatched Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListAgents:
|
||||
"""Tests for GET /api/v1/projects/{project_id}/agents endpoint."""
|
||||
|
||||
async def test_list_agents_empty(self, client, user_token, test_project):
|
||||
"""Test listing agents when none exist."""
|
||||
project_id = test_project["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
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_agents_with_data(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test listing agents with data."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agents
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Agent One",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Agent Two",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data["data"]) == 2
|
||||
assert data["pagination"]["total"] == 2
|
||||
|
||||
async def test_list_agents_filter_by_status(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test filtering agents by status."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Idle Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Filter by idle status
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/agents?status=idle",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert all(agent["status"] == "idle" for agent in data["data"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetAgent:
|
||||
"""Tests for GET /api/v1/projects/{project_id}/agents/{agent_id} endpoint."""
|
||||
|
||||
async def test_get_agent_success(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test getting agent by ID."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Get Test Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
agent_id = create_response.json()["id"]
|
||||
|
||||
# Get agent
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["id"] == agent_id
|
||||
assert data["name"] == "Get Test Agent"
|
||||
|
||||
async def test_get_agent_not_found(self, client, user_token, test_project):
|
||||
"""Test getting a nonexistent agent."""
|
||||
project_id = test_project["id"]
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/agents/{fake_agent_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateAgent:
|
||||
"""Tests for PATCH /api/v1/projects/{project_id}/agents/{agent_id} endpoint."""
|
||||
|
||||
async def test_update_agent_current_task(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test updating agent current_task."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Task Update Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
agent_id = create_response.json()["id"]
|
||||
|
||||
# Update current_task
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_id}",
|
||||
json={"current_task": "Working on feature #123"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["current_task"] == "Working on feature #123"
|
||||
|
||||
async def test_update_agent_memory(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test updating agent short-term memory."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Memory Update Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
agent_id = create_response.json()["id"]
|
||||
|
||||
# Update memory
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_id}",
|
||||
json={"short_term_memory": {"last_context": "updated", "step": 2}},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["short_term_memory"]["last_context"] == "updated"
|
||||
assert data["short_term_memory"]["step"] == 2
|
||||
|
||||
async def test_update_agent_not_found(self, client, user_token, test_project):
|
||||
"""Test updating a nonexistent agent."""
|
||||
project_id = test_project["id"]
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/projects/{project_id}/agents/{fake_agent_id}",
|
||||
json={"current_task": "Some task"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentLifecycle:
|
||||
"""Tests for agent lifecycle management endpoints."""
|
||||
|
||||
async def test_pause_agent(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test pausing an agent."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Pause Test Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
agent_id = create_response.json()["id"]
|
||||
|
||||
# Pause agent
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["status"] == "paused"
|
||||
|
||||
async def test_resume_paused_agent(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test resuming a paused agent."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create and pause agent
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Resume Test Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
agent_id = create_response.json()["id"]
|
||||
|
||||
# Pause first
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Resume agent
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_id}/resume",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["status"] == "idle"
|
||||
|
||||
async def test_pause_nonexistent_agent(self, client, user_token, test_project):
|
||||
"""Test pausing a nonexistent agent."""
|
||||
project_id = test_project["id"]
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents/{fake_agent_id}/pause",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteAgent:
|
||||
"""Tests for DELETE /api/v1/projects/{project_id}/agents/{agent_id} endpoint."""
|
||||
|
||||
async def test_delete_agent_success(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test deleting an agent."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Delete Test Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
agent_id = create_response.json()["id"]
|
||||
|
||||
# Delete agent
|
||||
response = await client.delete(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_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_agent_not_found(self, client, user_token, test_project):
|
||||
"""Test deleting a nonexistent agent."""
|
||||
project_id = test_project["id"]
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/projects/{project_id}/agents/{fake_agent_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentMetrics:
|
||||
"""Tests for agent metrics endpoints."""
|
||||
|
||||
async def test_get_agent_metrics(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test getting metrics for a single agent."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent
|
||||
create_response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Metrics Test Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
agent_id = create_response.json()["id"]
|
||||
|
||||
# Get metrics
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/agents/{agent_id}/metrics",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
# AgentInstanceMetrics schema
|
||||
assert "total_instances" in data
|
||||
assert "total_tasks_completed" in data
|
||||
assert "total_tokens_used" in data
|
||||
assert "total_cost_incurred" in data
|
||||
|
||||
async def test_get_project_agents_metrics(
|
||||
self, client, user_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test getting metrics for all agents in a project."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agents
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Metrics Agent 1",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Metrics Agent 2",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
# Get project-wide metrics
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{project_id}/agents/metrics",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentAuthorization:
|
||||
"""Tests for agent authorization."""
|
||||
|
||||
async def test_superuser_can_manage_any_project_agents(
|
||||
self, client, user_token, superuser_token, test_project, test_agent_type
|
||||
):
|
||||
"""Test that superuser can manage agents in any project."""
|
||||
project_id = test_project["id"]
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
# Create agent as superuser in user's project
|
||||
response = await client.post(
|
||||
f"/api/v1/projects/{project_id}/agents",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"agent_type_id": agent_type_id,
|
||||
"name": "Superuser Created Agent",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
async def test_user_cannot_access_other_project_agents(
|
||||
self, client, user_token, superuser_token, test_agent_type
|
||||
):
|
||||
"""Test that user cannot access agents in another user's project."""
|
||||
# Create a project as superuser (not owned by regular user)
|
||||
project_response = await client.post(
|
||||
"/api/v1/projects",
|
||||
json={
|
||||
"name": "Other User Project",
|
||||
"slug": f"other-user-project-{uuid.uuid4().hex[:8]}",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
other_project_id = project_response.json()["id"]
|
||||
|
||||
# Regular user tries to list agents - should fail
|
||||
response = await client.get(
|
||||
f"/api/v1/projects/{other_project_id}/agents",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
985
backend/tests/api/routes/syndarix/test_issues.py
Normal file
985
backend/tests/api/routes/syndarix/test_issues.py
Normal file
@@ -0,0 +1,985 @@
|
||||
# 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
|
||||
1040
backend/tests/api/routes/syndarix/test_projects.py
Normal file
1040
backend/tests/api/routes/syndarix/test_projects.py
Normal file
File diff suppressed because it is too large
Load Diff
1121
backend/tests/api/routes/syndarix/test_sprints.py
Normal file
1121
backend/tests/api/routes/syndarix/test_sprints.py
Normal file
File diff suppressed because it is too large
Load Diff
473
backend/tests/crud/syndarix/test_agent_instance.py
Normal file
473
backend/tests/crud/syndarix/test_agent_instance.py
Normal file
@@ -0,0 +1,473 @@
|
||||
# tests/crud/syndarix/test_agent_instance.py
|
||||
"""Tests for AgentInstance CRUD operations."""
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
from app.crud.syndarix.agent_instance import agent_instance
|
||||
from app.models.syndarix import AgentInstance, AgentType, Project
|
||||
from app.models.syndarix.enums import (
|
||||
AgentStatus,
|
||||
ProjectStatus,
|
||||
)
|
||||
from app.schemas.syndarix import AgentInstanceCreate
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(async_test_db):
|
||||
"""Create a database session for tests."""
|
||||
_, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(db_session):
|
||||
"""Create a test project."""
|
||||
project = Project(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Project",
|
||||
slug=f"test-project-{uuid.uuid4().hex[:8]}",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_agent_type(db_session):
|
||||
"""Create a test agent type."""
|
||||
agent_type = AgentType(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Agent Type",
|
||||
slug=f"test-agent-type-{uuid.uuid4().hex[:8]}",
|
||||
primary_model="claude-3-opus",
|
||||
personality_prompt="You are a helpful test agent.",
|
||||
)
|
||||
db_session.add(agent_type)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(agent_type)
|
||||
return agent_type
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_agent_instance(db_session, test_project, test_agent_type):
|
||||
"""Create a test agent instance."""
|
||||
instance = AgentInstance(
|
||||
id=uuid.uuid4(),
|
||||
agent_type_id=test_agent_type.id,
|
||||
project_id=test_project.id,
|
||||
name="Test Agent",
|
||||
status=AgentStatus.IDLE,
|
||||
)
|
||||
db_session.add(instance)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
class TestAgentInstanceCreate:
|
||||
"""Tests for agent instance creation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_instance_success(
|
||||
self, db_session, test_project, test_agent_type
|
||||
):
|
||||
"""Test successful agent instance creation."""
|
||||
instance_data = AgentInstanceCreate(
|
||||
agent_type_id=test_agent_type.id,
|
||||
project_id=test_project.id,
|
||||
name="New Agent",
|
||||
)
|
||||
created = await agent_instance.create(db_session, obj_in=instance_data)
|
||||
assert created.name == "New Agent"
|
||||
assert created.status == AgentStatus.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_instance_with_all_fields(
|
||||
self, db_session, test_project, test_agent_type
|
||||
):
|
||||
"""Test agent instance creation with all optional fields."""
|
||||
instance_data = AgentInstanceCreate(
|
||||
agent_type_id=test_agent_type.id,
|
||||
project_id=test_project.id,
|
||||
name="Full Agent",
|
||||
status=AgentStatus.WORKING,
|
||||
current_task="Processing request",
|
||||
short_term_memory={"context": "test context", "history": []},
|
||||
long_term_memory_ref="ref-123",
|
||||
session_id="session-456",
|
||||
)
|
||||
created = await agent_instance.create(db_session, obj_in=instance_data)
|
||||
assert created.current_task == "Processing request"
|
||||
assert created.status == AgentStatus.WORKING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_instance_integrity_error(
|
||||
self, db_session, test_project, test_agent_type
|
||||
):
|
||||
"""Test agent instance creation with integrity error."""
|
||||
instance_data = AgentInstanceCreate(
|
||||
agent_type_id=test_agent_type.id,
|
||||
project_id=test_project.id,
|
||||
name="Test Agent",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=IntegrityError("", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(ValueError, match="Database integrity error"):
|
||||
await agent_instance.create(db_session, obj_in=instance_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_instance_unexpected_error(
|
||||
self, db_session, test_project, test_agent_type
|
||||
):
|
||||
"""Test agent instance creation with unexpected error."""
|
||||
instance_data = AgentInstanceCreate(
|
||||
agent_type_id=test_agent_type.id,
|
||||
project_id=test_project.id,
|
||||
name="Test Agent",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=RuntimeError("Unexpected error"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Unexpected error"):
|
||||
await agent_instance.create(db_session, obj_in=instance_data)
|
||||
|
||||
|
||||
class TestAgentInstanceGetWithDetails:
|
||||
"""Tests for getting agent instance with details."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_not_found(self, db_session):
|
||||
"""Test getting non-existent agent instance with details."""
|
||||
result = await agent_instance.get_with_details(
|
||||
db_session, instance_id=uuid.uuid4()
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_success(self, db_session, test_agent_instance):
|
||||
"""Test getting agent instance with details."""
|
||||
result = await agent_instance.get_with_details(
|
||||
db_session, instance_id=test_agent_instance.id
|
||||
)
|
||||
assert result is not None
|
||||
assert result["instance"].id == test_agent_instance.id
|
||||
assert "agent_type_name" in result
|
||||
assert "assigned_issues_count" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_db_error(self, db_session, test_agent_instance):
|
||||
"""Test getting agent instance with details when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.get_with_details(
|
||||
db_session, instance_id=test_agent_instance.id
|
||||
)
|
||||
|
||||
|
||||
class TestAgentInstanceGetByProject:
|
||||
"""Tests for getting agent instances by project."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_success(
|
||||
self, db_session, test_project, test_agent_instance
|
||||
):
|
||||
"""Test getting agent instances by project."""
|
||||
instances, total = await agent_instance.get_by_project(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert len(instances) == 1
|
||||
assert total == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_with_status_filter(
|
||||
self, db_session, test_project, test_agent_instance
|
||||
):
|
||||
"""Test getting agent instances with status filter."""
|
||||
instances, total = await agent_instance.get_by_project(
|
||||
db_session,
|
||||
project_id=test_project.id,
|
||||
status=AgentStatus.IDLE,
|
||||
)
|
||||
assert len(instances) == 1
|
||||
assert instances[0].status == AgentStatus.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_db_error(self, db_session, test_project):
|
||||
"""Test getting agent instances when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.get_by_project(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
|
||||
|
||||
class TestAgentInstanceGetByAgentType:
|
||||
"""Tests for getting agent instances by agent type."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_agent_type_success(
|
||||
self, db_session, test_agent_type, test_agent_instance
|
||||
):
|
||||
"""Test getting agent instances by agent type."""
|
||||
instances = await agent_instance.get_by_agent_type(
|
||||
db_session, agent_type_id=test_agent_type.id
|
||||
)
|
||||
assert len(instances) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_agent_type_with_status_filter(
|
||||
self, db_session, test_agent_type, test_agent_instance
|
||||
):
|
||||
"""Test getting agent instances by agent type with status filter."""
|
||||
instances = await agent_instance.get_by_agent_type(
|
||||
db_session,
|
||||
agent_type_id=test_agent_type.id,
|
||||
status=AgentStatus.IDLE,
|
||||
)
|
||||
assert len(instances) == 1
|
||||
assert instances[0].status == AgentStatus.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_agent_type_db_error(self, db_session, test_agent_type):
|
||||
"""Test getting agent instances by agent type when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.get_by_agent_type(
|
||||
db_session, agent_type_id=test_agent_type.id
|
||||
)
|
||||
|
||||
|
||||
class TestAgentInstanceStatusOperations:
|
||||
"""Tests for agent instance status operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_status_not_found(self, db_session):
|
||||
"""Test updating status for non-existent agent instance."""
|
||||
result = await agent_instance.update_status(
|
||||
db_session,
|
||||
instance_id=uuid.uuid4(),
|
||||
status=AgentStatus.WORKING,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_status_success(self, db_session, test_agent_instance):
|
||||
"""Test successfully updating agent instance status."""
|
||||
result = await agent_instance.update_status(
|
||||
db_session,
|
||||
instance_id=test_agent_instance.id,
|
||||
status=AgentStatus.WORKING,
|
||||
current_task="Processing task",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == AgentStatus.WORKING
|
||||
assert result.current_task == "Processing task"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_status_db_error(self, db_session, test_agent_instance):
|
||||
"""Test updating status when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.update_status(
|
||||
db_session,
|
||||
instance_id=test_agent_instance.id,
|
||||
status=AgentStatus.WORKING,
|
||||
)
|
||||
|
||||
|
||||
class TestAgentInstanceTerminate:
|
||||
"""Tests for agent instance termination."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_not_found(self, db_session):
|
||||
"""Test terminating non-existent agent instance."""
|
||||
result = await agent_instance.terminate(db_session, instance_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_success(self, db_session, test_agent_instance):
|
||||
"""Test successfully terminating agent instance."""
|
||||
result = await agent_instance.terminate(
|
||||
db_session, instance_id=test_agent_instance.id
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == AgentStatus.TERMINATED
|
||||
assert result.terminated_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_db_error(self, db_session, test_agent_instance):
|
||||
"""Test terminating agent instance when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.terminate(
|
||||
db_session, instance_id=test_agent_instance.id
|
||||
)
|
||||
|
||||
|
||||
class TestAgentInstanceTaskCompletion:
|
||||
"""Tests for recording task completion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_task_completion_not_found(self, db_session):
|
||||
"""Test recording task completion for non-existent agent instance."""
|
||||
result = await agent_instance.record_task_completion(
|
||||
db_session,
|
||||
instance_id=uuid.uuid4(),
|
||||
tokens_used=100,
|
||||
cost_incurred=Decimal("0.01"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_task_completion_success(
|
||||
self, db_session, test_agent_instance
|
||||
):
|
||||
"""Test successfully recording task completion."""
|
||||
result = await agent_instance.record_task_completion(
|
||||
db_session,
|
||||
instance_id=test_agent_instance.id,
|
||||
tokens_used=1000,
|
||||
cost_incurred=Decimal("0.05"),
|
||||
)
|
||||
assert result is not None
|
||||
assert result.tasks_completed == 1
|
||||
assert result.tokens_used == 1000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_task_completion_db_error(
|
||||
self, db_session, test_agent_instance
|
||||
):
|
||||
"""Test recording task completion when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.record_task_completion(
|
||||
db_session,
|
||||
instance_id=test_agent_instance.id,
|
||||
tokens_used=100,
|
||||
cost_incurred=Decimal("0.01"),
|
||||
)
|
||||
|
||||
|
||||
class TestAgentInstanceMetrics:
|
||||
"""Tests for agent instance metrics."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_metrics_empty(self, db_session, test_project):
|
||||
"""Test getting project metrics with no agent instances."""
|
||||
result = await agent_instance.get_project_metrics(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert result["total_instances"] == 0
|
||||
assert result["active_instances"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_metrics_with_data(
|
||||
self, db_session, test_project, test_agent_instance
|
||||
):
|
||||
"""Test getting project metrics with agent instances."""
|
||||
result = await agent_instance.get_project_metrics(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert result["total_instances"] == 1
|
||||
assert result["idle_instances"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_metrics_db_error(self, db_session, test_project):
|
||||
"""Test getting project metrics when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.get_project_metrics(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
|
||||
|
||||
class TestAgentInstanceBulkTerminate:
|
||||
"""Tests for bulk termination."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_terminate_by_project_empty(self, db_session, test_project):
|
||||
"""Test bulk terminating with no agent instances."""
|
||||
count = await agent_instance.bulk_terminate_by_project(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_terminate_by_project_success(
|
||||
self, db_session, test_project, test_agent_instance, test_agent_type
|
||||
):
|
||||
"""Test successfully bulk terminating agent instances."""
|
||||
# Create another active instance
|
||||
instance2 = AgentInstance(
|
||||
id=uuid.uuid4(),
|
||||
agent_type_id=test_agent_type.id,
|
||||
project_id=test_project.id,
|
||||
name="Test Agent 2",
|
||||
status=AgentStatus.WORKING,
|
||||
)
|
||||
db_session.add(instance2)
|
||||
await db_session.commit()
|
||||
|
||||
count = await agent_instance.bulk_terminate_by_project(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_terminate_by_project_db_error(
|
||||
self, db_session, test_project, test_agent_instance
|
||||
):
|
||||
"""Test bulk terminating when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_instance.bulk_terminate_by_project(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
307
backend/tests/crud/syndarix/test_agent_type.py
Normal file
307
backend/tests/crud/syndarix/test_agent_type.py
Normal file
@@ -0,0 +1,307 @@
|
||||
# tests/crud/syndarix/test_agent_type.py
|
||||
"""Tests for AgentType CRUD operations."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
from app.crud.syndarix.agent_type import agent_type
|
||||
from app.models.syndarix import AgentInstance, AgentType, Project
|
||||
from app.models.syndarix.enums import AgentStatus, ProjectStatus
|
||||
from app.schemas.syndarix import AgentTypeCreate
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(async_test_db):
|
||||
"""Create a database session for tests."""
|
||||
_, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_agent_type(db_session):
|
||||
"""Create a test agent type."""
|
||||
at = AgentType(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Agent Type",
|
||||
slug=f"test-agent-type-{uuid.uuid4().hex[:8]}",
|
||||
primary_model="claude-3-opus",
|
||||
personality_prompt="You are a helpful test agent.",
|
||||
expertise=["python", "testing"],
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(at)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(at)
|
||||
return at
|
||||
|
||||
|
||||
class TestAgentTypeGetBySlug:
|
||||
"""Tests for getting agent type by slug."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_not_found(self, db_session):
|
||||
"""Test getting non-existent agent type by slug."""
|
||||
result = await agent_type.get_by_slug(db_session, slug="nonexistent")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_success(self, db_session, test_agent_type):
|
||||
"""Test successfully getting agent type by slug."""
|
||||
result = await agent_type.get_by_slug(db_session, slug=test_agent_type.slug)
|
||||
assert result is not None
|
||||
assert result.id == test_agent_type.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_db_error(self, db_session):
|
||||
"""Test getting agent type by slug when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_type.get_by_slug(db_session, slug="test")
|
||||
|
||||
|
||||
class TestAgentTypeCreate:
|
||||
"""Tests for agent type creation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_agent_type_success(self, db_session):
|
||||
"""Test successful agent type creation."""
|
||||
agent_type_data = AgentTypeCreate(
|
||||
name="New Agent Type",
|
||||
slug=f"new-agent-type-{uuid.uuid4().hex[:8]}",
|
||||
primary_model="claude-3-opus",
|
||||
personality_prompt="You are a new agent.",
|
||||
)
|
||||
created = await agent_type.create(db_session, obj_in=agent_type_data)
|
||||
assert created.name == "New Agent Type"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_agent_type_duplicate_slug(self, db_session, test_agent_type):
|
||||
"""Test agent type creation with duplicate slug."""
|
||||
agent_type_data = AgentTypeCreate(
|
||||
name="Another Agent Type",
|
||||
slug=test_agent_type.slug, # Use existing slug
|
||||
primary_model="claude-3-opus",
|
||||
personality_prompt="You are another agent.",
|
||||
)
|
||||
|
||||
# Mock IntegrityError with slug in the message
|
||||
mock_orig = MagicMock()
|
||||
mock_orig.__str__ = lambda self: "duplicate key value violates unique constraint on slug"
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=IntegrityError("", {}, mock_orig),
|
||||
):
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await agent_type.create(db_session, obj_in=agent_type_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_agent_type_integrity_error(self, db_session):
|
||||
"""Test agent type creation with general integrity error."""
|
||||
agent_type_data = AgentTypeCreate(
|
||||
name="Test Agent Type",
|
||||
slug=f"test-{uuid.uuid4().hex[:8]}",
|
||||
primary_model="claude-3-opus",
|
||||
personality_prompt="You are a test agent.",
|
||||
)
|
||||
|
||||
# Mock IntegrityError without slug in the message
|
||||
mock_orig = MagicMock()
|
||||
mock_orig.__str__ = lambda self: "foreign key constraint violation"
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=IntegrityError("", {}, mock_orig),
|
||||
):
|
||||
with pytest.raises(ValueError, match="Database integrity error"):
|
||||
await agent_type.create(db_session, obj_in=agent_type_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_agent_type_unexpected_error(self, db_session):
|
||||
"""Test agent type creation with unexpected error."""
|
||||
agent_type_data = AgentTypeCreate(
|
||||
name="Test Agent Type",
|
||||
slug=f"test-{uuid.uuid4().hex[:8]}",
|
||||
primary_model="claude-3-opus",
|
||||
personality_prompt="You are a test agent.",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=RuntimeError("Unexpected error"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Unexpected error"):
|
||||
await agent_type.create(db_session, obj_in=agent_type_data)
|
||||
|
||||
|
||||
class TestAgentTypeGetMultiWithFilters:
|
||||
"""Tests for getting agent types with filters."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_filters_success(self, db_session, test_agent_type):
|
||||
"""Test successfully getting agent types with filters."""
|
||||
results, total = await agent_type.get_multi_with_filters(db_session)
|
||||
assert total >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_filters_sort_asc(self, db_session, test_agent_type):
|
||||
"""Test getting agent types with ascending sort order."""
|
||||
results, total = await agent_type.get_multi_with_filters(
|
||||
db_session,
|
||||
sort_by="created_at",
|
||||
sort_order="asc",
|
||||
)
|
||||
assert total >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_filters_db_error(self, db_session):
|
||||
"""Test getting agent types when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_type.get_multi_with_filters(db_session)
|
||||
|
||||
|
||||
class TestAgentTypeGetWithInstanceCount:
|
||||
"""Tests for getting agent type with instance count."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_instance_count_not_found(self, db_session):
|
||||
"""Test getting non-existent agent type with instance count."""
|
||||
result = await agent_type.get_with_instance_count(
|
||||
db_session, agent_type_id=uuid.uuid4()
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_instance_count_success(self, db_session, test_agent_type):
|
||||
"""Test successfully getting agent type with instance count."""
|
||||
result = await agent_type.get_with_instance_count(
|
||||
db_session, agent_type_id=test_agent_type.id
|
||||
)
|
||||
assert result is not None
|
||||
assert result["agent_type"].id == test_agent_type.id
|
||||
assert result["instance_count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_instance_count_db_error(self, db_session, test_agent_type):
|
||||
"""Test getting agent type with instance count when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_type.get_with_instance_count(
|
||||
db_session, agent_type_id=test_agent_type.id
|
||||
)
|
||||
|
||||
|
||||
class TestAgentTypeGetMultiWithInstanceCounts:
|
||||
"""Tests for getting agent types with instance counts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_instance_counts_empty(self, db_session):
|
||||
"""Test getting agent types with instance counts when none exist."""
|
||||
# Create a separate project to ensure isolation
|
||||
results, total = await agent_type.get_multi_with_instance_counts(
|
||||
db_session,
|
||||
is_active=None,
|
||||
search="nonexistent-xyz-query",
|
||||
)
|
||||
assert results == []
|
||||
assert total == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_instance_counts_success(
|
||||
self, db_session, test_agent_type
|
||||
):
|
||||
"""Test successfully getting agent types with instance counts."""
|
||||
results, total = await agent_type.get_multi_with_instance_counts(db_session)
|
||||
assert total >= 1
|
||||
assert len(results) >= 1
|
||||
assert "agent_type" in results[0]
|
||||
assert "instance_count" in results[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_instance_counts_db_error(
|
||||
self, db_session, test_agent_type
|
||||
):
|
||||
"""Test getting agent types with instance counts when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_type.get_multi_with_instance_counts(db_session)
|
||||
|
||||
|
||||
class TestAgentTypeGetByExpertise:
|
||||
"""Tests for getting agent types by expertise."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Uses PostgreSQL JSONB contains operator, not available in SQLite")
|
||||
async def test_get_by_expertise_success(self, db_session, test_agent_type):
|
||||
"""Test successfully getting agent types by expertise."""
|
||||
results = await agent_type.get_by_expertise(db_session, expertise="python")
|
||||
assert len(results) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Uses PostgreSQL JSONB contains operator, not available in SQLite")
|
||||
async def test_get_by_expertise_db_error(self, db_session):
|
||||
"""Test getting agent types by expertise when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_type.get_by_expertise(db_session, expertise="python")
|
||||
|
||||
|
||||
class TestAgentTypeDeactivate:
|
||||
"""Tests for deactivating agent types."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_not_found(self, db_session):
|
||||
"""Test deactivating non-existent agent type."""
|
||||
result = await agent_type.deactivate(db_session, agent_type_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_success(self, db_session, test_agent_type):
|
||||
"""Test successfully deactivating agent type."""
|
||||
result = await agent_type.deactivate(
|
||||
db_session, agent_type_id=test_agent_type.id
|
||||
)
|
||||
assert result is not None
|
||||
assert result.is_active is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_db_error(self, db_session, test_agent_type):
|
||||
"""Test deactivating agent type when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await agent_type.deactivate(
|
||||
db_session, agent_type_id=test_agent_type.id
|
||||
)
|
||||
673
backend/tests/crud/syndarix/test_issue.py
Normal file
673
backend/tests/crud/syndarix/test_issue.py
Normal file
@@ -0,0 +1,673 @@
|
||||
# tests/crud/syndarix/test_issue.py
|
||||
"""Tests for Issue CRUD operations."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
from app.crud.syndarix.issue import CRUDIssue, issue
|
||||
from app.models.syndarix import Issue, Project, Sprint
|
||||
from app.models.syndarix.enums import (
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
ProjectStatus,
|
||||
SprintStatus,
|
||||
SyncStatus,
|
||||
)
|
||||
from app.schemas.syndarix import IssueCreate, IssueUpdate
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(async_test_db):
|
||||
"""Create a database session for tests."""
|
||||
_, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(db_session):
|
||||
"""Create a test project for issues."""
|
||||
project = Project(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Project",
|
||||
slug=f"test-project-{uuid.uuid4().hex[:8]}",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_sprint(db_session, test_project):
|
||||
"""Create a test sprint."""
|
||||
from datetime import date
|
||||
sprint = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Test Sprint",
|
||||
number=1,
|
||||
status=SprintStatus.PLANNED,
|
||||
start_date=date.today(),
|
||||
end_date=date.today(),
|
||||
)
|
||||
db_session.add(sprint)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(sprint)
|
||||
return sprint
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_issue(db_session, test_project):
|
||||
"""Create a test issue."""
|
||||
issue_obj = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
title="Test Issue",
|
||||
body="Test issue body",
|
||||
status=IssueStatus.OPEN,
|
||||
priority=IssuePriority.MEDIUM,
|
||||
labels=["bug", "backend"],
|
||||
)
|
||||
db_session.add(issue_obj)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(issue_obj)
|
||||
return issue_obj
|
||||
|
||||
|
||||
class TestIssueCreate:
|
||||
"""Tests for issue creation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_success(self, db_session, test_project):
|
||||
"""Test successful issue creation."""
|
||||
issue_data = IssueCreate(
|
||||
project_id=test_project.id,
|
||||
title="New Issue",
|
||||
body="Issue description",
|
||||
status=IssueStatus.OPEN,
|
||||
priority=IssuePriority.HIGH,
|
||||
labels=["feature"],
|
||||
)
|
||||
created = await issue.create(db_session, obj_in=issue_data)
|
||||
assert created.title == "New Issue"
|
||||
assert created.priority == IssuePriority.HIGH
|
||||
assert created.sync_status == SyncStatus.SYNCED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_with_external_tracker(self, db_session, test_project):
|
||||
"""Test issue creation with external tracker info."""
|
||||
issue_data = IssueCreate(
|
||||
project_id=test_project.id,
|
||||
title="External Issue",
|
||||
external_tracker_type="gitea",
|
||||
external_issue_id="ext-123",
|
||||
remote_url="https://gitea.example.com/issues/123",
|
||||
external_issue_number=123,
|
||||
)
|
||||
created = await issue.create(db_session, obj_in=issue_data)
|
||||
assert created.external_tracker_type == "gitea"
|
||||
assert created.external_issue_id == "ext-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_integrity_error(self, db_session, test_project):
|
||||
"""Test issue creation with integrity error."""
|
||||
issue_data = IssueCreate(
|
||||
project_id=test_project.id,
|
||||
title="Test Issue",
|
||||
)
|
||||
|
||||
# Mock commit to raise IntegrityError
|
||||
mock_orig = MagicMock()
|
||||
mock_orig.__str__ = lambda self: "UNIQUE constraint failed"
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=IntegrityError("", {}, mock_orig),
|
||||
):
|
||||
with pytest.raises(ValueError, match="Database integrity error"):
|
||||
await issue.create(db_session, obj_in=issue_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_unexpected_error(self, db_session, test_project):
|
||||
"""Test issue creation with unexpected error."""
|
||||
issue_data = IssueCreate(
|
||||
project_id=test_project.id,
|
||||
title="Test Issue",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=RuntimeError("Unexpected error"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Unexpected error"):
|
||||
await issue.create(db_session, obj_in=issue_data)
|
||||
|
||||
|
||||
class TestIssueGetWithDetails:
|
||||
"""Tests for getting issue with details."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_not_found(self, db_session):
|
||||
"""Test getting non-existent issue with details."""
|
||||
result = await issue.get_with_details(db_session, issue_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_success(self, db_session, test_issue):
|
||||
"""Test getting issue with details."""
|
||||
result = await issue.get_with_details(db_session, issue_id=test_issue.id)
|
||||
assert result is not None
|
||||
assert result["issue"].id == test_issue.id
|
||||
assert "project_name" in result
|
||||
assert "project_slug" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_db_error(self, db_session, test_issue):
|
||||
"""Test getting issue with details when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.get_with_details(db_session, issue_id=test_issue.id)
|
||||
|
||||
|
||||
class TestIssueGetByProject:
|
||||
"""Tests for getting issues by project."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_with_filters(
|
||||
self, db_session, test_project, test_issue
|
||||
):
|
||||
"""Test getting issues with various filters."""
|
||||
# Create issue with specific labels
|
||||
issue2 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
title="Filtered Issue",
|
||||
status=IssueStatus.IN_PROGRESS,
|
||||
priority=IssuePriority.HIGH,
|
||||
labels=["frontend"],
|
||||
)
|
||||
db_session.add(issue2)
|
||||
await db_session.commit()
|
||||
|
||||
# Test status filter
|
||||
issues, total = await issue.get_by_project(
|
||||
db_session,
|
||||
project_id=test_project.id,
|
||||
status=IssueStatus.IN_PROGRESS,
|
||||
)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].status == IssueStatus.IN_PROGRESS
|
||||
|
||||
# Test priority filter
|
||||
issues, total = await issue.get_by_project(
|
||||
db_session,
|
||||
project_id=test_project.id,
|
||||
priority=IssuePriority.HIGH,
|
||||
)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].priority == IssuePriority.HIGH
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Labels filter uses PostgreSQL @> operator, not available in SQLite")
|
||||
async def test_get_by_project_with_labels_filter(
|
||||
self, db_session, test_project, test_issue
|
||||
):
|
||||
"""Test getting issues filtered by labels."""
|
||||
issues, total = await issue.get_by_project(
|
||||
db_session,
|
||||
project_id=test_project.id,
|
||||
labels=["bug"],
|
||||
)
|
||||
assert len(issues) == 1
|
||||
assert "bug" in issues[0].labels
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_sort_order_asc(
|
||||
self, db_session, test_project, test_issue
|
||||
):
|
||||
"""Test getting issues with ascending sort order."""
|
||||
# Create another issue
|
||||
issue2 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
title="Second Issue",
|
||||
status=IssueStatus.OPEN,
|
||||
)
|
||||
db_session.add(issue2)
|
||||
await db_session.commit()
|
||||
|
||||
issues, total = await issue.get_by_project(
|
||||
db_session,
|
||||
project_id=test_project.id,
|
||||
sort_by="created_at",
|
||||
sort_order="asc",
|
||||
)
|
||||
assert len(issues) == 2
|
||||
# Compare without timezone info since DB may strip it
|
||||
first_time = issues[0].created_at.replace(tzinfo=None) if issues[0].created_at.tzinfo else issues[0].created_at
|
||||
second_time = issues[1].created_at.replace(tzinfo=None) if issues[1].created_at.tzinfo else issues[1].created_at
|
||||
assert first_time <= second_time
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_db_error(self, db_session, test_project):
|
||||
"""Test getting issues when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.get_by_project(db_session, project_id=test_project.id)
|
||||
|
||||
|
||||
class TestIssueGetBySprint:
|
||||
"""Tests for getting issues by sprint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_sprint_with_status(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test getting issues by sprint with status filter."""
|
||||
# Create issues in sprint
|
||||
issue1 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Sprint Issue 1",
|
||||
status=IssueStatus.OPEN,
|
||||
)
|
||||
issue2 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Sprint Issue 2",
|
||||
status=IssueStatus.CLOSED,
|
||||
)
|
||||
db_session.add_all([issue1, issue2])
|
||||
await db_session.commit()
|
||||
|
||||
# Test status filter
|
||||
issues = await issue.get_by_sprint(
|
||||
db_session,
|
||||
sprint_id=test_sprint.id,
|
||||
status=IssueStatus.OPEN,
|
||||
)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].status == IssueStatus.OPEN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_sprint_db_error(self, db_session, test_sprint):
|
||||
"""Test getting issues by sprint when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.get_by_sprint(db_session, sprint_id=test_sprint.id)
|
||||
|
||||
|
||||
class TestIssueAssignment:
|
||||
"""Tests for issue assignment operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_to_agent_not_found(self, db_session):
|
||||
"""Test assigning non-existent issue to agent."""
|
||||
result = await issue.assign_to_agent(
|
||||
db_session, issue_id=uuid.uuid4(), agent_id=uuid.uuid4()
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_to_agent_db_error(self, db_session, test_issue):
|
||||
"""Test assigning issue to agent when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.assign_to_agent(
|
||||
db_session, issue_id=test_issue.id, agent_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_to_human_not_found(self, db_session):
|
||||
"""Test assigning non-existent issue to human."""
|
||||
result = await issue.assign_to_human(
|
||||
db_session, issue_id=uuid.uuid4(), human_assignee="john@example.com"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_to_human_db_error(self, db_session, test_issue):
|
||||
"""Test assigning issue to human when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.assign_to_human(
|
||||
db_session,
|
||||
issue_id=test_issue.id,
|
||||
human_assignee="john@example.com",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unassign_not_found(self, db_session):
|
||||
"""Test unassigning non-existent issue."""
|
||||
result = await issue.unassign(db_session, issue_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unassign_db_error(self, db_session, test_issue):
|
||||
"""Test unassigning issue when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.unassign(db_session, issue_id=test_issue.id)
|
||||
|
||||
|
||||
class TestIssueStatusChanges:
|
||||
"""Tests for issue status change operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_issue_not_found(self, db_session):
|
||||
"""Test closing non-existent issue."""
|
||||
result = await issue.close_issue(db_session, issue_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_issue_db_error(self, db_session, test_issue):
|
||||
"""Test closing issue when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.close_issue(db_session, issue_id=test_issue.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reopen_issue_not_found(self, db_session):
|
||||
"""Test reopening non-existent issue."""
|
||||
result = await issue.reopen_issue(db_session, issue_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reopen_issue_db_error(self, db_session, test_issue):
|
||||
"""Test reopening issue when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.reopen_issue(db_session, issue_id=test_issue.id)
|
||||
|
||||
|
||||
class TestIssueSyncStatus:
|
||||
"""Tests for issue sync status operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_sync_status_not_found(self, db_session):
|
||||
"""Test updating sync status for non-existent issue."""
|
||||
result = await issue.update_sync_status(
|
||||
db_session,
|
||||
issue_id=uuid.uuid4(),
|
||||
sync_status=SyncStatus.SYNCED,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_sync_status_with_timestamps(self, db_session, test_issue):
|
||||
"""Test updating sync status with timestamps."""
|
||||
now = datetime.now(UTC)
|
||||
result = await issue.update_sync_status(
|
||||
db_session,
|
||||
issue_id=test_issue.id,
|
||||
sync_status=SyncStatus.SYNCED,
|
||||
last_synced_at=now,
|
||||
external_updated_at=now,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.sync_status == SyncStatus.SYNCED
|
||||
# Compare without timezone info since DB may strip it
|
||||
assert result.last_synced_at.replace(tzinfo=None) == now.replace(tzinfo=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_sync_status_db_error(self, db_session, test_issue):
|
||||
"""Test updating sync status when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.update_sync_status(
|
||||
db_session,
|
||||
issue_id=test_issue.id,
|
||||
sync_status=SyncStatus.ERROR,
|
||||
)
|
||||
|
||||
|
||||
class TestIssueStats:
|
||||
"""Tests for issue statistics."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_stats(self, db_session, test_project, test_issue):
|
||||
"""Test getting project issue statistics."""
|
||||
stats = await issue.get_project_stats(db_session, project_id=test_project.id)
|
||||
assert stats["total"] >= 1
|
||||
assert "open" in stats
|
||||
assert "by_priority" in stats
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_stats_db_error(self, db_session, test_project):
|
||||
"""Test getting project stats when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.get_project_stats(db_session, project_id=test_project.id)
|
||||
|
||||
|
||||
class TestIssueExternalTracker:
|
||||
"""Tests for external tracker operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_external_id_not_found(self, db_session):
|
||||
"""Test getting issue by non-existent external ID."""
|
||||
result = await issue.get_by_external_id(
|
||||
db_session,
|
||||
external_tracker_type="gitea",
|
||||
external_issue_id="nonexistent",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_external_id_success(self, db_session, test_project):
|
||||
"""Test getting issue by external ID."""
|
||||
# Create issue with external tracker
|
||||
issue_obj = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
title="External Issue",
|
||||
external_tracker_type="gitea",
|
||||
external_issue_id="ext-456",
|
||||
)
|
||||
db_session.add(issue_obj)
|
||||
await db_session.commit()
|
||||
|
||||
result = await issue.get_by_external_id(
|
||||
db_session,
|
||||
external_tracker_type="gitea",
|
||||
external_issue_id="ext-456",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.external_issue_id == "ext-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_external_id_db_error(self, db_session):
|
||||
"""Test getting issue by external ID when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.get_by_external_id(
|
||||
db_session,
|
||||
external_tracker_type="gitea",
|
||||
external_issue_id="test",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_sync(self, db_session, test_project):
|
||||
"""Test getting issues pending sync."""
|
||||
# Create issue with pending sync
|
||||
issue_obj = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
title="Pending Sync Issue",
|
||||
external_tracker_type="gitea",
|
||||
external_issue_id="ext-789",
|
||||
sync_status=SyncStatus.PENDING,
|
||||
)
|
||||
db_session.add(issue_obj)
|
||||
await db_session.commit()
|
||||
|
||||
# Test without project filter
|
||||
issues = await issue.get_pending_sync(db_session)
|
||||
assert len(issues) >= 1
|
||||
|
||||
# Test with project filter
|
||||
issues = await issue.get_pending_sync(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert len(issues) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_sync_db_error(self, db_session):
|
||||
"""Test getting pending sync issues when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.get_pending_sync(db_session)
|
||||
|
||||
|
||||
class TestIssueSprintOperations:
|
||||
"""Tests for sprint-related issue operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_sprint_from_issues(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test removing sprint from all issues."""
|
||||
# Create issues in sprint
|
||||
issue1 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Sprint Issue 1",
|
||||
)
|
||||
issue2 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Sprint Issue 2",
|
||||
)
|
||||
db_session.add_all([issue1, issue2])
|
||||
await db_session.commit()
|
||||
|
||||
count = await issue.remove_sprint_from_issues(
|
||||
db_session, sprint_id=test_sprint.id
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
# Verify issues no longer in sprint
|
||||
await db_session.refresh(issue1)
|
||||
await db_session.refresh(issue2)
|
||||
assert issue1.sprint_id is None
|
||||
assert issue2.sprint_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_sprint_from_issues_db_error(self, db_session, test_sprint):
|
||||
"""Test removing sprint from issues when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.remove_sprint_from_issues(
|
||||
db_session, sprint_id=test_sprint.id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_from_sprint_not_found(self, db_session):
|
||||
"""Test removing non-existent issue from sprint."""
|
||||
result = await issue.remove_from_sprint(db_session, issue_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_from_sprint_success(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test removing issue from sprint."""
|
||||
issue_obj = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Issue in Sprint",
|
||||
)
|
||||
db_session.add(issue_obj)
|
||||
await db_session.commit()
|
||||
|
||||
result = await issue.remove_from_sprint(db_session, issue_id=issue_obj.id)
|
||||
assert result is not None
|
||||
assert result.sprint_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_from_sprint_db_error(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test removing issue from sprint when DB error occurs."""
|
||||
issue_obj = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Issue in Sprint",
|
||||
)
|
||||
db_session.add(issue_obj)
|
||||
await db_session.commit()
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await issue.remove_from_sprint(db_session, issue_id=issue_obj.id)
|
||||
284
backend/tests/crud/syndarix/test_project.py
Normal file
284
backend/tests/crud/syndarix/test_project.py
Normal file
@@ -0,0 +1,284 @@
|
||||
# tests/crud/syndarix/test_project.py
|
||||
"""Tests for Project CRUD operations."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
from app.crud.syndarix.project import project
|
||||
from app.models.syndarix import Project
|
||||
from app.models.syndarix.enums import AutonomyLevel, ProjectStatus
|
||||
from app.schemas.syndarix import ProjectCreate
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(async_test_db):
|
||||
"""Create a database session for tests."""
|
||||
_, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(db_session):
|
||||
"""Create a test project."""
|
||||
proj = Project(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Project",
|
||||
slug=f"test-project-{uuid.uuid4().hex[:8]}",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(proj)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(proj)
|
||||
return proj
|
||||
|
||||
|
||||
class TestProjectGetBySlug:
|
||||
"""Tests for getting project by slug."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_not_found(self, db_session):
|
||||
"""Test getting non-existent project by slug."""
|
||||
result = await project.get_by_slug(db_session, slug="nonexistent")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_success(self, db_session, test_project):
|
||||
"""Test successfully getting project by slug."""
|
||||
result = await project.get_by_slug(db_session, slug=test_project.slug)
|
||||
assert result is not None
|
||||
assert result.id == test_project.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_db_error(self, db_session):
|
||||
"""Test getting project by slug when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await project.get_by_slug(db_session, slug="test")
|
||||
|
||||
|
||||
class TestProjectCreate:
|
||||
"""Tests for project creation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_success(self, db_session):
|
||||
"""Test successful project creation."""
|
||||
project_data = ProjectCreate(
|
||||
name="New Project",
|
||||
slug=f"new-project-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
created = await project.create(db_session, obj_in=project_data)
|
||||
assert created.name == "New Project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_duplicate_slug(self, db_session, test_project):
|
||||
"""Test project creation with duplicate slug."""
|
||||
project_data = ProjectCreate(
|
||||
name="Another Project",
|
||||
slug=test_project.slug, # Use existing slug
|
||||
)
|
||||
|
||||
# Mock IntegrityError with slug in the message
|
||||
mock_orig = MagicMock()
|
||||
mock_orig.__str__ = lambda self: "duplicate key value violates unique constraint on slug"
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=IntegrityError("", {}, mock_orig),
|
||||
):
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await project.create(db_session, obj_in=project_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_integrity_error(self, db_session):
|
||||
"""Test project creation with general integrity error."""
|
||||
project_data = ProjectCreate(
|
||||
name="Test Project",
|
||||
slug=f"test-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
|
||||
# Mock IntegrityError without slug in the message
|
||||
mock_orig = MagicMock()
|
||||
mock_orig.__str__ = lambda self: "foreign key constraint violation"
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=IntegrityError("", {}, mock_orig),
|
||||
):
|
||||
with pytest.raises(ValueError, match="Database integrity error"):
|
||||
await project.create(db_session, obj_in=project_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_unexpected_error(self, db_session):
|
||||
"""Test project creation with unexpected error."""
|
||||
project_data = ProjectCreate(
|
||||
name="Test Project",
|
||||
slug=f"test-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=RuntimeError("Unexpected error"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Unexpected error"):
|
||||
await project.create(db_session, obj_in=project_data)
|
||||
|
||||
|
||||
class TestProjectGetMultiWithFilters:
|
||||
"""Tests for getting projects with filters."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_filters_success(self, db_session, test_project):
|
||||
"""Test successfully getting projects with filters."""
|
||||
results, total = await project.get_multi_with_filters(db_session)
|
||||
assert total >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_filters_db_error(self, db_session):
|
||||
"""Test getting projects when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await project.get_multi_with_filters(db_session)
|
||||
|
||||
|
||||
class TestProjectGetWithCounts:
|
||||
"""Tests for getting project with counts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_counts_not_found(self, db_session):
|
||||
"""Test getting non-existent project with counts."""
|
||||
result = await project.get_with_counts(
|
||||
db_session, project_id=uuid.uuid4()
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_counts_success(self, db_session, test_project):
|
||||
"""Test successfully getting project with counts."""
|
||||
result = await project.get_with_counts(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert result is not None
|
||||
assert result["project"].id == test_project.id
|
||||
assert result["agent_count"] == 0
|
||||
assert result["issue_count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_counts_db_error(self, db_session, test_project):
|
||||
"""Test getting project with counts when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await project.get_with_counts(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
|
||||
|
||||
class TestProjectGetMultiWithCounts:
|
||||
"""Tests for getting projects with counts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_counts_empty(self, db_session):
|
||||
"""Test getting projects with counts when none match."""
|
||||
results, total = await project.get_multi_with_counts(
|
||||
db_session,
|
||||
search="nonexistent-xyz-query",
|
||||
)
|
||||
assert results == []
|
||||
assert total == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_counts_success(self, db_session, test_project):
|
||||
"""Test successfully getting projects with counts."""
|
||||
results, total = await project.get_multi_with_counts(db_session)
|
||||
assert total >= 1
|
||||
assert len(results) >= 1
|
||||
assert "project" in results[0]
|
||||
assert "agent_count" in results[0]
|
||||
assert "issue_count" in results[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_counts_db_error(self, db_session, test_project):
|
||||
"""Test getting projects with counts when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await project.get_multi_with_counts(db_session)
|
||||
|
||||
|
||||
class TestProjectGetByOwner:
|
||||
"""Tests for getting projects by owner."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_projects_by_owner_empty(self, db_session):
|
||||
"""Test getting projects by owner when none exist."""
|
||||
results = await project.get_projects_by_owner(
|
||||
db_session, owner_id=uuid.uuid4()
|
||||
)
|
||||
assert results == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_projects_by_owner_db_error(self, db_session):
|
||||
"""Test getting projects by owner when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await project.get_projects_by_owner(
|
||||
db_session, owner_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
|
||||
class TestProjectArchive:
|
||||
"""Tests for archiving projects."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_project_not_found(self, db_session):
|
||||
"""Test archiving non-existent project."""
|
||||
result = await project.archive_project(db_session, project_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_project_success(self, db_session, test_project):
|
||||
"""Test successfully archiving project."""
|
||||
result = await project.archive_project(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == ProjectStatus.ARCHIVED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_project_db_error(self, db_session, test_project):
|
||||
"""Test archiving project when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await project.archive_project(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
502
backend/tests/crud/syndarix/test_sprint.py
Normal file
502
backend/tests/crud/syndarix/test_sprint.py
Normal file
@@ -0,0 +1,502 @@
|
||||
# tests/crud/syndarix/test_sprint.py
|
||||
"""Tests for Sprint CRUD operations."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
from app.crud.syndarix.sprint import CRUDSprint, sprint
|
||||
from app.models.syndarix import Issue, Project, Sprint
|
||||
from app.models.syndarix.enums import (
|
||||
IssueStatus,
|
||||
ProjectStatus,
|
||||
SprintStatus,
|
||||
)
|
||||
from app.schemas.syndarix import SprintCreate
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(async_test_db):
|
||||
"""Create a database session for tests."""
|
||||
_, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(db_session):
|
||||
"""Create a test project for sprints."""
|
||||
project = Project(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Project",
|
||||
slug=f"test-project-{uuid.uuid4().hex[:8]}",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_sprint(db_session, test_project):
|
||||
"""Create a test sprint."""
|
||||
sprint_obj = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Test Sprint",
|
||||
number=1,
|
||||
status=SprintStatus.PLANNED,
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
)
|
||||
db_session.add(sprint_obj)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(sprint_obj)
|
||||
return sprint_obj
|
||||
|
||||
|
||||
class TestSprintCreate:
|
||||
"""Tests for sprint creation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sprint_success(self, db_session, test_project):
|
||||
"""Test successful sprint creation."""
|
||||
sprint_data = SprintCreate(
|
||||
project_id=test_project.id,
|
||||
name="New Sprint",
|
||||
number=1,
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
)
|
||||
created = await sprint.create(db_session, obj_in=sprint_data)
|
||||
assert created.name == "New Sprint"
|
||||
assert created.number == 1
|
||||
assert created.status == SprintStatus.PLANNED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sprint_with_all_fields(self, db_session, test_project):
|
||||
"""Test sprint creation with all optional fields."""
|
||||
sprint_data = SprintCreate(
|
||||
project_id=test_project.id,
|
||||
name="Full Sprint",
|
||||
number=2,
|
||||
goal="Deliver user authentication",
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
status=SprintStatus.PLANNED,
|
||||
planned_points=20,
|
||||
velocity=15,
|
||||
)
|
||||
created = await sprint.create(db_session, obj_in=sprint_data)
|
||||
assert created.goal == "Deliver user authentication"
|
||||
assert created.planned_points == 20
|
||||
assert created.velocity == 15
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sprint_integrity_error(self, db_session, test_project):
|
||||
"""Test sprint creation with integrity error."""
|
||||
sprint_data = SprintCreate(
|
||||
project_id=test_project.id,
|
||||
name="Test Sprint",
|
||||
number=1,
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=IntegrityError("", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(ValueError, match="Database integrity error"):
|
||||
await sprint.create(db_session, obj_in=sprint_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sprint_unexpected_error(self, db_session, test_project):
|
||||
"""Test sprint creation with unexpected error."""
|
||||
sprint_data = SprintCreate(
|
||||
project_id=test_project.id,
|
||||
name="Test Sprint",
|
||||
number=1,
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=RuntimeError("Unexpected error"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Unexpected error"):
|
||||
await sprint.create(db_session, obj_in=sprint_data)
|
||||
|
||||
|
||||
class TestSprintGetWithDetails:
|
||||
"""Tests for getting sprint with details."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_not_found(self, db_session):
|
||||
"""Test getting non-existent sprint with details."""
|
||||
result = await sprint.get_with_details(db_session, sprint_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_success(self, db_session, test_sprint):
|
||||
"""Test getting sprint with details."""
|
||||
result = await sprint.get_with_details(db_session, sprint_id=test_sprint.id)
|
||||
assert result is not None
|
||||
assert result["sprint"].id == test_sprint.id
|
||||
assert "project_name" in result
|
||||
assert "issue_count" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_details_db_error(self, db_session, test_sprint):
|
||||
"""Test getting sprint with details when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.get_with_details(db_session, sprint_id=test_sprint.id)
|
||||
|
||||
|
||||
class TestSprintGetByProject:
|
||||
"""Tests for getting sprints by project."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_with_status_filter(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test getting sprints with status filter."""
|
||||
sprints, total = await sprint.get_by_project(
|
||||
db_session,
|
||||
project_id=test_project.id,
|
||||
status=SprintStatus.PLANNED,
|
||||
)
|
||||
assert len(sprints) == 1
|
||||
assert sprints[0].status == SprintStatus.PLANNED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_project_db_error(self, db_session, test_project):
|
||||
"""Test getting sprints when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.get_by_project(db_session, project_id=test_project.id)
|
||||
|
||||
|
||||
class TestSprintActiveOperations:
|
||||
"""Tests for active sprint operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_sprint_none(self, db_session, test_project, test_sprint):
|
||||
"""Test getting active sprint when none exists."""
|
||||
result = await sprint.get_active_sprint(db_session, project_id=test_project.id)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_sprint_db_error(self, db_session, test_project):
|
||||
"""Test getting active sprint when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.get_active_sprint(db_session, project_id=test_project.id)
|
||||
|
||||
|
||||
class TestSprintNumberOperations:
|
||||
"""Tests for sprint number operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_sprint_number_empty(self, db_session, test_project):
|
||||
"""Test getting next sprint number for project with no sprints."""
|
||||
result = await sprint.get_next_sprint_number(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert result == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_sprint_number_with_existing(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test getting next sprint number with existing sprints."""
|
||||
result = await sprint.get_next_sprint_number(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert result == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_sprint_number_db_error(self, db_session, test_project):
|
||||
"""Test getting next sprint number when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.get_next_sprint_number(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
|
||||
|
||||
class TestSprintLifecycle:
|
||||
"""Tests for sprint lifecycle operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sprint_not_found(self, db_session):
|
||||
"""Test starting non-existent sprint."""
|
||||
result = await sprint.start_sprint(db_session, sprint_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sprint_invalid_status(self, db_session, test_project):
|
||||
"""Test starting sprint with invalid status."""
|
||||
# Create an active sprint
|
||||
active_sprint = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Active Sprint",
|
||||
number=1,
|
||||
status=SprintStatus.ACTIVE,
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
)
|
||||
db_session.add(active_sprint)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot start sprint with status"):
|
||||
await sprint.start_sprint(db_session, sprint_id=active_sprint.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sprint_with_existing_active(self, db_session, test_project):
|
||||
"""Test starting sprint when another is already active."""
|
||||
# Create active sprint
|
||||
active_sprint = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Active Sprint",
|
||||
number=1,
|
||||
status=SprintStatus.ACTIVE,
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
)
|
||||
db_session.add(active_sprint)
|
||||
|
||||
# Create planned sprint
|
||||
planned_sprint = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Planned Sprint",
|
||||
number=2,
|
||||
status=SprintStatus.PLANNED,
|
||||
start_date=date.today() + timedelta(days=15),
|
||||
end_date=date.today() + timedelta(days=29),
|
||||
)
|
||||
db_session.add(planned_sprint)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="Project already has an active sprint"):
|
||||
await sprint.start_sprint(db_session, sprint_id=planned_sprint.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sprint_db_error(self, db_session, test_sprint):
|
||||
"""Test starting sprint when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.start_sprint(db_session, sprint_id=test_sprint.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_sprint_not_found(self, db_session):
|
||||
"""Test completing non-existent sprint."""
|
||||
result = await sprint.complete_sprint(db_session, sprint_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_sprint_invalid_status(self, db_session, test_sprint):
|
||||
"""Test completing sprint with invalid status (PLANNED)."""
|
||||
with pytest.raises(ValueError, match="Cannot complete sprint with status"):
|
||||
await sprint.complete_sprint(db_session, sprint_id=test_sprint.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_sprint_db_error(self, db_session, test_project):
|
||||
"""Test completing sprint when DB error occurs."""
|
||||
# Create active sprint
|
||||
active_sprint = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Active Sprint",
|
||||
number=1,
|
||||
status=SprintStatus.ACTIVE,
|
||||
start_date=date.today(),
|
||||
end_date=date.today() + timedelta(days=14),
|
||||
)
|
||||
db_session.add(active_sprint)
|
||||
await db_session.commit()
|
||||
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.complete_sprint(db_session, sprint_id=active_sprint.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sprint_not_found(self, db_session):
|
||||
"""Test cancelling non-existent sprint."""
|
||||
result = await sprint.cancel_sprint(db_session, sprint_id=uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sprint_invalid_status(self, db_session, test_project):
|
||||
"""Test cancelling sprint with invalid status (COMPLETED)."""
|
||||
completed_sprint = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Completed Sprint",
|
||||
number=1,
|
||||
status=SprintStatus.COMPLETED,
|
||||
start_date=date.today() - timedelta(days=14),
|
||||
end_date=date.today(),
|
||||
)
|
||||
db_session.add(completed_sprint)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot cancel sprint with status"):
|
||||
await sprint.cancel_sprint(db_session, sprint_id=completed_sprint.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sprint_success(self, db_session, test_sprint):
|
||||
"""Test successfully cancelling a planned sprint."""
|
||||
result = await sprint.cancel_sprint(db_session, sprint_id=test_sprint.id)
|
||||
assert result is not None
|
||||
assert result.status == SprintStatus.CANCELLED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sprint_db_error(self, db_session, test_sprint):
|
||||
"""Test cancelling sprint when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"commit",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.cancel_sprint(db_session, sprint_id=test_sprint.id)
|
||||
|
||||
|
||||
class TestSprintVelocity:
|
||||
"""Tests for velocity operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_velocity_empty(self, db_session, test_project):
|
||||
"""Test getting velocity with no completed sprints."""
|
||||
result = await sprint.get_velocity(db_session, project_id=test_project.id)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_velocity_with_data(self, db_session, test_project):
|
||||
"""Test getting velocity with completed sprints."""
|
||||
completed_sprint = Sprint(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
name="Completed Sprint",
|
||||
number=1,
|
||||
status=SprintStatus.COMPLETED,
|
||||
start_date=date.today() - timedelta(days=14),
|
||||
end_date=date.today(),
|
||||
planned_points=20,
|
||||
velocity=18,
|
||||
)
|
||||
db_session.add(completed_sprint)
|
||||
await db_session.commit()
|
||||
|
||||
result = await sprint.get_velocity(db_session, project_id=test_project.id)
|
||||
assert len(result) == 1
|
||||
assert result[0]["sprint_number"] == 1
|
||||
assert result[0]["velocity"] == 18
|
||||
assert result[0]["velocity_ratio"] == 0.9
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_velocity_db_error(self, db_session, test_project):
|
||||
"""Test getting velocity when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.get_velocity(db_session, project_id=test_project.id)
|
||||
|
||||
|
||||
class TestSprintWithIssueCounts:
|
||||
"""Tests for sprints with issue counts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sprints_with_issue_counts_empty(self, db_session, test_project):
|
||||
"""Test getting sprints with issue counts when no sprints exist."""
|
||||
results, total = await sprint.get_sprints_with_issue_counts(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert results == []
|
||||
assert total == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sprints_with_issue_counts_success(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test getting sprints with issue counts."""
|
||||
# Add some issues to the sprint
|
||||
issue1 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Issue 1",
|
||||
status=IssueStatus.OPEN,
|
||||
)
|
||||
issue2 = Issue(
|
||||
id=uuid.uuid4(),
|
||||
project_id=test_project.id,
|
||||
sprint_id=test_sprint.id,
|
||||
title="Issue 2",
|
||||
status=IssueStatus.CLOSED,
|
||||
)
|
||||
db_session.add_all([issue1, issue2])
|
||||
await db_session.commit()
|
||||
|
||||
results, total = await sprint.get_sprints_with_issue_counts(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0]["issue_count"] == 2
|
||||
assert results[0]["open_issues"] == 1
|
||||
assert results[0]["completed_issues"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sprints_with_issue_counts_db_error(
|
||||
self, db_session, test_project, test_sprint
|
||||
):
|
||||
"""Test getting sprints with issue counts when DB error occurs."""
|
||||
with patch.object(
|
||||
db_session,
|
||||
"execute",
|
||||
side_effect=OperationalError("Connection lost", {}, Exception()),
|
||||
):
|
||||
with pytest.raises(OperationalError):
|
||||
await sprint.get_sprints_with_issue_counts(
|
||||
db_session, project_id=test_project.id
|
||||
)
|
||||
Reference in New Issue
Block a user