feat: Implement comprehensive Testing Architecture Enhancement
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>
This commit is contained in:
2025-09-26 22:36:35 +02:00
parent 0606115104
commit 21a5d1d734
23 changed files with 4122 additions and 1 deletions

3
tests/utils/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Test utilities and helpers for MarkiTect tests.
"""

274
tests/utils/assertions.py Normal file
View File

@@ -0,0 +1,274 @@
"""
Custom assertions and test utilities for MarkiTect tests.
"""
import json
from typing import Any, Dict, List, Optional, Union, Callable
from datetime import datetime, timezone
from pathlib import Path
import pytest
def assert_issue_equal(actual, expected, ignore_timestamps: bool = False):
"""Assert that two Issue objects are equal."""
assert actual.number == expected.number, f"Issue numbers don't match: {actual.number} != {expected.number}"
assert actual.title == expected.title, f"Issue titles don't match: {actual.title} != {expected.title}"
assert actual.state == expected.state, f"Issue states don't match: {actual.state} != {expected.state}"
assert len(actual.labels) == len(expected.labels), f"Label counts don't match: {len(actual.labels)} != {len(expected.labels)}"
# Compare labels
actual_label_names = {label.name for label in actual.labels}
expected_label_names = {label.name for label in expected.labels}
assert actual_label_names == expected_label_names, f"Labels don't match: {actual_label_names} != {expected_label_names}"
if not ignore_timestamps:
assert actual.created_at == expected.created_at, f"Created timestamps don't match"
assert actual.updated_at == expected.updated_at, f"Updated timestamps don't match"
assert actual.closed_at == expected.closed_at, f"Closed timestamps don't match"
def assert_project_equal(actual, expected, ignore_timestamps: bool = False):
"""Assert that two Project objects are equal."""
assert actual.name == expected.name, f"Project names don't match: {actual.name} != {expected.name}"
assert actual.description == expected.description, f"Project descriptions don't match"
assert actual.state == expected.state, f"Project states don't match: {actual.state} != {expected.state}"
assert actual.kanban_columns == expected.kanban_columns, f"Kanban columns don't match"
assert len(actual.milestones) == len(expected.milestones), f"Milestone counts don't match"
if not ignore_timestamps:
assert actual.created_at == expected.created_at, f"Created timestamps don't match"
assert actual.updated_at == expected.updated_at, f"Updated timestamps don't match"
assert actual.archived_at == expected.archived_at, f"Archived timestamps don't match"
def assert_milestone_equal(actual, expected):
"""Assert that two Milestone objects are equal."""
assert actual.id == expected.id, f"Milestone IDs don't match: {actual.id} != {expected.id}"
assert actual.title == expected.title, f"Milestone titles don't match: {actual.title} != {expected.title}"
assert actual.description == expected.description, f"Milestone descriptions don't match"
assert actual.state == expected.state, f"Milestone states don't match: {actual.state} != {expected.state}"
assert actual.open_issues == expected.open_issues, f"Open issue counts don't match"
assert actual.closed_issues == expected.closed_issues, f"Closed issue counts don't match"
assert actual.due_date == expected.due_date, f"Due dates don't match"
def assert_json_equal(actual: Union[str, Dict], expected: Union[str, Dict]):
"""Assert that two JSON objects are equal."""
if isinstance(actual, str):
actual = json.loads(actual)
if isinstance(expected, str):
expected = json.loads(expected)
assert actual == expected, f"JSON objects don't match:\nActual: {json.dumps(actual, indent=2)}\nExpected: {json.dumps(expected, indent=2)}"
def assert_markdown_structure_equal(actual: str, expected: str):
"""Assert that two markdown documents have the same structure (ignoring whitespace differences)."""
actual_lines = [line.strip() for line in actual.split('\n') if line.strip()]
expected_lines = [line.strip() for line in expected.split('\n') if line.strip()]
assert len(actual_lines) == len(expected_lines), f"Line count mismatch: {len(actual_lines)} != {len(expected_lines)}"
for i, (actual_line, expected_line) in enumerate(zip(actual_lines, expected_lines)):
assert actual_line == expected_line, f"Line {i+1} mismatch:\nActual: {actual_line}\nExpected: {expected_line}"
def assert_file_exists(file_path: Union[str, Path], message: str = None):
"""Assert that a file exists."""
path = Path(file_path)
assert path.exists(), message or f"File does not exist: {path}"
assert path.is_file(), message or f"Path is not a file: {path}"
def assert_directory_exists(dir_path: Union[str, Path], message: str = None):
"""Assert that a directory exists."""
path = Path(dir_path)
assert path.exists(), message or f"Directory does not exist: {path}"
assert path.is_dir(), message or f"Path is not a directory: {path}"
def assert_file_contains(file_path: Union[str, Path], content: str, message: str = None):
"""Assert that a file contains specific content."""
path = Path(file_path)
assert_file_exists(path)
file_content = path.read_text()
assert content in file_content, message or f"File {path} does not contain: {content}"
def assert_file_not_contains(file_path: Union[str, Path], content: str, message: str = None):
"""Assert that a file does not contain specific content."""
path = Path(file_path)
assert_file_exists(path)
file_content = path.read_text()
assert content not in file_content, message or f"File {path} unexpectedly contains: {content}"
def assert_time_approximately_equal(actual: datetime, expected: datetime, tolerance_seconds: int = 1):
"""Assert that two datetime objects are approximately equal within tolerance."""
diff = abs((actual - expected).total_seconds())
assert diff <= tolerance_seconds, f"Times differ by {diff} seconds, tolerance is {tolerance_seconds}"
def assert_performance_within_bounds(execution_time: float, max_time: float, operation: str = "operation"):
"""Assert that an operation completed within performance bounds."""
assert execution_time <= max_time, f"{operation} took {execution_time:.3f}s, expected <= {max_time:.3f}s"
def assert_memory_usage_within_bounds(memory_usage_mb: float, max_memory_mb: float, operation: str = "operation"):
"""Assert that memory usage is within bounds."""
assert memory_usage_mb <= max_memory_mb, f"{operation} used {memory_usage_mb:.2f}MB, expected <= {max_memory_mb:.2f}MB"
def assert_mock_called_with_pattern(mock, pattern: Callable[[Any], bool], message: str = None):
"""Assert that a mock was called with arguments matching a pattern."""
found_match = False
for call in mock.call_args_list:
if pattern(call):
found_match = True
break
assert found_match, message or f"Mock was not called with expected pattern. Calls: {mock.call_args_list}"
def assert_sequence_equal(actual: List[Any], expected: List[Any], compare_fn: Optional[Callable[[Any, Any], bool]] = None):
"""Assert that two sequences are equal using optional custom comparison."""
assert len(actual) == len(expected), f"Sequence lengths don't match: {len(actual)} != {len(expected)}"
for i, (actual_item, expected_item) in enumerate(zip(actual, expected)):
if compare_fn:
assert compare_fn(actual_item, expected_item), f"Items at index {i} don't match"
else:
assert actual_item == expected_item, f"Items at index {i} don't match: {actual_item} != {expected_item}"
def assert_contains_all(container: Union[List, Dict, str], items: List[Any], message: str = None):
"""Assert that a container contains all specified items."""
missing_items = []
for item in items:
if item not in container:
missing_items.append(item)
assert not missing_items, message or f"Container missing items: {missing_items}"
def assert_contains_none(container: Union[List, Dict, str], items: List[Any], message: str = None):
"""Assert that a container contains none of the specified items."""
found_items = []
for item in items:
if item in container:
found_items.append(item)
assert not found_items, message or f"Container unexpectedly contains items: {found_items}"
def assert_label_categories_valid(categories):
"""Assert that label categories are valid and properly separated."""
from domain.issues.models import LabelCategories
assert isinstance(categories, LabelCategories), "Categories must be LabelCategories instance"
# Check for overlaps between categories
all_labels = (
categories.state_labels +
categories.priority_labels +
categories.type_labels +
categories.other_labels
)
# No label should appear in multiple categories
seen_labels = set()
for label in all_labels:
assert label not in seen_labels, f"Label '{label}' appears in multiple categories"
seen_labels.add(label)
def assert_kanban_column_valid(column: str, valid_columns: List[str]):
"""Assert that a kanban column is valid."""
assert column in valid_columns, f"Invalid kanban column '{column}'. Valid columns: {valid_columns}"
def assert_business_rule_violated(exception_type: type, exception_message_pattern: str = None):
"""Context manager to assert that a business rule violation occurs."""
return pytest.raises(exception_type, match=exception_message_pattern)
def assert_async_operation_succeeds(async_func: Callable, timeout: float = 30.0):
"""Assert that an async operation succeeds within timeout."""
import asyncio
async def run_with_timeout():
return await asyncio.wait_for(async_func(), timeout=timeout)
try:
result = asyncio.run(run_with_timeout())
return result
except asyncio.TimeoutError:
pytest.fail(f"Async operation timed out after {timeout} seconds")
except Exception as e:
pytest.fail(f"Async operation failed: {e}")
# Custom pytest markers for different types of assertions
def mark_performance_test(max_time: float = None, max_memory_mb: float = None):
"""Mark a test as a performance test with optional bounds."""
markers = [pytest.mark.performance]
if max_time:
markers.append(pytest.mark.parametrize("max_execution_time", [max_time]))
if max_memory_mb:
markers.append(pytest.mark.parametrize("max_memory_usage", [max_memory_mb]))
return markers
def mark_integration_test(external_service: str = None):
"""Mark a test as an integration test."""
markers = [pytest.mark.integration]
if external_service:
markers.append(pytest.mark.parametrize("external_service", [external_service]))
return markers
# Test data validation helpers
def validate_issue_data(data: Dict[str, Any]) -> bool:
"""Validate that data represents a valid issue."""
required_fields = ["number", "title", "state", "labels", "created_at", "updated_at"]
for field in required_fields:
if field not in data:
return False
if not isinstance(data["number"], int) or data["number"] <= 0:
return False
if not isinstance(data["title"], str) or not data["title"].strip():
return False
if data["state"] not in ["open", "closed"]:
return False
if not isinstance(data["labels"], list):
return False
return True
def validate_project_data(data: Dict[str, Any]) -> bool:
"""Validate that data represents a valid project."""
required_fields = ["name", "state", "milestones", "kanban_columns", "created_at", "updated_at"]
for field in required_fields:
if field not in data:
return False
if not isinstance(data["name"], str) or not data["name"].strip():
return False
if data["state"] not in ["active", "archived"]:
return False
if not isinstance(data["milestones"], list):
return False
if not isinstance(data["kanban_columns"], list):
return False
return True

View File

@@ -0,0 +1,346 @@
"""
Mock factories for creating test doubles and mocks.
"""
from unittest.mock import Mock, AsyncMock, MagicMock
from typing import Dict, Any, List, Optional, Callable
import asyncio
from datetime import datetime, timezone
class MockRepositoryFactory:
"""Factory for creating mock repository objects."""
@staticmethod
def create_issue_repository() -> Mock:
"""Create a mock issue repository."""
repo = AsyncMock()
repo.get_issue = AsyncMock()
repo.create_issue = AsyncMock()
repo.update_issue = AsyncMock()
repo.delete_issue = AsyncMock()
repo.list_issues = AsyncMock()
repo.search_issues = AsyncMock()
return repo
@staticmethod
def create_project_repository() -> Mock:
"""Create a mock project repository."""
repo = AsyncMock()
repo.get_project = AsyncMock()
repo.create_project = AsyncMock()
repo.update_project = AsyncMock()
repo.delete_project = AsyncMock()
repo.list_projects = AsyncMock()
repo.get_issue_project_info = AsyncMock()
return repo
@staticmethod
def create_document_repository() -> Mock:
"""Create a mock document repository."""
repo = AsyncMock()
repo.store_document = AsyncMock()
repo.get_document = AsyncMock()
repo.update_document = AsyncMock()
repo.delete_document = AsyncMock()
repo.list_documents = AsyncMock()
repo.search_content = AsyncMock()
return repo
class MockServiceFactory:
"""Factory for creating mock service objects."""
@staticmethod
def create_http_client() -> Mock:
"""Create a mock HTTP client."""
client = AsyncMock()
# Default successful response
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"status": "success"})
mock_response.text = AsyncMock(return_value='{"status": "success"}')
mock_response.headers = {"Content-Type": "application/json"}
client.get.return_value = mock_response
client.post.return_value = mock_response
client.put.return_value = mock_response
client.delete.return_value = mock_response
client.close = AsyncMock()
return client
@staticmethod
def create_database_connection() -> Mock:
"""Create a mock database connection."""
conn = Mock()
cursor = Mock()
conn.cursor.return_value = cursor
conn.execute.return_value = cursor
conn.commit = Mock()
conn.rollback = Mock()
conn.close = Mock()
# Default empty results
cursor.fetchone.return_value = None
cursor.fetchall.return_value = []
cursor.fetchmany.return_value = []
cursor.lastrowid = 1
cursor.rowcount = 0
return conn
@staticmethod
def create_cache_manager() -> Mock:
"""Create a mock cache manager."""
cache = AsyncMock()
cache.get = AsyncMock(return_value=None)
cache.set = AsyncMock()
cache.delete = AsyncMock()
cache.clear = AsyncMock()
cache.exists = AsyncMock(return_value=False)
cache.expire = AsyncMock()
return cache
@staticmethod
def create_file_system() -> Mock:
"""Create a mock file system."""
fs = Mock()
fs.read_file = Mock(return_value="mock file content")
fs.write_file = Mock()
fs.delete_file = Mock()
fs.exists = Mock(return_value=True)
fs.list_files = Mock(return_value=[])
fs.create_directory = Mock()
fs.delete_directory = Mock()
return fs
class MockConfigFactory:
"""Factory for creating mock configuration objects."""
@staticmethod
def create_test_config(overrides: Optional[Dict[str, Any]] = None) -> Mock:
"""Create a mock configuration object."""
config = Mock()
# Default configuration values
defaults = {
"workspace_dir": "/tmp/test-workspace",
"database_path": "/tmp/test.db",
"cache_dir": "/tmp/test-cache",
"gitea_url": "http://test-gitea.com",
"gitea_token": "test-token",
"repo_owner": "test",
"repo_name": "repo",
"log_level": "DEBUG",
"max_retries": 3,
"timeout": 30,
"batch_size": 100
}
if overrides:
defaults.update(overrides)
for key, value in defaults.items():
setattr(config, key, value)
return config
class MockEventFactory:
"""Factory for creating mock event objects and event handlers."""
@staticmethod
def create_event_emitter() -> Mock:
"""Create a mock event emitter."""
emitter = Mock()
emitter.emit = Mock()
emitter.on = Mock()
emitter.off = Mock()
emitter.once = Mock()
emitter.listeners = Mock(return_value=[])
return emitter
@staticmethod
def create_event_handler() -> Mock:
"""Create a mock event handler."""
handler = Mock()
handler.handle = AsyncMock()
handler.can_handle = Mock(return_value=True)
handler.priority = 1
return handler
class MockNetworkFactory:
"""Factory for creating network-related mocks."""
@staticmethod
def create_rate_limiter() -> Mock:
"""Create a mock rate limiter."""
limiter = AsyncMock()
limiter.acquire = AsyncMock()
limiter.release = AsyncMock()
limiter.is_available = AsyncMock(return_value=True)
limiter.reset = AsyncMock()
return limiter
@staticmethod
def create_circuit_breaker() -> Mock:
"""Create a mock circuit breaker."""
breaker = Mock()
breaker.call = AsyncMock()
breaker.is_open = Mock(return_value=False)
breaker.is_closed = Mock(return_value=True)
breaker.is_half_open = Mock(return_value=False)
breaker.reset = Mock()
return breaker
class MockTimeFactory:
"""Factory for creating time-related mocks."""
@staticmethod
def create_timer() -> Mock:
"""Create a mock timer."""
timer = Mock()
timer.start = Mock()
timer.stop = Mock()
timer.elapsed = 0.1
timer.reset = Mock()
return timer
@staticmethod
def create_scheduler() -> Mock:
"""Create a mock task scheduler."""
scheduler = AsyncMock()
scheduler.schedule = AsyncMock()
scheduler.cancel = AsyncMock()
scheduler.is_scheduled = Mock(return_value=False)
scheduler.start = AsyncMock()
scheduler.stop = AsyncMock()
return scheduler
class MockResponseBuilder:
"""Builder for creating mock HTTP responses."""
def __init__(self):
self.status = 200
self.headers = {"Content-Type": "application/json"}
self.body = {"status": "success"}
self.delay = 0.0
self.exception = None
def with_status(self, status: int) -> "MockResponseBuilder":
"""Set response status code."""
self.status = status
return self
def with_headers(self, headers: Dict[str, str]) -> "MockResponseBuilder":
"""Set response headers."""
self.headers.update(headers)
return self
def with_json_body(self, body: Dict[str, Any]) -> "MockResponseBuilder":
"""Set JSON response body."""
self.body = body
self.headers["Content-Type"] = "application/json"
return self
def with_text_body(self, body: str) -> "MockResponseBuilder":
"""Set text response body."""
self.body = body
self.headers["Content-Type"] = "text/plain"
return self
def with_delay(self, delay: float) -> "MockResponseBuilder":
"""Add delay to response."""
self.delay = delay
return self
def with_exception(self, exception: Exception) -> "MockResponseBuilder":
"""Make response raise an exception."""
self.exception = exception
return self
def build(self) -> Mock:
"""Build the mock response."""
if self.exception:
# Create a coroutine that raises the exception
async def raise_exception():
await asyncio.sleep(self.delay)
raise self.exception
return raise_exception()
response = AsyncMock()
response.status = self.status
response.headers = self.headers
if isinstance(self.body, dict):
response.json = AsyncMock(return_value=self.body)
response.text = AsyncMock(return_value=str(self.body))
else:
response.text = AsyncMock(return_value=self.body)
response.json = AsyncMock(side_effect=ValueError("Not JSON"))
# Add delay if specified
if self.delay > 0:
original_json = response.json
original_text = response.text
async def delayed_json():
await asyncio.sleep(self.delay)
return await original_json()
async def delayed_text():
await asyncio.sleep(self.delay)
return await original_text()
response.json = delayed_json
response.text = delayed_text
return response
# Convenience functions
def create_failing_mock(exception: Exception) -> Mock:
"""Create a mock that always raises the specified exception."""
mock = Mock()
mock.side_effect = exception
return mock
def create_async_failing_mock(exception: Exception) -> AsyncMock:
"""Create an async mock that always raises the specified exception."""
mock = AsyncMock()
mock.side_effect = exception
return mock
def create_sequence_mock(values: List[Any]) -> Mock:
"""Create a mock that returns values in sequence."""
mock = Mock()
mock.side_effect = values
return mock
def create_async_sequence_mock(values: List[Any]) -> AsyncMock:
"""Create an async mock that returns values in sequence."""
mock = AsyncMock()
mock.side_effect = values
return mock
def create_conditional_mock(condition: Callable[..., bool], true_value: Any, false_value: Any) -> Mock:
"""Create a mock that returns different values based on a condition."""
def side_effect(*args, **kwargs):
if condition(*args, **kwargs):
return true_value
return false_value
mock = Mock()
mock.side_effect = side_effect
return mock

View File

@@ -0,0 +1,338 @@
"""
Test data builders using the builder pattern for creating domain objects.
"""
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
from domain.issues.models import Issue, Label, IssueState, LabelCategories
from domain.projects.models import Project, Milestone, ProjectState
class IssueBuilder:
"""Builder for creating Issue domain objects for testing."""
def __init__(self):
self.number = 1
self.title = "Test Issue"
self.state = IssueState.OPEN
self.labels: List[Label] = []
self.created_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
self.closed_at: Optional[datetime] = None
def with_number(self, number: int) -> "IssueBuilder":
"""Set issue number."""
self.number = number
return self
def with_title(self, title: str) -> "IssueBuilder":
"""Set issue title."""
self.title = title
return self
def with_state(self, state: IssueState) -> "IssueBuilder":
"""Set issue state."""
self.state = state
if state == IssueState.CLOSED and self.closed_at is None:
self.closed_at = datetime.now(timezone.utc)
return self
def with_labels(self, *label_names: str) -> "IssueBuilder":
"""Add labels to the issue."""
self.labels = [Label(name) for name in label_names]
return self
def with_label_objects(self, *labels: Label) -> "IssueBuilder":
"""Add label objects to the issue."""
self.labels = list(labels)
return self
def with_timestamps(self, created_at: datetime, updated_at: datetime, closed_at: Optional[datetime] = None) -> "IssueBuilder":
"""Set issue timestamps."""
self.created_at = created_at
self.updated_at = updated_at
self.closed_at = closed_at
return self
def as_closed(self, closed_at: Optional[datetime] = None) -> "IssueBuilder":
"""Mark issue as closed."""
self.state = IssueState.CLOSED
self.closed_at = closed_at or datetime.now(timezone.utc)
return self
def as_bug(self) -> "IssueBuilder":
"""Add bug label."""
self.labels.append(Label("bug"))
return self
def as_enhancement(self) -> "IssueBuilder":
"""Add enhancement label."""
self.labels.append(Label("enhancement"))
return self
def with_priority(self, priority: str) -> "IssueBuilder":
"""Add priority label."""
if priority not in ["low", "medium", "high", "critical"]:
raise ValueError("Priority must be one of: low, medium, high, critical")
self.labels.append(Label(f"priority:{priority}"))
return self
def with_status(self, status: str) -> "IssueBuilder":
"""Add status label."""
self.labels.append(Label(f"status:{status}"))
return self
def build(self) -> Issue:
"""Build the Issue object."""
return Issue(
number=self.number,
title=self.title,
state=self.state,
labels=self.labels,
created_at=self.created_at,
updated_at=self.updated_at,
closed_at=self.closed_at
)
class LabelBuilder:
"""Builder for creating Label objects for testing."""
def __init__(self, name: str = "test-label"):
self.name = name
def as_state_label(self, status: str) -> "LabelBuilder":
"""Create a state label."""
self.name = f"status:{status}"
return self
def as_priority_label(self, priority: str) -> "LabelBuilder":
"""Create a priority label."""
if priority not in ["low", "medium", "high", "critical"]:
raise ValueError("Priority must be one of: low, medium, high, critical")
self.name = f"priority:{priority}"
return self
def as_type_label(self, type_name: str) -> "LabelBuilder":
"""Create a type label."""
if type_name not in ["bug", "enhancement", "feature", "documentation"]:
raise ValueError("Type must be one of: bug, enhancement, feature, documentation")
self.name = type_name
return self
def with_custom_name(self, name: str) -> "LabelBuilder":
"""Set custom label name."""
self.name = name
return self
def build(self) -> Label:
"""Build the Label object."""
return Label(self.name)
class MilestoneBuilder:
"""Builder for creating Milestone objects for testing."""
def __init__(self):
self.id = 1
self.title = "Test Milestone"
self.description: Optional[str] = None
self.due_date: Optional[datetime] = None
self.state = "open"
self.open_issues = 0
self.closed_issues = 0
def with_id(self, id: int) -> "MilestoneBuilder":
"""Set milestone ID."""
self.id = id
return self
def with_title(self, title: str) -> "MilestoneBuilder":
"""Set milestone title."""
self.title = title
return self
def with_description(self, description: str) -> "MilestoneBuilder":
"""Set milestone description."""
self.description = description
return self
def with_due_date(self, due_date: datetime) -> "MilestoneBuilder":
"""Set milestone due date."""
self.due_date = due_date
return self
def with_state(self, state: str) -> "MilestoneBuilder":
"""Set milestone state."""
if state not in ["open", "closed"]:
raise ValueError("State must be 'open' or 'closed'")
self.state = state
return self
def with_issue_counts(self, open_issues: int, closed_issues: int) -> "MilestoneBuilder":
"""Set issue counts."""
self.open_issues = open_issues
self.closed_issues = closed_issues
return self
def as_overdue(self) -> "MilestoneBuilder":
"""Make milestone overdue."""
from datetime import timedelta
self.due_date = datetime.now(timezone.utc) - timedelta(days=1)
return self
def as_completed(self) -> "MilestoneBuilder":
"""Mark milestone as completed."""
self.state = "closed"
return self
def build(self) -> Milestone:
"""Build the Milestone object."""
return Milestone(
id=self.id,
title=self.title,
description=self.description,
due_date=self.due_date,
state=self.state,
open_issues=self.open_issues,
closed_issues=self.closed_issues
)
class ProjectBuilder:
"""Builder for creating Project objects for testing."""
def __init__(self):
self.name = "Test Project"
self.description: Optional[str] = None
self.state = ProjectState.ACTIVE
self.milestones: List[Milestone] = []
self.kanban_columns = ["Todo", "In Progress", "Done"]
self.created_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
self.archived_at: Optional[datetime] = None
def with_name(self, name: str) -> "ProjectBuilder":
"""Set project name."""
self.name = name
return self
def with_description(self, description: str) -> "ProjectBuilder":
"""Set project description."""
self.description = description
return self
def with_state(self, state: ProjectState) -> "ProjectBuilder":
"""Set project state."""
self.state = state
if state == ProjectState.ARCHIVED and self.archived_at is None:
self.archived_at = datetime.now(timezone.utc)
return self
def with_milestones(self, *milestones: Milestone) -> "ProjectBuilder":
"""Add milestones to the project."""
self.milestones = list(milestones)
return self
def with_kanban_columns(self, *columns: str) -> "ProjectBuilder":
"""Set kanban columns."""
self.kanban_columns = list(columns)
return self
def with_timestamps(self, created_at: datetime, updated_at: datetime, archived_at: Optional[datetime] = None) -> "ProjectBuilder":
"""Set project timestamps."""
self.created_at = created_at
self.updated_at = updated_at
self.archived_at = archived_at
return self
def as_archived(self, archived_at: Optional[datetime] = None) -> "ProjectBuilder":
"""Mark project as archived."""
self.state = ProjectState.ARCHIVED
self.archived_at = archived_at or datetime.now(timezone.utc)
return self
def build(self) -> Project:
"""Build the Project object."""
return Project(
name=self.name,
description=self.description,
state=self.state,
milestones=self.milestones,
kanban_columns=self.kanban_columns,
created_at=self.created_at,
updated_at=self.updated_at,
archived_at=self.archived_at
)
# Convenience functions for common test scenarios
def create_sample_issue(number: int = 1, title: str = "Sample Issue") -> Issue:
"""Create a basic sample issue for testing."""
return (IssueBuilder()
.with_number(number)
.with_title(title)
.as_bug()
.with_priority("medium")
.with_status("new")
.build())
def create_in_progress_issue(number: int = 1) -> Issue:
"""Create an in-progress issue for testing."""
return (IssueBuilder()
.with_number(number)
.with_title("In Progress Issue")
.as_enhancement()
.with_priority("high")
.with_status("in-progress")
.build())
def create_closed_issue(number: int = 1) -> Issue:
"""Create a closed issue for testing."""
return (IssueBuilder()
.with_number(number)
.with_title("Closed Issue")
.as_bug()
.with_priority("low")
.as_closed()
.build())
def create_sample_milestone(id: int = 1, title: str = "Sample Milestone") -> Milestone:
"""Create a basic sample milestone for testing."""
return (MilestoneBuilder()
.with_id(id)
.with_title(title)
.with_description(f"Description for {title}")
.with_issue_counts(3, 7)
.build())
def create_sample_project(name: str = "Sample Project") -> Project:
"""Create a basic sample project for testing."""
milestone1 = create_sample_milestone(1, "Version 1.0")
milestone2 = create_sample_milestone(2, "Version 2.0")
return (ProjectBuilder()
.with_name(name)
.with_description(f"Description for {name}")
.with_milestones(milestone1, milestone2)
.build())
def create_complex_issue_with_labels() -> Issue:
"""Create an issue with various types of labels for testing categorization."""
return (IssueBuilder()
.with_number(42)
.with_title("Complex Issue with Multiple Labels")
.with_labels(
"bug", # type label
"priority:critical", # priority label
"status:blocked", # state label
"frontend", # other label
"needs-testing", # other label
"enhancement" # type label (multiple types allowed)
)
.build())