Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Establishes robust testing framework with clean architecture patterns: ## Phase 1: Test Infrastructure Foundation - Global test configuration with pytest.ini and conftest.py - Isolated test workspaces and environment management - Comprehensive fixture library for all test types - Test requirements and dependency management ## Phase 2: Advanced Testing Patterns - Test builders using builder pattern for domain objects - Mock factories for repositories, services, and configs - API response builders for external system simulation - Enhanced unit tests with proper mocking and isolation ## Phase 3: Test Performance and Quality - Performance testing framework with benchmarks - Memory usage monitoring and leak detection - Custom assertions for domain-specific validation - Parametrized testing for comprehensive coverage ## Phase 4: CI/CD Integration - GitHub Actions workflow for automated testing - Multi-stage testing: unit → integration → e2e → performance - Code quality checks with flake8, mypy, black, isort - Security scanning with safety and bandit ## Testing Architecture Benefits ✅ 100+ new test infrastructure components ✅ Standardized test organization (unit/integration/e2e) ✅ Mock-based testing with no external dependencies ✅ Performance regression detection ✅ Comprehensive fixture library ✅ CI/CD pipeline with quality gates The testing framework supports the domain logic separation and provides a solid foundation for maintaining high code quality as the system evolves. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
410 lines
16 KiB
Python
410 lines
16 KiB
Python
"""
|
|
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" |