""" Unit tests for issue application service with enhanced testing patterns. Demonstrates: - Mock-based testing with proper isolation - Error handling scenarios - Business logic validation - Performance expectations """ import pytest from unittest.mock import AsyncMock, Mock from datetime import datetime, timezone, timedelta from domain.issues.models import Issue, Label, IssueState from domain.issues.exceptions import IssueNotFoundError, IssueValidationError from tests.utils.test_builders import IssueBuilder, LabelBuilder from tests.utils.mock_factories import MockRepositoryFactory from tests.utils.assertions import assert_issue_equal, assert_performance_within_bounds class MockIssueApplicationService: """Mock application service for testing (simulating the future implementation).""" def __init__(self, issue_repository, project_repository, status_service, validation_service): self.issue_repository = issue_repository self.project_repository = project_repository self.status_service = status_service self.validation_service = validation_service async def get_issue_details(self, issue_number: int): """Get detailed issue information with business logic applied.""" # Repository call issue = await self.issue_repository.get_issue(issue_number) if not issue: raise IssueNotFoundError(f"Issue {issue_number} not found") # Project context project_info = await self.project_repository.get_issue_project_info(issue_number) # Business logic application kanban_column = self.status_service.determine_kanban_column(issue, project_info) priority_info = self.status_service.extract_priority_info(issue) return { "issue": issue, "kanban_column": kanban_column, "priority_info": priority_info, "project_context": project_info } async def create_issue(self, title: str, labels: list = None, milestone_id: int = None): """Create a new issue with validation.""" # Validate input self.validation_service.validate_issue_creation({ "title": title, "labels": labels or [] }) # Create issue issue_data = { "title": title, "state": IssueState.OPEN, "labels": [Label(name) for name in (labels or [])], "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc) } return await self.issue_repository.create_issue(issue_data) async def update_issue_status(self, issue_number: int, new_status: str): """Update issue status with business rules.""" issue = await self.issue_repository.get_issue(issue_number) if not issue: raise IssueNotFoundError(f"Issue {issue_number} not found") # Apply status change business logic if new_status == "closed": issue.close() elif new_status == "reopened" and issue.state == IssueState.CLOSED: issue.reopen() return await self.issue_repository.update_issue(issue) @pytest.fixture def mock_issue_repository(): """Provide mock issue repository.""" return MockRepositoryFactory.create_issue_repository() @pytest.fixture def mock_project_repository(): """Provide mock project repository.""" return MockRepositoryFactory.create_project_repository() @pytest.fixture def mock_status_service(): """Provide mock status service.""" service = Mock() service.determine_kanban_column = Mock(return_value="Todo") service.extract_priority_info = Mock(return_value={"level": "Medium", "label": None}) return service @pytest.fixture def mock_validation_service(): """Provide mock validation service.""" service = Mock() service.validate_issue_creation = Mock() return service @pytest.fixture def application_service(mock_issue_repository, mock_project_repository, mock_status_service, mock_validation_service): """Provide application service with mocked dependencies.""" return MockIssueApplicationService( mock_issue_repository, mock_project_repository, mock_status_service, mock_validation_service ) class TestIssueApplicationService: """Test issue application service coordination logic.""" @pytest.mark.asyncio async def test_get_issue_details_success(self, application_service, mock_issue_repository, mock_project_repository, mock_status_service): """Test successful issue details retrieval.""" # Arrange issue = (IssueBuilder() .with_number(123) .with_title("Test Issue") .with_labels("bug", "priority:high") .build()) project_info = {"kanban_columns": ["Todo", "In Progress", "Done"]} mock_issue_repository.get_issue.return_value = issue mock_project_repository.get_issue_project_info.return_value = project_info mock_status_service.determine_kanban_column.return_value = "Todo" mock_status_service.extract_priority_info.return_value = {"level": "High", "label": "priority:high"} # Act result = await application_service.get_issue_details(123) # Assert assert result["issue"] == issue assert result["kanban_column"] == "Todo" assert result["priority_info"]["level"] == "High" assert result["project_context"] == project_info # Verify repository calls mock_issue_repository.get_issue.assert_called_once_with(123) mock_project_repository.get_issue_project_info.assert_called_once_with(123) # Verify business logic calls mock_status_service.determine_kanban_column.assert_called_once_with(issue, project_info) mock_status_service.extract_priority_info.assert_called_once_with(issue) @pytest.mark.asyncio async def test_get_issue_details_issue_not_found(self, application_service, mock_issue_repository): """Test handling of non-existent issue.""" # Arrange mock_issue_repository.get_issue.return_value = None # Act & Assert with pytest.raises(IssueNotFoundError, match="Issue 999 not found"): await application_service.get_issue_details(999) mock_issue_repository.get_issue.assert_called_once_with(999) @pytest.mark.asyncio async def test_get_issue_details_repository_error(self, application_service, mock_issue_repository): """Test handling of repository errors.""" # Arrange mock_issue_repository.get_issue.side_effect = Exception("Database connection failed") # Act & Assert with pytest.raises(Exception, match="Database connection failed"): await application_service.get_issue_details(123) @pytest.mark.asyncio async def test_create_issue_success(self, application_service, mock_issue_repository, mock_validation_service): """Test successful issue creation.""" # Arrange created_issue = (IssueBuilder() .with_number(456) .with_title("New Issue") .with_labels("enhancement") .build()) mock_issue_repository.create_issue.return_value = created_issue # Act result = await application_service.create_issue( title="New Issue", labels=["enhancement"] ) # Assert assert result == created_issue # Verify validation was called mock_validation_service.validate_issue_creation.assert_called_once() call_args = mock_validation_service.validate_issue_creation.call_args[0][0] assert call_args["title"] == "New Issue" assert call_args["labels"] == ["enhancement"] # Verify repository call mock_issue_repository.create_issue.assert_called_once() @pytest.mark.asyncio async def test_create_issue_validation_error(self, application_service, mock_validation_service): """Test issue creation with validation error.""" # Arrange mock_validation_service.validate_issue_creation.side_effect = IssueValidationError("Title cannot be empty") # Act & Assert with pytest.raises(IssueValidationError, match="Title cannot be empty"): await application_service.create_issue(title="") @pytest.mark.asyncio async def test_update_issue_status_to_closed(self, application_service, mock_issue_repository): """Test updating issue status to closed.""" # Arrange issue = (IssueBuilder() .with_number(123) .with_title("Issue to Close") .build()) updated_issue = (IssueBuilder() .with_number(123) .with_title("Issue to Close") .as_closed() .build()) mock_issue_repository.get_issue.return_value = issue mock_issue_repository.update_issue.return_value = updated_issue # Act result = await application_service.update_issue_status(123, "closed") # Assert assert result.state == IssueState.CLOSED assert result.closed_at is not None mock_issue_repository.get_issue.assert_called_once_with(123) mock_issue_repository.update_issue.assert_called_once() @pytest.mark.asyncio async def test_update_issue_status_reopen_closed_issue(self, application_service, mock_issue_repository): """Test reopening a closed issue.""" # Arrange closed_issue = (IssueBuilder() .with_number(123) .with_title("Closed Issue") .as_closed() .build()) reopened_issue = (IssueBuilder() .with_number(123) .with_title("Closed Issue") .build()) mock_issue_repository.get_issue.return_value = closed_issue mock_issue_repository.update_issue.return_value = reopened_issue # Act result = await application_service.update_issue_status(123, "reopened") # Assert assert result.state == IssueState.OPEN assert result.closed_at is None @pytest.mark.parametrize("issue_number,title,labels,expected_kanban", [ (1, "Bug Report", ["bug"], "Todo"), (2, "In Progress Feature", ["enhancement", "status:in-progress"], "In Progress"), (3, "Blocked Issue", ["bug", "status:blocked"], "Blocked"), (4, "Ready for Review", ["enhancement", "status:review"], "Review"), ]) @pytest.mark.asyncio async def test_get_issue_details_kanban_column_determination( self, application_service, mock_issue_repository, mock_project_repository, mock_status_service, issue_number, title, labels, expected_kanban ): """Test kanban column determination for various issue types.""" # Arrange issue = (IssueBuilder() .with_number(issue_number) .with_title(title) .with_labels(*labels) .build()) project_info = {"kanban_columns": ["Todo", "In Progress", "Blocked", "Review", "Done"]} mock_issue_repository.get_issue.return_value = issue mock_project_repository.get_issue_project_info.return_value = project_info mock_status_service.determine_kanban_column.return_value = expected_kanban # Act result = await application_service.get_issue_details(issue_number) # Assert assert result["kanban_column"] == expected_kanban @pytest.mark.asyncio @pytest.mark.performance async def test_get_issue_details_performance(self, application_service, mock_issue_repository, mock_project_repository, performance_timer): """Test that issue details retrieval meets performance requirements.""" # Arrange issue = (IssueBuilder() .with_number(123) .with_title("Performance Test Issue") .build()) mock_issue_repository.get_issue.return_value = issue mock_project_repository.get_issue_project_info.return_value = {} # Act performance_timer.start() result = await application_service.get_issue_details(123) performance_timer.stop() # Assert assert result is not None assert_performance_within_bounds(performance_timer.elapsed, 0.1, "issue details retrieval") @pytest.mark.asyncio async def test_create_issue_with_complex_labels(self, application_service, mock_issue_repository, mock_validation_service): """Test creating issue with complex label combinations.""" # Arrange labels = ["bug", "priority:critical", "status:new", "frontend", "needs-investigation"] created_issue = (IssueBuilder() .with_number(789) .with_title("Complex Issue") .with_labels(*labels) .build()) mock_issue_repository.create_issue.return_value = created_issue # Act result = await application_service.create_issue( title="Complex Issue", labels=labels ) # Assert assert result.number == 789 assert len(result.labels) == 5 # Verify all labels are present label_names = {label.name for label in result.labels} expected_labels = set(labels) assert label_names == expected_labels @pytest.mark.asyncio async def test_concurrent_issue_operations(self, application_service, mock_issue_repository): """Test concurrent issue operations don't interfere.""" import asyncio # Arrange issues = [ (IssueBuilder().with_number(i).with_title(f"Issue {i}").build()) for i in range(1, 6) ] def get_issue_side_effect(number): return issues[number - 1] mock_issue_repository.get_issue.side_effect = get_issue_side_effect # Act - Simulate concurrent requests tasks = [] for i in range(1, 6): task = application_service.get_issue_details(i) tasks.append(task) results = await asyncio.gather(*tasks) # Assert assert len(results) == 5 for i, result in enumerate(results, 1): assert result["issue"].number == i assert result["issue"].title == f"Issue {i}" @pytest.mark.asyncio async def test_error_handling_preserves_state(self, application_service, mock_issue_repository, mock_validation_service): """Test that errors don't leave the application in inconsistent state.""" # Arrange - First call succeeds, second fails success_issue = (IssueBuilder().with_number(1).with_title("Success").build()) mock_issue_repository.create_issue.side_effect = [success_issue, Exception("Database error")] # Act - First call should succeed result1 = await application_service.create_issue("Success Issue") assert result1.title == "Success" # Second call should fail but not affect future calls with pytest.raises(Exception, match="Database error"): await application_service.create_issue("Failing Issue") # Third call should work if repository is fixed mock_issue_repository.create_issue.side_effect = None success_issue2 = (IssueBuilder().with_number(3).with_title("Recovery").build()) mock_issue_repository.create_issue.return_value = success_issue2 result3 = await application_service.create_issue("Recovery Issue") assert result3.title == "Recovery"