forked from cardosofelipe/fast-next-template
- Add Project model with slug, description, autonomy level, and settings - Add AgentType model for agent templates with model config and failover - Add AgentInstance model for running agents with status and memory - Add Issue model with external tracker sync (Gitea/GitHub/GitLab) - Add Sprint model with velocity tracking and lifecycle management - Add comprehensive Pydantic schemas with validation - Add full CRUD operations for all models with filtering/sorting - Add 280+ tests for models, schemas, and CRUD operations Implements #23, #24, #25, #26, #27 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
557 lines
21 KiB
Python
557 lines
21 KiB
Python
# tests/crud/syndarix/test_issue_crud.py
|
|
"""
|
|
Tests for Issue CRUD operations.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from app.crud.syndarix import issue as issue_crud
|
|
from app.models.syndarix import IssuePriority, IssueStatus, SyncStatus
|
|
from app.schemas.syndarix import IssueCreate, IssueUpdate
|
|
|
|
|
|
class TestIssueCreate:
|
|
"""Tests for issue creation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_issue_success(self, async_test_db, test_project_crud):
|
|
"""Test successfully creating an issue."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Test Issue",
|
|
body="This is a test issue body",
|
|
status=IssueStatus.OPEN,
|
|
priority=IssuePriority.HIGH,
|
|
labels=["bug", "security"],
|
|
story_points=5,
|
|
)
|
|
result = await issue_crud.create(session, obj_in=issue_data)
|
|
|
|
assert result.id is not None
|
|
assert result.title == "Test Issue"
|
|
assert result.body == "This is a test issue body"
|
|
assert result.status == IssueStatus.OPEN
|
|
assert result.priority == IssuePriority.HIGH
|
|
assert result.labels == ["bug", "security"]
|
|
assert result.story_points == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_issue_with_external_tracker(self, async_test_db, test_project_crud):
|
|
"""Test creating issue with external tracker info."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="External Issue",
|
|
external_tracker="gitea",
|
|
external_id="gitea-123",
|
|
external_url="https://gitea.example.com/issues/123",
|
|
external_number=123,
|
|
)
|
|
result = await issue_crud.create(session, obj_in=issue_data)
|
|
|
|
assert result.external_tracker == "gitea"
|
|
assert result.external_id == "gitea-123"
|
|
assert result.external_number == 123
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_issue_minimal(self, async_test_db, test_project_crud):
|
|
"""Test creating issue with minimal fields."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Minimal Issue",
|
|
)
|
|
result = await issue_crud.create(session, obj_in=issue_data)
|
|
|
|
assert result.title == "Minimal Issue"
|
|
assert result.body == "" # Default
|
|
assert result.status == IssueStatus.OPEN # Default
|
|
assert result.priority == IssuePriority.MEDIUM # Default
|
|
|
|
|
|
class TestIssueRead:
|
|
"""Tests for issue read operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_issue_by_id(self, async_test_db, test_issue_crud):
|
|
"""Test getting issue by ID."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.get(session, id=str(test_issue_crud.id))
|
|
|
|
assert result is not None
|
|
assert result.id == test_issue_crud.id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_issue_by_id_not_found(self, async_test_db):
|
|
"""Test getting non-existent issue returns None."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.get(session, id=str(uuid.uuid4()))
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_with_details(self, async_test_db, test_issue_crud):
|
|
"""Test getting issue with related details."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.get_with_details(
|
|
session,
|
|
issue_id=test_issue_crud.id,
|
|
)
|
|
|
|
assert result is not None
|
|
assert result["issue"].id == test_issue_crud.id
|
|
assert result["project_name"] is not None
|
|
|
|
|
|
class TestIssueUpdate:
|
|
"""Tests for issue update operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_basic_fields(self, async_test_db, test_issue_crud):
|
|
"""Test updating basic issue fields."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
|
|
|
|
update_data = IssueUpdate(
|
|
title="Updated Title",
|
|
body="Updated body content",
|
|
)
|
|
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
|
|
|
|
assert result.title == "Updated Title"
|
|
assert result.body == "Updated body content"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_status(self, async_test_db, test_issue_crud):
|
|
"""Test updating issue status."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
|
|
|
|
update_data = IssueUpdate(status=IssueStatus.IN_PROGRESS)
|
|
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
|
|
|
|
assert result.status == IssueStatus.IN_PROGRESS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_priority(self, async_test_db, test_issue_crud):
|
|
"""Test updating issue priority."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
|
|
|
|
update_data = IssueUpdate(priority=IssuePriority.CRITICAL)
|
|
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
|
|
|
|
assert result.priority == IssuePriority.CRITICAL
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_labels(self, async_test_db, test_issue_crud):
|
|
"""Test updating issue labels."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
|
|
|
|
update_data = IssueUpdate(labels=["new-label", "updated"])
|
|
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
|
|
|
|
assert "new-label" in result.labels
|
|
|
|
|
|
class TestIssueAssignment:
|
|
"""Tests for issue assignment operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assign_to_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
|
|
"""Test assigning issue to an agent."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.assign_to_agent(
|
|
session,
|
|
issue_id=test_issue_crud.id,
|
|
agent_id=test_agent_instance_crud.id,
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.assigned_agent_id == test_agent_instance_crud.id
|
|
assert result.human_assignee is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unassign_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
|
|
"""Test unassigning agent from issue."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# First assign
|
|
async with AsyncTestingSessionLocal() as session:
|
|
await issue_crud.assign_to_agent(
|
|
session,
|
|
issue_id=test_issue_crud.id,
|
|
agent_id=test_agent_instance_crud.id,
|
|
)
|
|
|
|
# Then unassign
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.assign_to_agent(
|
|
session,
|
|
issue_id=test_issue_crud.id,
|
|
agent_id=None,
|
|
)
|
|
|
|
assert result.assigned_agent_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assign_to_human(self, async_test_db, test_issue_crud):
|
|
"""Test assigning issue to a human."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.assign_to_human(
|
|
session,
|
|
issue_id=test_issue_crud.id,
|
|
human_assignee="developer@example.com",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.human_assignee == "developer@example.com"
|
|
assert result.assigned_agent_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assign_to_human_clears_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
|
|
"""Test assigning to human clears agent assignment."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# First assign to agent
|
|
async with AsyncTestingSessionLocal() as session:
|
|
await issue_crud.assign_to_agent(
|
|
session,
|
|
issue_id=test_issue_crud.id,
|
|
agent_id=test_agent_instance_crud.id,
|
|
)
|
|
|
|
# Then assign to human
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.assign_to_human(
|
|
session,
|
|
issue_id=test_issue_crud.id,
|
|
human_assignee="developer@example.com",
|
|
)
|
|
|
|
assert result.human_assignee == "developer@example.com"
|
|
assert result.assigned_agent_id is None
|
|
|
|
|
|
class TestIssueLifecycle:
|
|
"""Tests for issue lifecycle operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_issue(self, async_test_db, test_issue_crud):
|
|
"""Test closing an issue."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.close_issue(session, issue_id=test_issue_crud.id)
|
|
|
|
assert result is not None
|
|
assert result.status == IssueStatus.CLOSED
|
|
assert result.closed_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reopen_issue(self, async_test_db, test_project_crud):
|
|
"""Test reopening a closed issue."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create and close an issue
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Issue to Reopen",
|
|
)
|
|
created = await issue_crud.create(session, obj_in=issue_data)
|
|
await issue_crud.close_issue(session, issue_id=created.id)
|
|
issue_id = created.id
|
|
|
|
# Reopen
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.reopen_issue(session, issue_id=issue_id)
|
|
|
|
assert result is not None
|
|
assert result.status == IssueStatus.OPEN
|
|
assert result.closed_at is None
|
|
|
|
|
|
class TestIssueByProject:
|
|
"""Tests for getting issues by project."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_project(self, async_test_db, test_project_crud, test_issue_crud):
|
|
"""Test getting issues by project."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issues, total = await issue_crud.get_by_project(
|
|
session,
|
|
project_id=test_project_crud.id,
|
|
)
|
|
|
|
assert total >= 1
|
|
assert all(i.project_id == test_project_crud.id for i in issues)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_project_with_status(self, async_test_db, test_project_crud):
|
|
"""Test filtering issues by status."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create issues with different statuses
|
|
async with AsyncTestingSessionLocal() as session:
|
|
open_issue = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Open Issue Filter",
|
|
status=IssueStatus.OPEN,
|
|
)
|
|
await issue_crud.create(session, obj_in=open_issue)
|
|
|
|
closed_issue = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Closed Issue Filter",
|
|
status=IssueStatus.CLOSED,
|
|
)
|
|
await issue_crud.create(session, obj_in=closed_issue)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issues, _ = await issue_crud.get_by_project(
|
|
session,
|
|
project_id=test_project_crud.id,
|
|
status=IssueStatus.OPEN,
|
|
)
|
|
|
|
assert all(i.status == IssueStatus.OPEN for i in issues)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_project_with_priority(self, async_test_db, test_project_crud):
|
|
"""Test filtering issues by priority."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
high_issue = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="High Priority Issue",
|
|
priority=IssuePriority.HIGH,
|
|
)
|
|
await issue_crud.create(session, obj_in=high_issue)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issues, _ = await issue_crud.get_by_project(
|
|
session,
|
|
project_id=test_project_crud.id,
|
|
priority=IssuePriority.HIGH,
|
|
)
|
|
|
|
assert all(i.priority == IssuePriority.HIGH for i in issues)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_project_with_search(self, async_test_db, test_project_crud):
|
|
"""Test searching issues by title/body."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
searchable_issue = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Searchable Unique Title",
|
|
body="This body contains searchable content",
|
|
)
|
|
await issue_crud.create(session, obj_in=searchable_issue)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issues, total = await issue_crud.get_by_project(
|
|
session,
|
|
project_id=test_project_crud.id,
|
|
search="Searchable Unique",
|
|
)
|
|
|
|
assert total >= 1
|
|
assert any(i.title == "Searchable Unique Title" for i in issues)
|
|
|
|
|
|
class TestIssueBySprint:
|
|
"""Tests for getting issues by sprint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_sprint(self, async_test_db, test_project_crud, test_sprint_crud):
|
|
"""Test getting issues by sprint."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create issue in sprint
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Sprint Issue",
|
|
sprint_id=test_sprint_crud.id,
|
|
)
|
|
await issue_crud.create(session, obj_in=issue_data)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issues = await issue_crud.get_by_sprint(
|
|
session,
|
|
sprint_id=test_sprint_crud.id,
|
|
)
|
|
|
|
assert len(issues) >= 1
|
|
assert all(i.sprint_id == test_sprint_crud.id for i in issues)
|
|
|
|
|
|
class TestIssueSyncStatus:
|
|
"""Tests for issue sync status operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_sync_status(self, async_test_db, test_project_crud):
|
|
"""Test updating issue sync status."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create issue with external tracker
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Sync Status Issue",
|
|
external_tracker="gitea",
|
|
external_id="gitea-456",
|
|
)
|
|
created = await issue_crud.create(session, obj_in=issue_data)
|
|
issue_id = created.id
|
|
|
|
# Update sync status
|
|
now = datetime.now(UTC)
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.update_sync_status(
|
|
session,
|
|
issue_id=issue_id,
|
|
sync_status=SyncStatus.PENDING,
|
|
last_synced_at=now,
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.sync_status == SyncStatus.PENDING
|
|
assert result.last_synced_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_sync(self, async_test_db, test_project_crud):
|
|
"""Test getting issues pending sync."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create issue with pending sync
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="Pending Sync Issue",
|
|
external_tracker="gitea",
|
|
external_id="gitea-789",
|
|
)
|
|
created = await issue_crud.create(session, obj_in=issue_data)
|
|
|
|
# Set to pending
|
|
await issue_crud.update_sync_status(
|
|
session,
|
|
issue_id=created.id,
|
|
sync_status=SyncStatus.PENDING,
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issues = await issue_crud.get_pending_sync(session)
|
|
|
|
assert any(i.sync_status == SyncStatus.PENDING for i in issues)
|
|
|
|
|
|
class TestIssueExternalTracker:
|
|
"""Tests for external tracker operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_external_id(self, async_test_db, test_project_crud):
|
|
"""Test getting issue by external tracker ID."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create issue with external ID
|
|
async with AsyncTestingSessionLocal() as session:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title="External ID Issue",
|
|
external_tracker="github",
|
|
external_id="github-unique-123",
|
|
)
|
|
await issue_crud.create(session, obj_in=issue_data)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.get_by_external_id(
|
|
session,
|
|
external_tracker="github",
|
|
external_id="github-unique-123",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.external_id == "github-unique-123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_external_id_not_found(self, async_test_db):
|
|
"""Test getting non-existent external ID returns None."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await issue_crud.get_by_external_id(
|
|
session,
|
|
external_tracker="gitea",
|
|
external_id="non-existent",
|
|
)
|
|
assert result is None
|
|
|
|
|
|
class TestIssueStats:
|
|
"""Tests for issue statistics."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_project_stats(self, async_test_db, test_project_crud):
|
|
"""Test getting issue statistics for a project."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create issues with various statuses and priorities
|
|
async with AsyncTestingSessionLocal() as session:
|
|
for status in [IssueStatus.OPEN, IssueStatus.IN_PROGRESS, IssueStatus.CLOSED]:
|
|
issue_data = IssueCreate(
|
|
project_id=test_project_crud.id,
|
|
title=f"Stats Issue {status.value}",
|
|
status=status,
|
|
story_points=3,
|
|
)
|
|
await issue_crud.create(session, obj_in=issue_data)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
stats = await issue_crud.get_project_stats(
|
|
session,
|
|
project_id=test_project_crud.id,
|
|
)
|
|
|
|
assert "total" in stats
|
|
assert "open" in stats
|
|
assert "in_progress" in stats
|
|
assert "closed" in stats
|
|
assert "by_priority" in stats
|
|
assert "total_story_points" in stats
|