# 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)