Compare commits
3 Commits
82f6ef794e
...
21a5d1d734
| Author | SHA1 | Date | |
|---|---|---|---|
| 21a5d1d734 | |||
| 0606115104 | |||
| a7a7960ef6 |
255
.github/workflows/test.yml
vendored
Normal file
255
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,255 @@
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r tests/requirements-test.txt
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
pytest tests/unit/ -v \
|
||||
--cov=domain \
|
||||
--cov=application \
|
||||
--cov=infrastructure \
|
||||
--cov-report=xml \
|
||||
--cov-report=term-missing \
|
||||
--cov-fail-under=85 \
|
||||
--tb=short \
|
||||
--durations=10
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unit-tests
|
||||
name: codecov-umbrella
|
||||
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit-tests
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r tests/requirements-test.txt
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
pytest tests/integration/ -v \
|
||||
--tb=short \
|
||||
--maxfail=5 \
|
||||
--timeout=300
|
||||
|
||||
- name: Archive test artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: integration-test-artifacts
|
||||
path: |
|
||||
tests/integration/logs/
|
||||
tests/integration/outputs/
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [unit-tests, integration-tests]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r tests/requirements-test.txt
|
||||
|
||||
- name: Run end-to-end tests (non-slow)
|
||||
run: |
|
||||
pytest tests/e2e/ -v \
|
||||
-m "not slow" \
|
||||
--tb=short \
|
||||
--maxfail=3 \
|
||||
--timeout=600
|
||||
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
pytest tests/ -v \
|
||||
-m "smoke" \
|
||||
--tb=short \
|
||||
--timeout=120
|
||||
|
||||
performance-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [unit-tests, integration-tests]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r tests/requirements-test.txt
|
||||
|
||||
- name: Run performance tests
|
||||
run: |
|
||||
pytest tests/e2e/performance/ -v \
|
||||
-m "performance" \
|
||||
--tb=short \
|
||||
--timeout=1200
|
||||
|
||||
- name: Archive performance results
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: performance-results
|
||||
path: |
|
||||
performance-results.json
|
||||
performance-charts/
|
||||
|
||||
code-quality:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r tests/requirements-test.txt
|
||||
|
||||
- name: Run flake8
|
||||
run: |
|
||||
flake8 domain/ application/ infrastructure/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
flake8 domain/ application/ infrastructure/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
- name: Run mypy
|
||||
run: |
|
||||
mypy domain/ application/ infrastructure/ --ignore-missing-imports
|
||||
|
||||
- name: Check code formatting with black
|
||||
run: |
|
||||
black --check domain/ application/ infrastructure/
|
||||
|
||||
- name: Check import sorting with isort
|
||||
run: |
|
||||
isort --check-only domain/ application/ infrastructure/
|
||||
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install safety bandit
|
||||
|
||||
- name: Run safety check
|
||||
run: |
|
||||
pip freeze | safety check --json
|
||||
|
||||
- name: Run bandit security linter
|
||||
run: |
|
||||
bandit -r domain/ application/ infrastructure/ -f json -o bandit-results.json
|
||||
|
||||
- name: Upload security scan results
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: security-scan-results
|
||||
path: |
|
||||
bandit-results.json
|
||||
|
||||
test-summary:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [unit-tests, integration-tests, e2e-tests, code-quality, security-scan]
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Check test results
|
||||
run: |
|
||||
echo "Unit Tests: ${{ needs.unit-tests.result }}"
|
||||
echo "Integration Tests: ${{ needs.integration-tests.result }}"
|
||||
echo "E2E Tests: ${{ needs.e2e-tests.result }}"
|
||||
echo "Code Quality: ${{ needs.code-quality.result }}"
|
||||
echo "Security Scan: ${{ needs.security-scan.result }}"
|
||||
|
||||
if [[ "${{ needs.unit-tests.result }}" == "failure" ||
|
||||
"${{ needs.integration-tests.result }}" == "failure" ||
|
||||
"${{ needs.e2e-tests.result }}" == "failure" ]]; then
|
||||
echo "❌ Test suite failed"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Test suite passed"
|
||||
fi
|
||||
|
||||
- name: Update status badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# This would update a status badge in the README
|
||||
echo "Test suite status: PASSING" > test-status.txt
|
||||
|
||||
- name: Upload test summary
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-summary
|
||||
path: test-status.txt
|
||||
483
DATA_ACCESS_IMPROVEMENTS_GAMEPLAN.md
Normal file
483
DATA_ACCESS_IMPROVEMENTS_GAMEPLAN.md
Normal file
@@ -0,0 +1,483 @@
|
||||
# Data Access Pattern Improvements - Gameplan
|
||||
|
||||
## Overview
|
||||
|
||||
This gameplan addresses systematic improvements to data access patterns across the MarkiTect codebase, focusing on implementing modern, maintainable, and performant data access strategies that complement the domain logic separation work.
|
||||
|
||||
## Current Data Access Anti-patterns Identified
|
||||
|
||||
### 1. **Direct API Calls Mixed with Business Logic**
|
||||
- **Location**: `services/issue_service.py` (lines 51-107)
|
||||
- **Problem**: Business presentation logic directly calls `project_mgr._make_api_call()`
|
||||
- **Impact**: Tight coupling, difficult testing, no error standardization
|
||||
|
||||
### 2. **Subprocess-based HTTP Requests**
|
||||
- **Location**: `tddai/project_manager.py` (lines 35-67)
|
||||
- **Problem**: Using `subprocess.run(['curl', ...])` for API calls
|
||||
- **Impact**: Poor performance, resource leaks, inconsistent error handling
|
||||
|
||||
### 3. **Scattered Database Operations**
|
||||
- **Location**: `markitect/document_manager.py` (lines 55-111)
|
||||
- **Problem**: Direct SQLite operations mixed with business logic
|
||||
- **Impact**: No transaction management, inconsistent error handling
|
||||
|
||||
### 4. **Inconsistent File System Access**
|
||||
- **Location**: `tddai/workspace.py` (lines 56-238)
|
||||
- **Problem**: Direct file operations mixed with domain logic
|
||||
- **Impact**: Poor error handling, no abstraction, difficult testing
|
||||
|
||||
### 5. **Missing Connection Management**
|
||||
- **Problem**: No connection pooling, resource management, or retry mechanisms
|
||||
- **Impact**: Poor performance, resource exhaustion, unreliable operations
|
||||
|
||||
## Implementation Gameplan
|
||||
|
||||
### **Phase 1: Foundation & Infrastructure (Week 1-2)**
|
||||
|
||||
#### **Task 1.1: Connection Management Infrastructure**
|
||||
```python
|
||||
# Create: infrastructure/connection_manager.py
|
||||
class ConnectionManager:
|
||||
- HTTP session pooling for Gitea API
|
||||
- Database connection pooling
|
||||
- Configuration-driven timeouts and retries
|
||||
- Resource cleanup and lifecycle management
|
||||
```
|
||||
|
||||
#### **Task 1.2: Error Handling Standardization**
|
||||
```python
|
||||
# Create: infrastructure/exceptions.py
|
||||
class DataAccessError(Exception):
|
||||
- Base exception for all data access errors
|
||||
- Structured error context and logging
|
||||
- Operation tracking and debugging info
|
||||
```
|
||||
|
||||
#### **Task 1.3: Repository Interface Definitions**
|
||||
```python
|
||||
# Create: infrastructure/repositories/interfaces.py
|
||||
- IssueRepository (abstract)
|
||||
- ProjectRepository (abstract)
|
||||
- DocumentRepository (abstract)
|
||||
- WorkspaceRepository (abstract)
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Connection manager with HTTP session pooling
|
||||
- [ ] Standardized error hierarchy
|
||||
- [ ] Abstract repository interfaces
|
||||
- [ ] Configuration for data sources
|
||||
|
||||
**Risk Level**: Low (additive changes only)
|
||||
|
||||
### **Phase 2: Repository Implementation (Week 2-3)**
|
||||
|
||||
#### **Task 2.1: Gitea Repository Implementation**
|
||||
```python
|
||||
# Create: infrastructure/repositories/gitea_repository.py
|
||||
class GiteaIssueRepository:
|
||||
- Async HTTP client with connection pooling
|
||||
- Retry mechanisms with exponential backoff
|
||||
- Proper error mapping and handling
|
||||
- Rate limiting and request throttling
|
||||
```
|
||||
|
||||
#### **Task 2.2: Database Repository Implementation**
|
||||
```python
|
||||
# Create: infrastructure/repositories/sqlite_repository.py
|
||||
class SqliteDocumentRepository:
|
||||
- Connection pooling for SQLite
|
||||
- Transaction management
|
||||
- Proper error handling and mapping
|
||||
- Query optimization and prepared statements
|
||||
```
|
||||
|
||||
#### **Task 2.3: File System Repository Implementation**
|
||||
```python
|
||||
# Create: infrastructure/repositories/filesystem_repository.py
|
||||
class FilesystemWorkspaceRepository:
|
||||
- Abstracted file operations
|
||||
- Atomic file operations
|
||||
- Path validation and security
|
||||
- Error handling and recovery
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Gitea API repository with async HTTP client
|
||||
- [ ] SQLite repository with transaction support
|
||||
- [ ] File system repository with atomic operations
|
||||
- [ ] Comprehensive error handling for all repositories
|
||||
|
||||
**Risk Level**: Low-Medium (parallel implementation)
|
||||
|
||||
### **Phase 3: Unit of Work Pattern (Week 3-4)**
|
||||
|
||||
#### **Task 3.1: Transaction Coordination**
|
||||
```python
|
||||
# Create: infrastructure/unit_of_work.py
|
||||
class UnitOfWork:
|
||||
- Coordinate transactions across multiple repositories
|
||||
- Rollback support for failures
|
||||
- Context manager for automatic cleanup
|
||||
- Support for nested transactions
|
||||
```
|
||||
|
||||
#### **Task 3.2: Caching Strategy**
|
||||
```python
|
||||
# Create: infrastructure/caching/cache_manager.py
|
||||
class CacheManager:
|
||||
- Multi-level caching (memory, disk, Redis)
|
||||
- Cache invalidation strategies
|
||||
- Performance monitoring
|
||||
- TTL and eviction policies
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Unit of Work implementation
|
||||
- [ ] Caching infrastructure
|
||||
- [ ] Transaction coordination
|
||||
- [ ] Performance monitoring
|
||||
|
||||
**Risk Level**: Medium (involves transaction logic)
|
||||
|
||||
### **Phase 4: Service Layer Migration (Week 4-6)**
|
||||
|
||||
#### **Task 4.1: Issue Service Refactoring**
|
||||
```python
|
||||
# Refactor: services/issue_service.py
|
||||
class IssueService:
|
||||
- Inject UnitOfWork dependency
|
||||
- Remove direct API calls
|
||||
- Separate business logic from data access
|
||||
- Add comprehensive error handling
|
||||
```
|
||||
|
||||
#### **Task 4.2: Document Service Refactoring**
|
||||
```python
|
||||
# Refactor: markitect/document_manager.py → services/document_service.py
|
||||
class DocumentService:
|
||||
- Use repository pattern for database operations
|
||||
- Implement proper transaction handling
|
||||
- Add caching layer integration
|
||||
- Separate parsing logic from storage
|
||||
```
|
||||
|
||||
#### **Task 4.3: Workspace Service Refactoring**
|
||||
```python
|
||||
# Refactor: tddai/workspace.py → services/workspace_service.py
|
||||
class WorkspaceService:
|
||||
- Abstract file system operations
|
||||
- Add proper error handling
|
||||
- Implement atomic workspace operations
|
||||
- Add workspace state management
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Refactored IssueService using repositories
|
||||
- [ ] New DocumentService with transaction support
|
||||
- [ ] New WorkspaceService with atomic operations
|
||||
- [ ] Backward compatibility adapters
|
||||
|
||||
**Risk Level**: Medium-High (core service changes)
|
||||
|
||||
### **Phase 5: Performance Optimization (Week 6-7)**
|
||||
|
||||
#### **Task 5.1: Query Optimization**
|
||||
```python
|
||||
# Implement query objects for complex operations
|
||||
class IssueQueries:
|
||||
- Parameterized queries for common operations
|
||||
- Batch operations for multiple issues
|
||||
- Pagination support
|
||||
- Index optimization recommendations
|
||||
```
|
||||
|
||||
#### **Task 5.2: Async/Await Implementation**
|
||||
```python
|
||||
# Convert synchronous operations to async
|
||||
- Async repository methods
|
||||
- Concurrent data fetching
|
||||
- Parallel processing where applicable
|
||||
- Non-blocking I/O operations
|
||||
```
|
||||
|
||||
#### **Task 5.3: Monitoring and Metrics**
|
||||
```python
|
||||
# Create: infrastructure/monitoring/data_metrics.py
|
||||
class DataAccessMetrics:
|
||||
- Query performance tracking
|
||||
- Error rate monitoring
|
||||
- Connection pool utilization
|
||||
- Cache hit/miss ratios
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Async repository implementations
|
||||
- [ ] Query optimization strategies
|
||||
- [ ] Performance monitoring
|
||||
- [ ] Batch operation support
|
||||
|
||||
**Risk Level**: Medium (performance changes)
|
||||
|
||||
### **Phase 6: Testing & Migration (Week 7-8)**
|
||||
|
||||
#### **Task 6.1: Comprehensive Testing**
|
||||
```python
|
||||
# Test Coverage:
|
||||
- Unit tests for all repositories (mocked dependencies)
|
||||
- Integration tests with real databases/APIs
|
||||
- Performance tests for critical operations
|
||||
- Error handling and recovery tests
|
||||
```
|
||||
|
||||
#### **Task 6.2: Gradual Migration**
|
||||
```python
|
||||
# Migration Strategy:
|
||||
- Feature flags for repository switching
|
||||
- Parallel running of old and new systems
|
||||
- Gradual consumer migration
|
||||
- Monitoring and rollback capabilities
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Complete test suite for data access layer
|
||||
- [ ] Migration scripts and tools
|
||||
- [ ] Performance benchmarks
|
||||
- [ ] Documentation and runbooks
|
||||
|
||||
**Risk Level**: Low-Medium (testing and gradual rollout)
|
||||
|
||||
## Specific Implementation Examples
|
||||
|
||||
### **Example 1: IssueService Transformation**
|
||||
|
||||
#### **Before (Current Anti-pattern):**
|
||||
```python
|
||||
class IssueService:
|
||||
def get_issue_details(self, issue_number: int) -> Dict[str, Any]:
|
||||
# Direct dependency creation
|
||||
from tddai.project_manager import ProjectManager
|
||||
project_mgr = ProjectManager()
|
||||
|
||||
# Direct API call mixed with business logic
|
||||
from tddai.config import get_config
|
||||
config = get_config()
|
||||
issue_url = f"{config.issues_api_url}/{issue_number}"
|
||||
detailed_issue = project_mgr._make_api_call('GET', issue_url)
|
||||
|
||||
# 50+ lines of mixed business logic and data transformation
|
||||
return self._process_issue_data(detailed_issue)
|
||||
```
|
||||
|
||||
#### **After (Repository Pattern):**
|
||||
```python
|
||||
class IssueService:
|
||||
def __init__(self, uow: UnitOfWork):
|
||||
self.uow = uow
|
||||
|
||||
async def get_issue_details(self, issue_number: int) -> IssueDetails:
|
||||
async with self.uow:
|
||||
# Clean separation: repository handles data access
|
||||
issue = await self.uow.issues.get_issue(issue_number)
|
||||
project_info = await self.uow.projects.get_issue_project_info(issue_number)
|
||||
|
||||
# Pure business logic - easily testable
|
||||
return self._build_issue_details(issue, project_info)
|
||||
|
||||
def _build_issue_details(self, issue: Issue, project_info: ProjectInfo) -> IssueDetails:
|
||||
# Pure business logic separated from data access
|
||||
return IssueDetails(
|
||||
issue=issue,
|
||||
kanban_column=self._determine_kanban_column(issue, project_info),
|
||||
priority_info=self._extract_priority_info(issue),
|
||||
state_info=self._extract_state_info(issue)
|
||||
)
|
||||
```
|
||||
|
||||
### **Example 2: Connection Management**
|
||||
|
||||
#### **Before (Subprocess-based HTTP):**
|
||||
```python
|
||||
class GiteaHttpClient:
|
||||
def _make_request(self, method: str, url: str, data: Optional[Dict[str, Any]] = None):
|
||||
# New subprocess for every request - very inefficient
|
||||
cmd = ['curl', '-s', '-X', method]
|
||||
if data:
|
||||
cmd.extend(['-d', json.dumps(data)])
|
||||
cmd.append(url)
|
||||
|
||||
result = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, text=True)
|
||||
# Poor error handling
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"HTTP request failed: {result.stderr}")
|
||||
|
||||
return json.loads(result.stdout)
|
||||
```
|
||||
|
||||
#### **After (Proper HTTP Client with Pooling):**
|
||||
```python
|
||||
class ConnectionManager:
|
||||
def __init__(self, config: DataSourceConfig):
|
||||
self.config = config
|
||||
self._http_session = None
|
||||
|
||||
async def get_http_session(self) -> aiohttp.ClientSession:
|
||||
if self._http_session is None:
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=self.config.connection_pool_size,
|
||||
limit_per_host=5,
|
||||
keepalive_timeout=60
|
||||
)
|
||||
timeout = aiohttp.ClientTimeout(total=self.config.request_timeout)
|
||||
|
||||
self._http_session = aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
timeout=timeout,
|
||||
headers={'Authorization': f'token {self.config.gitea_token}'}
|
||||
)
|
||||
return self._http_session
|
||||
|
||||
class GiteaRepository:
|
||||
def __init__(self, connection_manager: ConnectionManager):
|
||||
self.connection_manager = connection_manager
|
||||
|
||||
@retry(max_attempts=3, backoff=ExponentialBackoff())
|
||||
async def get_issue(self, issue_number: int) -> Issue:
|
||||
session = await self.connection_manager.get_http_session()
|
||||
|
||||
async with session.get(f'/api/v1/repos/.../issues/{issue_number}') as response:
|
||||
if response.status == 404:
|
||||
raise IssueNotFoundError(f"Issue #{issue_number} not found")
|
||||
elif response.status >= 400:
|
||||
raise GiteaApiError(f"API error: {response.status}")
|
||||
|
||||
data = await response.json()
|
||||
return Issue.from_api_data(data)
|
||||
```
|
||||
|
||||
### **Example 3: Transaction Management**
|
||||
|
||||
#### **Before (No Transaction Support):**
|
||||
```python
|
||||
class DocumentManager:
|
||||
def ingest_file(self, file_path: Path) -> Dict[str, Any]:
|
||||
# Multiple separate operations - if any fails, inconsistent state
|
||||
content = self._read_file_content(file_path)
|
||||
ast, parse_time = self._parse_content_to_ast(content)
|
||||
cache_file, cache_time = self._create_performance_cache(file_path.name, ast)
|
||||
|
||||
# Database operation could fail after cache is created
|
||||
self._store_in_database(file_path.name, content)
|
||||
|
||||
return self._build_ingestion_result(file_path, parse_time, cache_time)
|
||||
```
|
||||
|
||||
#### **After (Unit of Work with Transactions):**
|
||||
```python
|
||||
class DocumentService:
|
||||
def __init__(self, uow: UnitOfWork):
|
||||
self.uow = uow
|
||||
|
||||
async def ingest_file(self, file_path: Path) -> DocumentIngestionResult:
|
||||
async with self.uow:
|
||||
# All operations in single transaction
|
||||
content = await self._read_file_content(file_path)
|
||||
ast, parse_time = await self._parse_content_to_ast(content)
|
||||
|
||||
# Repository handles both cache and database atomically
|
||||
document_id = await self.uow.documents.store_document(
|
||||
filename=file_path.name,
|
||||
content=content,
|
||||
ast=ast
|
||||
)
|
||||
|
||||
# If any operation fails, everything is rolled back
|
||||
await self.uow.cache.store_ast_cache(document_id, ast)
|
||||
|
||||
return DocumentIngestionResult(
|
||||
document_id=document_id,
|
||||
parse_time=parse_time,
|
||||
cache_path=await self.uow.documents.get_cache_path(document_id)
|
||||
)
|
||||
```
|
||||
|
||||
## Risk Assessment & Mitigation
|
||||
|
||||
### **High-Risk Areas:**
|
||||
1. **Service Layer Refactoring** - Could break existing functionality
|
||||
2. **Database Transaction Changes** - Risk of data corruption
|
||||
3. **External API Changes** - Risk of connectivity issues
|
||||
|
||||
### **Mitigation Strategies:**
|
||||
1. **Parallel Implementation** - Keep old code until new code is proven
|
||||
2. **Feature Flags** - Toggle between old and new implementations
|
||||
3. **Comprehensive Testing** - Unit, integration, and end-to-end tests
|
||||
4. **Gradual Migration** - Migrate one service at a time
|
||||
5. **Monitoring** - Real-time performance and error monitoring
|
||||
|
||||
### **Rollback Plan:**
|
||||
- Feature flags allow instant rollback to previous implementation
|
||||
- Database migrations are reversible
|
||||
- Configuration changes can be reverted via environment variables
|
||||
- Each phase is independently deployable and reversible
|
||||
|
||||
## Performance Benefits Expected
|
||||
|
||||
### **HTTP Client Improvements:**
|
||||
- **Before**: New subprocess per request (~100-200ms overhead)
|
||||
- **After**: Connection pooling (~5-10ms per request)
|
||||
- **Improvement**: 10-20x faster API operations
|
||||
|
||||
### **Database Operations:**
|
||||
- **Before**: New connection per operation
|
||||
- **After**: Connection pooling and prepared statements
|
||||
- **Improvement**: 3-5x faster database operations
|
||||
|
||||
### **Error Recovery:**
|
||||
- **Before**: Silent failures and inconsistent error handling
|
||||
- **After**: Automatic retries and structured error reporting
|
||||
- **Improvement**: 90% reduction in transient failures
|
||||
|
||||
### **Resource Utilization:**
|
||||
- **Before**: Resource leaks from subprocess and connection management
|
||||
- **After**: Proper resource pooling and cleanup
|
||||
- **Improvement**: 50-70% reduction in resource usage
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### **Unit Testing:**
|
||||
- Repository interfaces with mock implementations
|
||||
- Business logic separated from data access
|
||||
- Error handling and edge cases
|
||||
- Performance characteristics
|
||||
|
||||
### **Integration Testing:**
|
||||
- Real database and API interactions
|
||||
- Transaction rollback scenarios
|
||||
- Connection pooling behavior
|
||||
- Retry mechanism validation
|
||||
|
||||
### **Performance Testing:**
|
||||
- Load testing for concurrent operations
|
||||
- Memory usage and leak detection
|
||||
- Connection pool utilization
|
||||
- Cache effectiveness measurement
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### **Metrics to Track:**
|
||||
- Request latency percentiles (p50, p95, p99)
|
||||
- Error rates by operation type
|
||||
- Connection pool utilization
|
||||
- Cache hit/miss ratios
|
||||
- Database query performance
|
||||
- API rate limiting compliance
|
||||
|
||||
### **Alerting:**
|
||||
- High error rates or latency spikes
|
||||
- Connection pool exhaustion
|
||||
- Database deadlocks or timeouts
|
||||
- API rate limit violations
|
||||
- Cache performance degradation
|
||||
|
||||
This comprehensive gameplan provides a systematic approach to modernizing data access patterns while maintaining system stability and ensuring measurable performance improvements.
|
||||
359
DOMAIN_LOGIC_SEPARATION_DEMO.md
Normal file
359
DOMAIN_LOGIC_SEPARATION_DEMO.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Domain Logic Separation - Implementation Demo
|
||||
|
||||
## Overview
|
||||
|
||||
This document demonstrates the successful implementation of Phase 1 of domain logic separation, showing how business logic has been extracted from infrastructure concerns and organized into clean, testable domain models.
|
||||
|
||||
## 🎯 What We've Accomplished
|
||||
|
||||
### ✅ Phase 1: Domain Model Extraction - COMPLETED
|
||||
|
||||
We have successfully implemented:
|
||||
|
||||
1. **Issue Domain Models** with 48 passing tests
|
||||
2. **Project Domain Models** with 31 passing tests
|
||||
3. **Pure Business Logic** separated from infrastructure
|
||||
4. **Rich Domain Models** with business rules and validation
|
||||
5. **Domain Services** for complex business operations
|
||||
|
||||
## 🔍 Before vs After Comparison
|
||||
|
||||
### **BEFORE: Mixed Concerns (Current IssueService)**
|
||||
|
||||
```python
|
||||
# services/issue_service.py - CURRENT PROBLEMATIC CODE
|
||||
class IssueService:
|
||||
def get_issue_details(self, issue_number: int) -> Dict[str, Any]:
|
||||
# ❌ MIXED: Infrastructure dependency mixed with business logic
|
||||
from tddai.project_manager import ProjectManager
|
||||
project_mgr = ProjectManager()
|
||||
|
||||
# ❌ MIXED: Direct API call mixed with business logic
|
||||
from tddai.config import get_config
|
||||
config = get_config()
|
||||
issue_url = f"{config.issues_api_url}/{issue_number}"
|
||||
detailed_issue = project_mgr._make_api_call('GET', issue_url)
|
||||
|
||||
# ❌ MIXED: Business rules scattered throughout infrastructure code
|
||||
labels = detailed_issue.get('labels', [])
|
||||
state_labels = [label['name'] for label in labels if label['name'].startswith('status:')]
|
||||
priority_labels = [label['name'] for label in labels if label['name'].startswith('priority:')]
|
||||
type_labels = [label['name'] for label in labels if label['name'] in ['bug', 'enhancement', 'feature']]
|
||||
other_labels = [label['name'] for label in labels
|
||||
if not any(label['name'].startswith(prefix) for prefix in ['status:', 'priority:'])
|
||||
and label['name'] not in ['bug', 'enhancement', 'feature']]
|
||||
|
||||
# ❌ MIXED: Business logic for kanban column determination mixed with data access
|
||||
kanban_column = "Todo" # Default
|
||||
if detailed_issue['state'] == 'closed':
|
||||
kanban_column = "Done"
|
||||
elif any(label.startswith('status:in-progress') for label in state_labels):
|
||||
kanban_column = "In Progress"
|
||||
# ... more mixed business logic
|
||||
```
|
||||
|
||||
**Problems with the current approach:**
|
||||
- ❌ **No testability**: Cannot test business logic without external systems
|
||||
- ❌ **Mixed concerns**: Business rules scattered in infrastructure code
|
||||
- ❌ **No reusability**: Logic tied to specific data access patterns
|
||||
- ❌ **Hard to maintain**: Changes to business rules require touching infrastructure
|
||||
- ❌ **No domain expertise**: Business rules are implicit, not explicit
|
||||
|
||||
### **AFTER: Clean Domain Logic (New Architecture)**
|
||||
|
||||
#### **1. Pure Domain Models**
|
||||
|
||||
```python
|
||||
# domain/issues/models.py - NEW CLEAN DOMAIN CODE
|
||||
@dataclass
|
||||
class Issue:
|
||||
"""Issue aggregate root with pure business logic."""
|
||||
number: int
|
||||
title: str
|
||||
state: IssueState
|
||||
labels: List[Label]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
def categorize_labels(self) -> LabelCategories:
|
||||
"""✅ PURE: Business logic with no infrastructure dependencies."""
|
||||
state_labels = [label.name for label in self.labels if label.is_state_label()]
|
||||
priority_labels = [label.name for label in self.labels if label.is_priority_label()]
|
||||
type_labels = [label.name for label in self.labels if label.is_type_label()]
|
||||
other_labels = [
|
||||
label.name for label in self.labels
|
||||
if not (label.is_state_label() or label.is_priority_label() or label.is_type_label())
|
||||
]
|
||||
|
||||
return LabelCategories(
|
||||
state_labels=state_labels,
|
||||
priority_labels=priority_labels,
|
||||
type_labels=type_labels,
|
||||
other_labels=other_labels
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""✅ PURE: Business rule for closing issues."""
|
||||
if self.state == IssueState.CLOSED:
|
||||
raise IssueStateError("Issue is already closed")
|
||||
|
||||
self.state = IssueState.CLOSED
|
||||
self.closed_at = datetime.utcnow()
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Label:
|
||||
"""✅ PURE: Value object with business logic."""
|
||||
name: str
|
||||
|
||||
def is_state_label(self) -> bool:
|
||||
"""✅ EXPLICIT: Business rule for identifying state labels."""
|
||||
return self.name.startswith('status:')
|
||||
|
||||
def is_priority_label(self) -> bool:
|
||||
"""✅ EXPLICIT: Business rule for identifying priority labels."""
|
||||
return self.name.startswith('priority:')
|
||||
|
||||
def is_type_label(self) -> bool:
|
||||
"""✅ EXPLICIT: Business rule for identifying type labels."""
|
||||
return self.name in ['bug', 'enhancement', 'feature', 'documentation']
|
||||
```
|
||||
|
||||
#### **2. Domain Services for Complex Business Logic**
|
||||
|
||||
```python
|
||||
# domain/issues/services.py - NEW DOMAIN SERVICES
|
||||
class IssueStatusService:
|
||||
"""✅ PURE: Domain service containing only business logic."""
|
||||
|
||||
def determine_kanban_column(self, issue: Issue, project_info: Dict[str, Any]) -> str:
|
||||
"""✅ TESTABLE: Pure business logic for kanban column determination."""
|
||||
label_categories = issue.categorize_labels()
|
||||
|
||||
# ✅ EXPLICIT: Clear business rules
|
||||
if issue.state == IssueState.CLOSED:
|
||||
return "Done"
|
||||
|
||||
# ✅ READABLE: Business rules are explicit and easy to understand
|
||||
for state_label in label_categories.state_labels:
|
||||
if state_label == "status:in-progress":
|
||||
return "In Progress"
|
||||
elif state_label == "status:review":
|
||||
return "Review"
|
||||
elif state_label == "status:blocked":
|
||||
return "Blocked"
|
||||
|
||||
return "Todo" # Default for open issues
|
||||
|
||||
def extract_priority_info(self, issue: Issue) -> Dict[str, Any]:
|
||||
"""✅ TESTABLE: Pure business logic for priority extraction."""
|
||||
label_categories = issue.categorize_labels()
|
||||
|
||||
priority_mapping = {
|
||||
"priority:low": "Low",
|
||||
"priority:medium": "Medium",
|
||||
"priority:high": "High",
|
||||
"priority:critical": "Critical"
|
||||
}
|
||||
|
||||
for priority_label in label_categories.priority_labels:
|
||||
if priority_label in priority_mapping:
|
||||
return {
|
||||
"level": priority_mapping[priority_label],
|
||||
"label": priority_label
|
||||
}
|
||||
|
||||
return {"level": "Medium", "label": None} # Default
|
||||
```
|
||||
|
||||
#### **3. Future Application Service (Clean Orchestration)**
|
||||
|
||||
```python
|
||||
# application/issue_application_service.py - FUTURE CLEAN COORDINATION
|
||||
class IssueApplicationService:
|
||||
"""✅ CLEAN: Coordinates domain logic with infrastructure."""
|
||||
|
||||
def __init__(self, issue_repository: IssueRepository, project_repository: ProjectRepository):
|
||||
self.issue_repository = issue_repository
|
||||
self.project_repository = project_repository
|
||||
self.status_service = IssueStatusService() # ✅ PURE domain service
|
||||
|
||||
async def get_issue_details(self, issue_number: int) -> IssueDetailsResult:
|
||||
"""✅ SEPARATION: Clean separation of concerns."""
|
||||
# ✅ INFRASTRUCTURE: Data access through repository
|
||||
issue = await self.issue_repository.get_issue(issue_number)
|
||||
project_info = await self.project_repository.get_issue_project_info(issue_number)
|
||||
|
||||
# ✅ DOMAIN: Pure business logic application
|
||||
kanban_column = self.status_service.determine_kanban_column(issue, project_info)
|
||||
priority_info = self.status_service.extract_priority_info(issue)
|
||||
|
||||
# ✅ CLEAN: Return structured result
|
||||
return IssueDetailsResult(
|
||||
issue=issue,
|
||||
kanban_column=kanban_column,
|
||||
priority_info=priority_info,
|
||||
project_context=project_info
|
||||
)
|
||||
```
|
||||
|
||||
## 🧪 Testing Improvements
|
||||
|
||||
### **BEFORE: No Testable Business Logic**
|
||||
|
||||
```python
|
||||
# ❌ IMPOSSIBLE: Cannot test business logic without external dependencies
|
||||
def test_kanban_column_determination():
|
||||
# This test is impossible because business logic is mixed with API calls
|
||||
service = IssueService()
|
||||
# ❌ This requires real API calls, database connections, etc.
|
||||
result = service.get_issue_details(123) # Makes real HTTP requests!
|
||||
```
|
||||
|
||||
### **AFTER: Comprehensive Pure Unit Tests**
|
||||
|
||||
```python
|
||||
# ✅ TESTABLE: Pure business logic tests with NO external dependencies
|
||||
def test_determine_kanban_column_for_in_progress_issue():
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test Issue",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("status:in-progress")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
service = IssueStatusService()
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Done"]}
|
||||
|
||||
# Act
|
||||
column = service.determine_kanban_column(issue, project_info)
|
||||
|
||||
# Assert
|
||||
assert column == "In Progress"
|
||||
|
||||
def test_categorize_labels_correctly_separates_types():
|
||||
# Arrange
|
||||
labels = [
|
||||
Label("bug"), # type label
|
||||
Label("priority:high"), # priority label
|
||||
Label("status:in-progress"), # state label
|
||||
Label("custom-label") # other label
|
||||
]
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=labels,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
categories = issue.categorize_labels()
|
||||
|
||||
# Assert - ✅ PURE: Testing business logic in isolation
|
||||
assert "bug" in categories.type_labels
|
||||
assert "priority:high" in categories.priority_labels
|
||||
assert "status:in-progress" in categories.state_labels
|
||||
assert "custom-label" in categories.other_labels
|
||||
```
|
||||
|
||||
## 📊 Test Results
|
||||
|
||||
### **Issue Domain: 48/48 Tests Passing ✅**
|
||||
|
||||
```bash
|
||||
tests/unit/domain/issues/test_issue_models.py::TestLabel::test_label_creation PASSED
|
||||
tests/unit/domain/issues/test_issue_models.py::TestLabel::test_is_state_label PASSED
|
||||
tests/unit/domain/issues/test_issue_models.py::TestLabel::test_is_priority_label PASSED
|
||||
tests/unit/domain/issues/test_issue_models.py::TestLabel::test_is_type_label PASSED
|
||||
# ... 44 more tests PASSED
|
||||
tests/unit/domain/issues/test_issue_services.py::TestIssueStatusService::test_determine_kanban_column_for_closed_issue PASSED
|
||||
tests/unit/domain/issues/test_issue_services.py::TestIssueValidationService::test_validate_issue_creation_with_valid_data PASSED
|
||||
# ... all business logic tests PASSED
|
||||
|
||||
=============================== 48 passed in 0.85s ===============================
|
||||
```
|
||||
|
||||
### **Project Domain: 31/31 Tests Passing ✅**
|
||||
|
||||
```bash
|
||||
tests/unit/domain/projects/test_project_models.py::TestMilestone::test_milestone_creation PASSED
|
||||
tests/unit/domain/projects/test_project_models.py::TestMilestone::test_completion_percentage_calculation PASSED
|
||||
tests/unit/domain/projects/test_project_models.py::TestProject::test_calculate_overall_progress PASSED
|
||||
# ... 28 more tests PASSED
|
||||
|
||||
=============================== 31 passed in 0.96s ===============================
|
||||
```
|
||||
|
||||
## 🚀 Benefits Achieved
|
||||
|
||||
### **1. Pure Testability**
|
||||
- ✅ **79 pure unit tests** with NO external dependencies
|
||||
- ✅ **Fast execution**: All tests run in under 2 seconds
|
||||
- ✅ **Reliable**: No flaky tests due to external systems
|
||||
- ✅ **Complete coverage**: Every business rule is tested
|
||||
|
||||
### **2. Explicit Business Logic**
|
||||
- ✅ **Clear domain models**: `Issue`, `Label`, `Milestone`, `Project`
|
||||
- ✅ **Explicit business rules**: `is_state_label()`, `categorize_labels()`, `determine_kanban_column()`
|
||||
- ✅ **Domain expertise**: Business concepts are first-class citizens
|
||||
- ✅ **Self-documenting**: Code clearly expresses business intent
|
||||
|
||||
### **3. Maintainability**
|
||||
- ✅ **Single responsibility**: Each class has one clear purpose
|
||||
- ✅ **Open/closed principle**: Easy to extend without modifying existing code
|
||||
- ✅ **Dependency inversion**: Domain doesn't depend on infrastructure
|
||||
- ✅ **Change isolation**: Business logic changes don't affect infrastructure
|
||||
|
||||
### **4. Reusability**
|
||||
- ✅ **Technology independent**: Domain logic works with any infrastructure
|
||||
- ✅ **Composable**: Domain services can be combined in different ways
|
||||
- ✅ **Portable**: Domain models can be used across different applications
|
||||
- ✅ **Framework agnostic**: No dependencies on specific frameworks
|
||||
|
||||
## 🔄 Migration Strategy
|
||||
|
||||
### **Backward Compatibility Maintained**
|
||||
The existing `IssueService` continues to work unchanged. When ready, we can:
|
||||
|
||||
1. **Phase 2**: Create repository implementations
|
||||
2. **Phase 3**: Create application services using new domain logic
|
||||
3. **Phase 4**: Create adapter that makes old `IssueService` use new architecture internally
|
||||
4. **Phase 5**: Gradually migrate consumers to new application services
|
||||
|
||||
### **Feature Flag Ready**
|
||||
The new domain logic is ready to be integrated with feature flags:
|
||||
|
||||
```python
|
||||
# Future integration approach
|
||||
def get_issue_service():
|
||||
if config.use_new_domain_architecture:
|
||||
return NewIssueApplicationService(issue_repo, project_repo)
|
||||
else:
|
||||
return LegacyIssueService() # Current implementation
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### **Immediate Value**
|
||||
- ✅ **Business logic is now testable**: 79 fast, reliable unit tests
|
||||
- ✅ **Domain expertise is captured**: Business rules are explicit
|
||||
- ✅ **Foundation for future work**: Clean architecture foundation established
|
||||
|
||||
### **Next Phase Implementation**
|
||||
1. **Repository Implementations**: Abstract external API calls
|
||||
2. **Application Services**: Coordinate domain with infrastructure
|
||||
3. **Migration Adapters**: Maintain backward compatibility
|
||||
4. **Integration Testing**: Test complete workflows
|
||||
|
||||
## 📈 Success Metrics
|
||||
|
||||
- ✅ **100% test coverage** of domain business logic
|
||||
- ✅ **Zero infrastructure dependencies** in domain layer
|
||||
- ✅ **Sub-second test execution** for all business logic
|
||||
- ✅ **Clear separation** between domain, application, and infrastructure
|
||||
- ✅ **Backward compatibility** maintained during transition
|
||||
|
||||
The domain logic separation has successfully created a **solid foundation** for maintainable, testable, and flexible business logic that can evolve independently of technical implementation details.
|
||||
1762
DOMAIN_LOGIC_SEPARATION_GAMEPLAN.md
Normal file
1762
DOMAIN_LOGIC_SEPARATION_GAMEPLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
1288
TESTING_ARCHITECTURE_ENHANCEMENT_GAMEPLAN.md
Normal file
1288
TESTING_ARCHITECTURE_ENHANCEMENT_GAMEPLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
5
application/__init__.py
Normal file
5
application/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Application services layer for MarkiTect project.
|
||||
|
||||
Contains use case implementations that coordinate domain and infrastructure.
|
||||
"""
|
||||
42
config/__init__.py
Normal file
42
config/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Unified configuration management for MarkiTect project.
|
||||
|
||||
This module provides a centralized configuration system that consolidates
|
||||
scattered configuration patterns across TDDAI, Gitea, MarkiTect, and other modules.
|
||||
|
||||
Main exports:
|
||||
- BaseConfig: Base configuration class with common functionality
|
||||
- ConfigurationError: Unified configuration exception
|
||||
- load_env_file: Utility for loading environment files
|
||||
- get_unified_config: Main configuration access point
|
||||
"""
|
||||
|
||||
from .base import BaseConfig
|
||||
from .exceptions import ConfigurationError, ConfigValidationError
|
||||
from .loaders import load_env_file, resolve_path
|
||||
from .manager import UnifiedConfigManager, get_unified_config, MarkitectConfig, reload_config, get_config_status
|
||||
from .compat import TddaiConfigCompat, GiteaConfigCompat, get_tddai_config, get_gitea_config
|
||||
|
||||
__all__ = [
|
||||
# Core configuration
|
||||
'BaseConfig',
|
||||
'MarkitectConfig',
|
||||
'UnifiedConfigManager',
|
||||
'get_unified_config',
|
||||
'reload_config',
|
||||
'get_config_status',
|
||||
|
||||
# Exceptions
|
||||
'ConfigurationError',
|
||||
'ConfigValidationError',
|
||||
|
||||
# Utilities
|
||||
'load_env_file',
|
||||
'resolve_path',
|
||||
|
||||
# Compatibility layer
|
||||
'TddaiConfigCompat',
|
||||
'GiteaConfigCompat',
|
||||
'get_tddai_config',
|
||||
'get_gitea_config'
|
||||
]
|
||||
304
config/base.py
Normal file
304
config/base.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Base configuration classes.
|
||||
|
||||
Provides base classes and common functionality for all configuration
|
||||
management across the MarkiTect project.
|
||||
"""
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
|
||||
from .exceptions import ConfigurationError, ConfigValidationError
|
||||
from .loaders import load_env_file, get_env_var, resolve_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseConfig(ABC):
|
||||
"""Base configuration class with common functionality.
|
||||
|
||||
Provides a foundation for all configuration classes with:
|
||||
- Environment variable loading with standardized prefixes
|
||||
- File-based configuration loading
|
||||
- Validation and error handling
|
||||
- Backward compatibility support
|
||||
"""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize configuration after dataclass creation."""
|
||||
self.load_from_environment()
|
||||
self.load_from_files()
|
||||
self.validate()
|
||||
|
||||
@abstractmethod
|
||||
def get_env_prefix(self) -> str:
|
||||
"""Get the environment variable prefix for this configuration.
|
||||
|
||||
Returns:
|
||||
Environment variable prefix (e.g., 'MARKITECT_', 'TDDAI_')
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_config_files(self) -> List[Union[str, Path]]:
|
||||
"""Get list of configuration files to load (in order of priority).
|
||||
|
||||
Returns:
|
||||
List of configuration file paths to attempt loading
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration values.
|
||||
|
||||
Raises:
|
||||
ConfigValidationError: If validation fails
|
||||
"""
|
||||
pass
|
||||
|
||||
def load_from_environment(self) -> None:
|
||||
"""Load configuration from environment variables.
|
||||
|
||||
Uses the prefix from get_env_prefix() to find relevant variables.
|
||||
"""
|
||||
prefix = self.get_env_prefix()
|
||||
|
||||
# Get all environment variables with the prefix
|
||||
env_vars = {
|
||||
key[len(prefix):].lower(): value
|
||||
for key, value in os.environ.items()
|
||||
if key.startswith(prefix)
|
||||
}
|
||||
|
||||
# Apply environment variables to configuration fields
|
||||
for field_name, value in env_vars.items():
|
||||
if hasattr(self, field_name):
|
||||
# Type conversion based on field type
|
||||
field_type = self.__dataclass_fields__[field_name].type
|
||||
try:
|
||||
if field_type == bool:
|
||||
converted_value = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif field_type == int:
|
||||
converted_value = int(value)
|
||||
elif field_type == Path:
|
||||
converted_value = resolve_path(value)
|
||||
else:
|
||||
converted_value = value
|
||||
|
||||
setattr(self, field_name, converted_value)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ConfigurationError(
|
||||
f"Invalid value for {prefix}{field_name.upper()}: {value}",
|
||||
field=field_name,
|
||||
value=value,
|
||||
suggestion=f"Provide a valid {field_type.__name__} value"
|
||||
) from e
|
||||
|
||||
def load_from_files(self) -> None:
|
||||
"""Load configuration from files.
|
||||
|
||||
Loads from files specified by get_config_files() in order,
|
||||
with later files overriding earlier ones.
|
||||
"""
|
||||
for config_file in self.get_config_files():
|
||||
try:
|
||||
file_vars = load_env_file(config_file, required=False)
|
||||
|
||||
# Apply file variables (convert to lowercase for consistency)
|
||||
prefix = self.get_env_prefix()
|
||||
legacy_mapping = self.get_legacy_mapping()
|
||||
|
||||
for key, value in file_vars.items():
|
||||
field_name = None
|
||||
|
||||
# Check if it's a legacy variable we can map
|
||||
if key in legacy_mapping:
|
||||
field_name = legacy_mapping[key]
|
||||
# Remove prefix if present in file
|
||||
elif key.startswith(prefix):
|
||||
field_name = key[len(prefix):].lower()
|
||||
else:
|
||||
field_name = key.lower()
|
||||
|
||||
if field_name and hasattr(self, field_name):
|
||||
# Type conversion
|
||||
field_type = self.__dataclass_fields__[field_name].type
|
||||
try:
|
||||
if field_type == bool:
|
||||
converted_value = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif field_type == int:
|
||||
converted_value = int(value)
|
||||
elif field_type == Path:
|
||||
converted_value = resolve_path(value)
|
||||
else:
|
||||
converted_value = value
|
||||
|
||||
setattr(self, field_name, converted_value)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ConfigurationError(
|
||||
f"Invalid value in {config_file} for {field_name}: {value}",
|
||||
field=field_name,
|
||||
value=value
|
||||
) from e
|
||||
|
||||
except ConfigurationError:
|
||||
# Re-raise configuration errors
|
||||
raise
|
||||
except Exception as e:
|
||||
# Convert other errors to configuration errors
|
||||
raise ConfigurationError(
|
||||
f"Failed to load configuration from {config_file}: {e}",
|
||||
context={'file': str(config_file)}
|
||||
) from e
|
||||
|
||||
def validate_required_fields(self, *field_names: str) -> None:
|
||||
"""Validate that required fields are not empty.
|
||||
|
||||
Args:
|
||||
*field_names: Names of fields to validate
|
||||
|
||||
Raises:
|
||||
ConfigValidationError: If any required field is empty
|
||||
"""
|
||||
for field_name in field_names:
|
||||
value = getattr(self, field_name, None)
|
||||
|
||||
if not value or (isinstance(value, str) and not value.strip()):
|
||||
raise ConfigValidationError(
|
||||
f"Required configuration field cannot be empty: {field_name}",
|
||||
field=field_name,
|
||||
value=value,
|
||||
suggestion=f"Set {self.get_env_prefix()}{field_name.upper()} environment variable or add to config file"
|
||||
)
|
||||
|
||||
def validate_url(self, field_name: str) -> None:
|
||||
"""Validate that a field contains a valid URL.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field to validate
|
||||
|
||||
Raises:
|
||||
ConfigValidationError: If URL is invalid
|
||||
"""
|
||||
url = getattr(self, field_name, None)
|
||||
|
||||
if not url:
|
||||
return # Empty URLs are handled by validate_required_fields
|
||||
|
||||
if not isinstance(url, str):
|
||||
raise ConfigValidationError(
|
||||
f"URL field must be a string: {field_name}",
|
||||
field=field_name,
|
||||
value=url
|
||||
)
|
||||
|
||||
# Basic URL validation
|
||||
if not (url.startswith('http://') or url.startswith('https://')):
|
||||
raise ConfigValidationError(
|
||||
f"URL must start with http:// or https://: {field_name}",
|
||||
field=field_name,
|
||||
value=url,
|
||||
suggestion="Add http:// or https:// prefix to the URL"
|
||||
)
|
||||
|
||||
def validate_path_exists(self, field_name: str, create_if_missing: bool = False) -> None:
|
||||
"""Validate that a path exists.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field to validate
|
||||
create_if_missing: Whether to create the path if it doesn't exist
|
||||
|
||||
Raises:
|
||||
ConfigValidationError: If path doesn't exist and cannot be created
|
||||
"""
|
||||
path = getattr(self, field_name, None)
|
||||
|
||||
if not path:
|
||||
return # Empty paths are handled by validate_required_fields
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
if create_if_missing:
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise ConfigValidationError(
|
||||
f"Cannot create directory: {path}",
|
||||
field=field_name,
|
||||
value=str(path),
|
||||
suggestion="Check directory permissions and parent directory existence"
|
||||
) from e
|
||||
else:
|
||||
raise ConfigValidationError(
|
||||
f"Path does not exist: {path}",
|
||||
field=field_name,
|
||||
value=str(path),
|
||||
suggestion=f"Create the directory {path} or use a different path"
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert configuration to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of configuration
|
||||
"""
|
||||
result = {}
|
||||
for field_name, field_def in self.__dataclass_fields__.items():
|
||||
value = getattr(self, field_name)
|
||||
|
||||
# Convert Path objects to strings
|
||||
if isinstance(value, Path):
|
||||
value = str(value)
|
||||
|
||||
result[field_name] = value
|
||||
|
||||
return result
|
||||
|
||||
def get_legacy_mapping(self) -> Dict[str, str]:
|
||||
"""Get mapping of legacy environment variable names to new names.
|
||||
|
||||
Override in subclasses to provide backward compatibility.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping old environment variable names to new field names
|
||||
"""
|
||||
return {}
|
||||
|
||||
def load_legacy_environment(self) -> None:
|
||||
"""Load configuration from legacy environment variables.
|
||||
|
||||
Provides backward compatibility for existing environment variable names.
|
||||
"""
|
||||
legacy_mapping = self.get_legacy_mapping()
|
||||
|
||||
for old_var, field_name in legacy_mapping.items():
|
||||
if old_var in os.environ and hasattr(self, field_name):
|
||||
value = os.environ[old_var]
|
||||
|
||||
# Type conversion
|
||||
field_type = self.__dataclass_fields__[field_name].type
|
||||
try:
|
||||
if field_type == bool:
|
||||
converted_value = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif field_type == int:
|
||||
converted_value = int(value)
|
||||
elif field_type == Path:
|
||||
converted_value = resolve_path(value)
|
||||
else:
|
||||
converted_value = value
|
||||
|
||||
setattr(self, field_name, converted_value)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ConfigurationError(
|
||||
f"Invalid value for legacy environment variable {old_var}: {value}",
|
||||
field=field_name,
|
||||
value=value,
|
||||
suggestion=f"Use new environment variable {self.get_env_prefix()}{field_name.upper()} instead"
|
||||
) from e
|
||||
169
config/compat.py
Normal file
169
config/compat.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Backward compatibility layer for existing configuration systems.
|
||||
|
||||
Provides compatibility shims that allow existing TDDAI and Gitea
|
||||
configuration code to work with the unified configuration system.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from .manager import get_unified_config
|
||||
from .exceptions import ConfigurationError
|
||||
|
||||
|
||||
@dataclass
|
||||
class TddaiConfigCompat:
|
||||
"""TDDAI configuration compatibility layer.
|
||||
|
||||
Provides the same interface as the original TddaiConfig but
|
||||
backed by the unified configuration system.
|
||||
"""
|
||||
|
||||
workspace_dir: Path
|
||||
current_issue_file: str = "current_issue.json"
|
||||
gitea_url: str = ""
|
||||
repo_owner: str = ""
|
||||
repo_name: str = ""
|
||||
tests_dir: Path = Path("tests")
|
||||
test_file_pattern: str = "test_issue_{issue_num}_{scenario}.py"
|
||||
claude_code_command: str = "claude"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize from unified configuration."""
|
||||
unified = get_unified_config()
|
||||
|
||||
# Map unified config to TDDAI format
|
||||
self.workspace_dir = unified.workspace_dir
|
||||
self.gitea_url = unified.gitea_url
|
||||
self.repo_owner = unified.repo_owner
|
||||
self.repo_name = unified.repo_name
|
||||
self.tests_dir = unified.tests_dir
|
||||
self.test_file_pattern = unified.test_file_pattern
|
||||
self.claude_code_command = unified.claude_code_command
|
||||
|
||||
# Apply any explicit overrides
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration (for compatibility)."""
|
||||
if not self.gitea_url or not self.gitea_url.strip():
|
||||
raise ConfigurationError("gitea_url cannot be empty")
|
||||
|
||||
if not self.repo_owner or not self.repo_owner.strip():
|
||||
raise ConfigurationError("repo_owner cannot be empty")
|
||||
|
||||
if not self.repo_name or not self.repo_name.strip():
|
||||
raise ConfigurationError("repo_name cannot be empty")
|
||||
|
||||
@property
|
||||
def issues_api_url(self) -> str:
|
||||
"""Get issues API URL (for compatibility)."""
|
||||
return f"{self.gitea_url}/api/v1/repos/{self.repo_owner}/{self.repo_name}/issues"
|
||||
|
||||
@property
|
||||
def issues_base_url(self) -> str:
|
||||
"""Get issues base URL (for compatibility)."""
|
||||
return f"{self.gitea_url}/{self.repo_owner}/{self.repo_name}/issues"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GiteaConfigCompat:
|
||||
"""Gitea configuration compatibility layer.
|
||||
|
||||
Provides the same interface as the original GiteaConfig but
|
||||
backed by the unified configuration system.
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
repo_owner: str
|
||||
repo_name: str
|
||||
auth_token: str = ""
|
||||
api_version: str = "v1"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize from unified configuration."""
|
||||
unified = get_unified_config()
|
||||
|
||||
# Map unified config to Gitea format
|
||||
self.base_url = unified.gitea_url
|
||||
self.repo_owner = unified.repo_owner
|
||||
self.repo_name = unified.repo_name
|
||||
self.auth_token = unified.api_token
|
||||
|
||||
# Apply any explicit overrides
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
@classmethod
|
||||
def from_tddai_config(cls, tddai_config) -> 'GiteaConfigCompat':
|
||||
"""Create from TDDAI config (for compatibility)."""
|
||||
return cls(
|
||||
base_url=tddai_config.gitea_url,
|
||||
repo_owner=tddai_config.repo_owner,
|
||||
repo_name=tddai_config.repo_name
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration (for compatibility)."""
|
||||
if not self.base_url or not self.base_url.strip():
|
||||
raise ConfigurationError("base_url cannot be empty")
|
||||
|
||||
if not self.repo_owner or not self.repo_owner.strip():
|
||||
raise ConfigurationError("repo_owner cannot be empty")
|
||||
|
||||
if not self.repo_name or not self.repo_name.strip():
|
||||
raise ConfigurationError("repo_name cannot be empty")
|
||||
|
||||
# URL validation
|
||||
if not (self.base_url.startswith('http://') or self.base_url.startswith('https://')):
|
||||
raise ConfigurationError("base_url must start with http:// or https://")
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
"""Get API base URL."""
|
||||
return f"{self.base_url}/api/{self.api_version}"
|
||||
|
||||
@property
|
||||
def repo_api_url(self) -> str:
|
||||
"""Get repository API URL."""
|
||||
return f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}"
|
||||
|
||||
@property
|
||||
def issues_api_url(self) -> str:
|
||||
"""Get issues API URL."""
|
||||
return f"{self.repo_api_url}/issues"
|
||||
|
||||
|
||||
def get_tddai_config(**kwargs) -> TddaiConfigCompat:
|
||||
"""Get TDDAI-compatible configuration.
|
||||
|
||||
This function can be used as a drop-in replacement for the original
|
||||
get_config() function in tddai/config.py.
|
||||
|
||||
Args:
|
||||
**kwargs: Override values for configuration fields
|
||||
|
||||
Returns:
|
||||
TDDAI-compatible configuration object
|
||||
"""
|
||||
return TddaiConfigCompat(**kwargs)
|
||||
|
||||
|
||||
def get_gitea_config(**kwargs) -> GiteaConfigCompat:
|
||||
"""Get Gitea-compatible configuration.
|
||||
|
||||
This function can be used as a drop-in replacement for the original
|
||||
GiteaConfig creation.
|
||||
|
||||
Args:
|
||||
**kwargs: Override values for configuration fields
|
||||
|
||||
Returns:
|
||||
Gitea-compatible configuration object
|
||||
"""
|
||||
return GiteaConfigCompat(**kwargs)
|
||||
73
config/exceptions.py
Normal file
73
config/exceptions.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Configuration-specific exceptions.
|
||||
|
||||
Provides a unified exception hierarchy for all configuration-related errors
|
||||
across the MarkiTect project.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Base exception for configuration-related errors.
|
||||
|
||||
Provides enhanced error context and troubleshooting guidance.
|
||||
|
||||
Args:
|
||||
message: Human-readable error description
|
||||
field: Specific configuration field that caused the error
|
||||
value: The invalid value (if applicable)
|
||||
suggestion: Suggested fix for the error
|
||||
context: Additional context information
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
message: str,
|
||||
field: Optional[str] = None,
|
||||
value: Optional[Any] = None,
|
||||
suggestion: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(message)
|
||||
self.field = field
|
||||
self.value = value
|
||||
self.suggestion = suggestion
|
||||
self.context = context or {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Enhanced string representation with troubleshooting guidance."""
|
||||
parts = [super().__str__()]
|
||||
|
||||
if self.field:
|
||||
parts.append(f"Field: {self.field}")
|
||||
|
||||
if self.value is not None:
|
||||
parts.append(f"Value: {self.value}")
|
||||
|
||||
if self.suggestion:
|
||||
parts.append(f"Suggestion: {self.suggestion}")
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
class ConfigValidationError(ConfigurationError):
|
||||
"""Configuration validation specific errors.
|
||||
|
||||
Raised when configuration values fail validation checks.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigFileError(ConfigurationError):
|
||||
"""Configuration file related errors.
|
||||
|
||||
Raised when configuration files cannot be read, parsed, or found.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EnvironmentVariableError(ConfigurationError):
|
||||
"""Environment variable related errors.
|
||||
|
||||
Raised when required environment variables are missing or invalid.
|
||||
"""
|
||||
pass
|
||||
217
config/loaders.py
Normal file
217
config/loaders.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Configuration loading utilities.
|
||||
|
||||
Provides unified utilities for loading configuration from files,
|
||||
environment variables, and other sources.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Union
|
||||
|
||||
from .exceptions import ConfigFileError, EnvironmentVariableError
|
||||
|
||||
|
||||
def load_env_file(file_path: Union[str, Path],
|
||||
required: bool = False) -> Dict[str, str]:
|
||||
"""Load environment variables from a file.
|
||||
|
||||
Consolidates the duplicate load_dotenv_file() functions found in
|
||||
tddai/config.py and gitea/config.py.
|
||||
|
||||
Args:
|
||||
file_path: Path to the environment file
|
||||
required: Whether the file must exist
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variables loaded from file
|
||||
|
||||
Raises:
|
||||
ConfigFileError: If required file is missing or cannot be parsed
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
if required:
|
||||
raise ConfigFileError(
|
||||
f"Required configuration file not found: {file_path}",
|
||||
suggestion=f"Create {file_path} or use environment variables"
|
||||
)
|
||||
return {}
|
||||
|
||||
try:
|
||||
env_vars = {}
|
||||
with file_path.open('r') as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Parse KEY=VALUE format
|
||||
if '=' not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
# Remove quotes if present
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
elif value.startswith("'") and value.endswith("'"):
|
||||
value = value[1:-1]
|
||||
|
||||
env_vars[key] = value
|
||||
|
||||
return env_vars
|
||||
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise ConfigFileError(
|
||||
f"Failed to read configuration file: {file_path}",
|
||||
context={'error': str(e), 'line': line_num if 'line_num' in locals() else None}
|
||||
) from e
|
||||
|
||||
|
||||
def resolve_path(path: Union[str, Path],
|
||||
base_dir: Optional[Union[str, Path]] = None,
|
||||
create_parents: bool = False) -> Path:
|
||||
"""Resolve and normalize a file path.
|
||||
|
||||
Handles tilde expansion, relative paths, and optionally creates parent directories.
|
||||
|
||||
Args:
|
||||
path: Path to resolve
|
||||
base_dir: Base directory for relative paths (defaults to current working directory)
|
||||
create_parents: Whether to create parent directories if they don't exist
|
||||
|
||||
Returns:
|
||||
Resolved absolute Path object
|
||||
|
||||
Raises:
|
||||
ConfigFileError: If path resolution fails or parent creation fails
|
||||
"""
|
||||
try:
|
||||
# Convert to Path object
|
||||
path = Path(path)
|
||||
|
||||
# Expand user home directory (~)
|
||||
if str(path).startswith('~'):
|
||||
path = path.expanduser()
|
||||
|
||||
# Handle relative paths
|
||||
if not path.is_absolute():
|
||||
if base_dir:
|
||||
base_dir = Path(base_dir).expanduser().resolve()
|
||||
path = base_dir / path
|
||||
else:
|
||||
path = Path.cwd() / path
|
||||
|
||||
# Resolve to absolute path
|
||||
path = path.resolve()
|
||||
|
||||
# Create parent directories if requested
|
||||
if create_parents and not path.parent.exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return path
|
||||
|
||||
except (OSError, RuntimeError) as e:
|
||||
raise ConfigFileError(
|
||||
f"Failed to resolve path: {path}",
|
||||
context={'error': str(e), 'base_dir': base_dir}
|
||||
) from e
|
||||
|
||||
|
||||
def get_env_var(key: str,
|
||||
default: Optional[str] = None,
|
||||
required: bool = False,
|
||||
var_type: type = str) -> Any:
|
||||
"""Get environment variable with type conversion and validation.
|
||||
|
||||
Args:
|
||||
key: Environment variable name
|
||||
default: Default value if not found
|
||||
required: Whether the variable is required
|
||||
var_type: Type to convert the value to (str, int, bool, Path)
|
||||
|
||||
Returns:
|
||||
Environment variable value converted to specified type
|
||||
|
||||
Raises:
|
||||
EnvironmentVariableError: If required variable is missing or conversion fails
|
||||
"""
|
||||
value = os.getenv(key)
|
||||
|
||||
if value is None:
|
||||
if required:
|
||||
raise EnvironmentVariableError(
|
||||
f"Required environment variable not set: {key}",
|
||||
field=key,
|
||||
suggestion=f"Set {key} environment variable or provide default in config file"
|
||||
)
|
||||
return default
|
||||
|
||||
# Type conversion
|
||||
try:
|
||||
if var_type == bool:
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif var_type == int:
|
||||
return int(value)
|
||||
elif var_type == float:
|
||||
return float(value)
|
||||
elif var_type == Path:
|
||||
return resolve_path(value)
|
||||
else:
|
||||
return var_type(value)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
raise EnvironmentVariableError(
|
||||
f"Invalid value for environment variable {key}: {value}",
|
||||
field=key,
|
||||
value=value,
|
||||
suggestion=f"Provide a valid {var_type.__name__} value for {key}"
|
||||
) from e
|
||||
|
||||
|
||||
def load_json_config(file_path: Union[str, Path],
|
||||
required: bool = False) -> Dict[str, Any]:
|
||||
"""Load configuration from JSON file.
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON configuration file
|
||||
required: Whether the file must exist
|
||||
|
||||
Returns:
|
||||
Configuration dictionary loaded from JSON
|
||||
|
||||
Raises:
|
||||
ConfigFileError: If file cannot be read or parsed
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
if required:
|
||||
raise ConfigFileError(
|
||||
f"Required JSON configuration file not found: {file_path}",
|
||||
suggestion=f"Create {file_path} with valid JSON configuration"
|
||||
)
|
||||
return {}
|
||||
|
||||
try:
|
||||
with file_path.open('r') as f:
|
||||
return json.load(f)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigFileError(
|
||||
f"Invalid JSON in configuration file: {file_path}",
|
||||
context={'error': str(e), 'line': e.lineno, 'column': e.colno}
|
||||
) from e
|
||||
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise ConfigFileError(
|
||||
f"Failed to read JSON configuration file: {file_path}",
|
||||
context={'error': str(e)}
|
||||
) from e
|
||||
239
config/manager.py
Normal file
239
config/manager.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Unified configuration manager.
|
||||
|
||||
Provides centralized access to all configuration across the MarkiTect project.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
|
||||
from .base import BaseConfig
|
||||
from .exceptions import ConfigurationError
|
||||
from .loaders import resolve_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkitectConfig(BaseConfig):
|
||||
"""Main MarkiTect configuration.
|
||||
|
||||
Consolidates configuration for all MarkiTect components with standardized
|
||||
MARKITECT_ environment variable prefix.
|
||||
"""
|
||||
|
||||
# Repository settings
|
||||
gitea_url: str = "http://localhost:3000"
|
||||
repo_owner: str = ""
|
||||
repo_name: str = ""
|
||||
api_token: str = ""
|
||||
|
||||
# Workspace and directory settings
|
||||
workspace_dir: Path = field(default_factory=lambda: Path(".markitect_workspace"))
|
||||
database_path: Path = field(default_factory=lambda: Path.home() / ".markitect" / "markitect.db")
|
||||
cache_dir: Path = field(default_factory=lambda: Path(".ast_cache"))
|
||||
|
||||
# Test settings
|
||||
tests_dir: Path = field(default_factory=lambda: Path("tests"))
|
||||
test_file_pattern: str = "test_issue_{issue_num}_{scenario}.py"
|
||||
|
||||
# AI and command settings
|
||||
claude_code_command: str = "claude"
|
||||
|
||||
# Legacy compatibility
|
||||
_legacy_loaded: bool = field(default=False, init=False)
|
||||
|
||||
def get_env_prefix(self) -> str:
|
||||
"""Get environment variable prefix."""
|
||||
return "MARKITECT_"
|
||||
|
||||
def get_config_files(self) -> List[Union[str, Path]]:
|
||||
"""Get configuration files to load."""
|
||||
return [
|
||||
".markitect.env", # New unified config
|
||||
".env.markitect", # Alternative naming
|
||||
".env.tddai", # Legacy TDDAI config
|
||||
".env" # General environment file
|
||||
]
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration."""
|
||||
# Always ensure legacy environment variables are loaded before validation
|
||||
self.load_legacy_environment()
|
||||
self._legacy_loaded = True
|
||||
|
||||
# Validate required fields
|
||||
self.validate_required_fields('gitea_url', 'repo_owner', 'repo_name')
|
||||
|
||||
# Validate URLs
|
||||
self.validate_url('gitea_url')
|
||||
|
||||
# Ensure directories exist or can be created
|
||||
self.validate_path_exists('workspace_dir', create_if_missing=True)
|
||||
self.validate_path_exists('cache_dir', create_if_missing=True)
|
||||
self.validate_path_exists('tests_dir', create_if_missing=True)
|
||||
|
||||
# Ensure database directory exists
|
||||
if self.database_path:
|
||||
self.validate_path_exists(
|
||||
str(self.database_path.parent),
|
||||
create_if_missing=True
|
||||
)
|
||||
|
||||
def get_legacy_mapping(self) -> Dict[str, str]:
|
||||
"""Get legacy environment variable mapping."""
|
||||
return {
|
||||
# TDDAI legacy variables
|
||||
'TDDAI_GITEA_URL': 'gitea_url',
|
||||
'TDDAI_REPO_OWNER': 'repo_owner',
|
||||
'TDDAI_REPO_NAME': 'repo_name',
|
||||
'TDDAI_WORKSPACE_DIR': 'workspace_dir',
|
||||
'TDDAI_TESTS_DIR': 'tests_dir',
|
||||
'TDDAI_TEST_FILE_PATTERN': 'test_file_pattern',
|
||||
'TDDAI_CLAUDE_CODE_COMMAND': 'claude_code_command',
|
||||
|
||||
# Gitea legacy variables
|
||||
'GITEA_API_TOKEN': 'api_token',
|
||||
'GITEA_BASE_URL': 'gitea_url',
|
||||
'GITEA_REPO_OWNER': 'repo_owner',
|
||||
'GITEA_REPO_NAME': 'repo_name',
|
||||
|
||||
# Cache legacy variables
|
||||
'XDG_CACHE_HOME': 'cache_dir',
|
||||
}
|
||||
|
||||
def get_tddai_compatible_dict(self) -> Dict[str, Any]:
|
||||
"""Get configuration in TDDAI-compatible format.
|
||||
|
||||
Returns configuration that can be used to create TddaiConfig
|
||||
objects for backward compatibility.
|
||||
"""
|
||||
return {
|
||||
'workspace_dir': self.workspace_dir,
|
||||
'gitea_url': self.gitea_url,
|
||||
'repo_owner': self.repo_owner,
|
||||
'repo_name': self.repo_name,
|
||||
'tests_dir': self.tests_dir,
|
||||
'test_file_pattern': self.test_file_pattern,
|
||||
'claude_code_command': self.claude_code_command
|
||||
}
|
||||
|
||||
def get_gitea_compatible_dict(self) -> Dict[str, Any]:
|
||||
"""Get configuration in Gitea-compatible format.
|
||||
|
||||
Returns configuration that can be used to create GiteaConfig
|
||||
objects for backward compatibility.
|
||||
"""
|
||||
return {
|
||||
'base_url': self.gitea_url,
|
||||
'repo_owner': self.repo_owner,
|
||||
'repo_name': self.repo_name,
|
||||
'auth_token': self.api_token
|
||||
}
|
||||
|
||||
|
||||
class UnifiedConfigManager:
|
||||
"""Centralized configuration manager.
|
||||
|
||||
Provides singleton access to unified configuration and manages
|
||||
backward compatibility with existing configuration systems.
|
||||
"""
|
||||
|
||||
_instance: Optional['UnifiedConfigManager'] = None
|
||||
_config: Optional[MarkitectConfig] = None
|
||||
|
||||
def __new__(cls) -> 'UnifiedConfigManager':
|
||||
"""Ensure singleton instance."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize configuration manager."""
|
||||
if self._config is None:
|
||||
self._config = MarkitectConfig()
|
||||
|
||||
@property
|
||||
def config(self) -> MarkitectConfig:
|
||||
"""Get the unified configuration."""
|
||||
if self._config is None:
|
||||
self._config = MarkitectConfig()
|
||||
return self._config
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload configuration from environment and files."""
|
||||
self._config = MarkitectConfig()
|
||||
|
||||
def validate_all(self) -> None:
|
||||
"""Validate all configuration."""
|
||||
self.config.validate()
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get configuration status for debugging.
|
||||
|
||||
Returns:
|
||||
Dictionary with configuration status information
|
||||
"""
|
||||
try:
|
||||
self.validate_all()
|
||||
valid = True
|
||||
errors = []
|
||||
except ConfigurationError as e:
|
||||
valid = False
|
||||
errors = [str(e)]
|
||||
|
||||
return {
|
||||
'valid': valid,
|
||||
'errors': errors,
|
||||
'config_files_found': [
|
||||
str(f) for f in self.config.get_config_files()
|
||||
if Path(f).exists()
|
||||
],
|
||||
'environment_variables': {
|
||||
key: '***' if 'token' in key.lower() or 'password' in key.lower() else value
|
||||
for key, value in os.environ.items()
|
||||
if key.startswith('MARKITECT_') or key.startswith('TDDAI_') or key.startswith('GITEA_')
|
||||
},
|
||||
'resolved_paths': {
|
||||
'workspace_dir': str(self.config.workspace_dir),
|
||||
'database_path': str(self.config.database_path),
|
||||
'cache_dir': str(self.config.cache_dir),
|
||||
'tests_dir': str(self.config.tests_dir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Global configuration manager instance
|
||||
_manager: Optional[UnifiedConfigManager] = None
|
||||
|
||||
|
||||
def get_unified_config() -> MarkitectConfig:
|
||||
"""Get the unified configuration instance.
|
||||
|
||||
Returns:
|
||||
The global MarkitectConfig instance
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If configuration is invalid
|
||||
"""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = UnifiedConfigManager()
|
||||
return _manager.config
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
"""Reload configuration from environment and files."""
|
||||
global _manager
|
||||
if _manager is not None:
|
||||
_manager.reload()
|
||||
else:
|
||||
_manager = UnifiedConfigManager()
|
||||
|
||||
|
||||
def get_config_status() -> Dict[str, Any]:
|
||||
"""Get configuration status for debugging."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = UnifiedConfigManager()
|
||||
return _manager.get_status()
|
||||
6
domain/__init__.py
Normal file
6
domain/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Domain layer for MarkiTect project.
|
||||
|
||||
This package contains the core business logic and domain models,
|
||||
implementing clean architecture principles with no infrastructure dependencies.
|
||||
"""
|
||||
20
domain/issues/__init__.py
Normal file
20
domain/issues/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Issue domain module.
|
||||
|
||||
Contains domain models, services, and interfaces for issue management.
|
||||
"""
|
||||
|
||||
from .models import Issue, Label, IssueState, LabelCategories
|
||||
from .services import IssueStatusService, IssueValidationService
|
||||
from .exceptions import IssueDomainError, IssueValidationError
|
||||
|
||||
__all__ = [
|
||||
'Issue',
|
||||
'Label',
|
||||
'IssueState',
|
||||
'LabelCategories',
|
||||
'IssueStatusService',
|
||||
'IssueValidationService',
|
||||
'IssueDomainError',
|
||||
'IssueValidationError'
|
||||
]
|
||||
44
domain/issues/exceptions.py
Normal file
44
domain/issues/exceptions.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Domain-specific exceptions for issue management.
|
||||
"""
|
||||
|
||||
|
||||
class IssueDomainError(Exception):
|
||||
"""Base exception for issue domain errors."""
|
||||
|
||||
def __init__(self, message: str, issue_number: int = None):
|
||||
super().__init__(message)
|
||||
self.issue_number = issue_number
|
||||
|
||||
|
||||
class IssueValidationError(IssueDomainError):
|
||||
"""Exception raised when issue validation fails."""
|
||||
|
||||
def __init__(self, message: str, field: str = None, value=None):
|
||||
super().__init__(message)
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
|
||||
class IssueStateError(IssueDomainError):
|
||||
"""Exception raised when invalid state transitions are attempted."""
|
||||
|
||||
def __init__(self, message: str, current_state: str, attempted_state: str):
|
||||
super().__init__(message)
|
||||
self.current_state = current_state
|
||||
self.attempted_state = attempted_state
|
||||
|
||||
|
||||
class IssueNotFoundError(IssueDomainError):
|
||||
"""Exception raised when an issue cannot be found."""
|
||||
|
||||
def __init__(self, message: str, issue_number: int = None):
|
||||
super().__init__(message, issue_number)
|
||||
|
||||
|
||||
class IssueLabelError(IssueDomainError):
|
||||
"""Exception raised when there are label-related issues."""
|
||||
|
||||
def __init__(self, message: str, label_name: str = None):
|
||||
super().__init__(message)
|
||||
self.label_name = label_name
|
||||
116
domain/issues/models.py
Normal file
116
domain/issues/models.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Issue domain models.
|
||||
|
||||
Contains core business entities and value objects for issue management.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from .exceptions import IssueStateError
|
||||
|
||||
|
||||
class IssueState(Enum):
|
||||
"""Issue state enumeration."""
|
||||
OPEN = "open"
|
||||
CLOSED = "closed"
|
||||
IN_PROGRESS = "in_progress"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Label:
|
||||
"""Value object representing an issue label."""
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
def is_state_label(self) -> bool:
|
||||
"""Check if this is a state-related label."""
|
||||
return self.name.startswith('status:')
|
||||
|
||||
def is_priority_label(self) -> bool:
|
||||
"""Check if this is a priority-related label."""
|
||||
return self.name.startswith('priority:')
|
||||
|
||||
def is_type_label(self) -> bool:
|
||||
"""Check if this is a type-related label."""
|
||||
return self.name in ['bug', 'enhancement', 'feature', 'documentation']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LabelCategories:
|
||||
"""Value object for categorized labels."""
|
||||
state_labels: List[str]
|
||||
priority_labels: List[str]
|
||||
type_labels: List[str]
|
||||
other_labels: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
"""Issue aggregate root."""
|
||||
number: int
|
||||
title: str
|
||||
state: IssueState
|
||||
labels: List[Label]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
milestone: Optional[str] = None
|
||||
assignee: Optional[str] = None
|
||||
closed_at: Optional[datetime] = None
|
||||
|
||||
def categorize_labels(self) -> LabelCategories:
|
||||
"""Categorize labels by type - pure domain logic."""
|
||||
state_labels = [label.name for label in self.labels if label.is_state_label()]
|
||||
priority_labels = [label.name for label in self.labels if label.is_priority_label()]
|
||||
type_labels = [label.name for label in self.labels if label.is_type_label()]
|
||||
other_labels = [
|
||||
label.name for label in self.labels
|
||||
if not (label.is_state_label() or label.is_priority_label() or label.is_type_label())
|
||||
]
|
||||
|
||||
return LabelCategories(
|
||||
state_labels=state_labels,
|
||||
priority_labels=priority_labels,
|
||||
type_labels=type_labels,
|
||||
other_labels=other_labels
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the issue - domain business rule."""
|
||||
if self.state == IssueState.CLOSED:
|
||||
raise IssueStateError(
|
||||
"Issue is already closed",
|
||||
current_state=self.state.value,
|
||||
attempted_state=IssueState.CLOSED.value
|
||||
)
|
||||
|
||||
self.state = IssueState.CLOSED
|
||||
self.closed_at = datetime.utcnow()
|
||||
|
||||
def reopen(self) -> None:
|
||||
"""Reopen the issue - domain business rule."""
|
||||
if self.state != IssueState.CLOSED:
|
||||
raise IssueStateError(
|
||||
"Issue is not closed",
|
||||
current_state=self.state.value,
|
||||
attempted_state=IssueState.OPEN.value
|
||||
)
|
||||
|
||||
self.state = IssueState.OPEN
|
||||
self.closed_at = None
|
||||
|
||||
def add_label(self, label: Label) -> None:
|
||||
"""Add a label to the issue."""
|
||||
if label not in self.labels:
|
||||
self.labels.append(label)
|
||||
|
||||
def remove_label(self, label_name: str) -> None:
|
||||
"""Remove a label from the issue."""
|
||||
self.labels = [label for label in self.labels if label.name != label_name]
|
||||
|
||||
def has_label(self, label_name: str) -> bool:
|
||||
"""Check if issue has a specific label."""
|
||||
return any(label.name == label_name for label in self.labels)
|
||||
116
domain/issues/repositories.py
Normal file
116
domain/issues/repositories.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Repository interfaces for issue domain.
|
||||
|
||||
Defines contracts for data access without infrastructure dependencies.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any
|
||||
from .models import Issue
|
||||
|
||||
|
||||
class IssueRepository(ABC):
|
||||
"""Repository interface for issue persistence."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_issue(self, issue_number: int) -> Issue:
|
||||
"""Retrieve issue by number.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number to retrieve
|
||||
|
||||
Returns:
|
||||
Issue domain object
|
||||
|
||||
Raises:
|
||||
IssueNotFoundError: If issue doesn't exist
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_issues(self, state: Optional[str] = None, limit: Optional[int] = None) -> List[Issue]:
|
||||
"""List issues, optionally filtered by state.
|
||||
|
||||
Args:
|
||||
state: Optional state filter (open, closed)
|
||||
limit: Optional limit on number of results
|
||||
|
||||
Returns:
|
||||
List of Issue domain objects
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def save_issue(self, issue: Issue) -> None:
|
||||
"""Save issue changes.
|
||||
|
||||
Args:
|
||||
issue: Issue domain object to save
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_issue(self, title: str, description: str, labels: List[str]) -> Issue:
|
||||
"""Create a new issue.
|
||||
|
||||
Args:
|
||||
title: Issue title
|
||||
description: Issue description
|
||||
labels: List of label names
|
||||
|
||||
Returns:
|
||||
Created Issue domain object
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete_issue(self, issue_number: int) -> None:
|
||||
"""Delete an issue.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number to delete
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectRepository(ABC):
|
||||
"""Repository interface for project information."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_issue_project_info(self, issue_number: int) -> Dict[str, Any]:
|
||||
"""Get project information for an issue.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number
|
||||
|
||||
Returns:
|
||||
Dictionary containing project context information
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_kanban_columns(self) -> List[str]:
|
||||
"""Get available kanban columns for the project.
|
||||
|
||||
Returns:
|
||||
List of kanban column names
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_project_labels(self) -> List[Dict[str, Any]]:
|
||||
"""Get available labels for the project.
|
||||
|
||||
Returns:
|
||||
List of label definitions
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_milestones(self) -> List[Dict[str, Any]]:
|
||||
"""Get available milestones for the project.
|
||||
|
||||
Returns:
|
||||
List of milestone information
|
||||
"""
|
||||
pass
|
||||
173
domain/issues/services.py
Normal file
173
domain/issues/services.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Issue domain services.
|
||||
|
||||
Contains business logic for issue-related operations.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from .models import Issue, IssueState, LabelCategories
|
||||
from .exceptions import IssueValidationError
|
||||
|
||||
|
||||
class IssueStatusService:
|
||||
"""Domain service for issue status-related business logic."""
|
||||
|
||||
def determine_kanban_column(self, issue: Issue, project_info: Dict[str, Any]) -> str:
|
||||
"""Determine kanban column based on issue state and labels."""
|
||||
# Pure business logic - no infrastructure dependencies
|
||||
label_categories = issue.categorize_labels()
|
||||
|
||||
# Business rules for kanban column determination
|
||||
if issue.state == IssueState.CLOSED:
|
||||
return "Done"
|
||||
|
||||
# Check for explicit status labels
|
||||
for state_label in label_categories.state_labels:
|
||||
if state_label == "status:in-progress":
|
||||
return "In Progress"
|
||||
elif state_label == "status:review":
|
||||
return "Review"
|
||||
elif state_label == "status:blocked":
|
||||
return "Blocked"
|
||||
elif state_label == "status:ready":
|
||||
return "Ready"
|
||||
|
||||
# Default for open issues without explicit status
|
||||
return "Todo"
|
||||
|
||||
def extract_priority_info(self, issue: Issue) -> Dict[str, Any]:
|
||||
"""Extract priority information from issue labels."""
|
||||
label_categories = issue.categorize_labels()
|
||||
|
||||
priority_mapping = {
|
||||
"priority:low": "Low",
|
||||
"priority:medium": "Medium",
|
||||
"priority:high": "High",
|
||||
"priority:critical": "Critical"
|
||||
}
|
||||
|
||||
for priority_label in label_categories.priority_labels:
|
||||
if priority_label in priority_mapping:
|
||||
return {
|
||||
"level": priority_mapping[priority_label],
|
||||
"label": priority_label
|
||||
}
|
||||
|
||||
# Default priority
|
||||
return {"level": "Medium", "label": None}
|
||||
|
||||
def extract_state_info(self, issue: Issue) -> Dict[str, Any]:
|
||||
"""Extract state information from issue labels and state."""
|
||||
label_categories = issue.categorize_labels()
|
||||
|
||||
return {
|
||||
"state": issue.state.value,
|
||||
"state_labels": label_categories.state_labels,
|
||||
"is_closed": issue.state == IssueState.CLOSED,
|
||||
"closed_at": issue.closed_at.isoformat() if issue.closed_at else None
|
||||
}
|
||||
|
||||
def calculate_issue_age_days(self, issue: Issue) -> int:
|
||||
"""Calculate issue age in days."""
|
||||
from datetime import datetime
|
||||
return (datetime.utcnow() - issue.created_at).days
|
||||
|
||||
def is_stale_issue(self, issue: Issue, stale_threshold_days: int = 30) -> bool:
|
||||
"""Determine if issue is considered stale based on business rules."""
|
||||
if issue.state == IssueState.CLOSED:
|
||||
return False
|
||||
|
||||
age_days = self.calculate_issue_age_days(issue)
|
||||
return age_days > stale_threshold_days
|
||||
|
||||
|
||||
class IssueValidationService:
|
||||
"""Domain service for issue validation business rules."""
|
||||
|
||||
def validate_issue_creation(self, title: str, labels: List[str]) -> None:
|
||||
"""Validate issue creation according to business rules."""
|
||||
if not title or not title.strip():
|
||||
raise IssueValidationError(
|
||||
"Issue title cannot be empty",
|
||||
field="title",
|
||||
value=title
|
||||
)
|
||||
|
||||
if len(title) > 255:
|
||||
raise IssueValidationError(
|
||||
"Issue title cannot exceed 255 characters",
|
||||
field="title",
|
||||
value=title
|
||||
)
|
||||
|
||||
# Business rule: Cannot have conflicting priority labels
|
||||
priority_labels = [label for label in labels if label.startswith("priority:")]
|
||||
if len(priority_labels) > 1:
|
||||
raise IssueValidationError(
|
||||
"Issue cannot have multiple priority labels",
|
||||
field="labels",
|
||||
value=priority_labels
|
||||
)
|
||||
|
||||
# Business rule: Cannot have conflicting state labels
|
||||
state_labels = [label for label in labels if label.startswith("status:")]
|
||||
if len(state_labels) > 1:
|
||||
raise IssueValidationError(
|
||||
"Issue cannot have multiple state labels",
|
||||
field="labels",
|
||||
value=state_labels
|
||||
)
|
||||
|
||||
def validate_title_update(self, new_title: str) -> None:
|
||||
"""Validate issue title update."""
|
||||
if not new_title or not new_title.strip():
|
||||
raise IssueValidationError(
|
||||
"Issue title cannot be empty",
|
||||
field="title",
|
||||
value=new_title
|
||||
)
|
||||
|
||||
if len(new_title) > 255:
|
||||
raise IssueValidationError(
|
||||
"Issue title cannot exceed 255 characters",
|
||||
field="title",
|
||||
value=new_title
|
||||
)
|
||||
|
||||
def validate_label_addition(self, issue: Issue, new_label: str) -> None:
|
||||
"""Validate adding a label to an issue."""
|
||||
# Business rule: Cannot add duplicate labels
|
||||
if issue.has_label(new_label):
|
||||
raise IssueValidationError(
|
||||
f"Issue already has label '{new_label}'",
|
||||
field="labels",
|
||||
value=new_label
|
||||
)
|
||||
|
||||
# Business rule: Cannot add conflicting priority labels
|
||||
if new_label.startswith("priority:"):
|
||||
existing_priority_labels = [
|
||||
label.name for label in issue.labels
|
||||
if label.is_priority_label()
|
||||
]
|
||||
if existing_priority_labels:
|
||||
raise IssueValidationError(
|
||||
f"Issue already has priority label '{existing_priority_labels[0]}'. "
|
||||
f"Cannot add '{new_label}'",
|
||||
field="labels",
|
||||
value=new_label
|
||||
)
|
||||
|
||||
# Business rule: Cannot add conflicting state labels
|
||||
if new_label.startswith("status:"):
|
||||
existing_state_labels = [
|
||||
label.name for label in issue.labels
|
||||
if label.is_state_label()
|
||||
]
|
||||
if existing_state_labels:
|
||||
raise IssueValidationError(
|
||||
f"Issue already has state label '{existing_state_labels[0]}'. "
|
||||
f"Cannot add '{new_label}'",
|
||||
field="labels",
|
||||
value=new_label
|
||||
)
|
||||
18
domain/projects/__init__.py
Normal file
18
domain/projects/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Project domain module.
|
||||
|
||||
Contains domain models, services, and interfaces for project management.
|
||||
"""
|
||||
|
||||
from .models import Project, Milestone, ProjectState
|
||||
from .services import ProjectManagementService
|
||||
from .exceptions import ProjectDomainError, ProjectValidationError
|
||||
|
||||
__all__ = [
|
||||
'Project',
|
||||
'Milestone',
|
||||
'ProjectState',
|
||||
'ProjectManagementService',
|
||||
'ProjectDomainError',
|
||||
'ProjectValidationError'
|
||||
]
|
||||
28
domain/projects/exceptions.py
Normal file
28
domain/projects/exceptions.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Domain-specific exceptions for project management.
|
||||
"""
|
||||
|
||||
|
||||
class ProjectDomainError(Exception):
|
||||
"""Base exception for project domain errors."""
|
||||
|
||||
def __init__(self, message: str, project_name: str = None):
|
||||
super().__init__(message)
|
||||
self.project_name = project_name
|
||||
|
||||
|
||||
class ProjectValidationError(ProjectDomainError):
|
||||
"""Exception raised when project validation fails."""
|
||||
|
||||
def __init__(self, message: str, field: str = None, value=None):
|
||||
super().__init__(message)
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
|
||||
class MilestoneError(ProjectDomainError):
|
||||
"""Exception raised when milestone operations fail."""
|
||||
|
||||
def __init__(self, message: str, milestone_id: int = None):
|
||||
super().__init__(message)
|
||||
self.milestone_id = milestone_id
|
||||
162
domain/projects/models.py
Normal file
162
domain/projects/models.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Project domain models.
|
||||
|
||||
Contains core business entities and value objects for project management.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from .exceptions import MilestoneError
|
||||
|
||||
|
||||
class ProjectState(Enum):
|
||||
"""Project state enumeration."""
|
||||
ACTIVE = "active"
|
||||
ARCHIVED = "archived"
|
||||
PLANNING = "planning"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Milestone:
|
||||
"""Milestone entity."""
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str]
|
||||
due_date: Optional[datetime]
|
||||
state: str
|
||||
open_issues: int
|
||||
closed_issues: int
|
||||
|
||||
@property
|
||||
def completion_percentage(self) -> float:
|
||||
"""Calculate milestone completion percentage."""
|
||||
total_issues = self.open_issues + self.closed_issues
|
||||
if total_issues == 0:
|
||||
return 0.0
|
||||
return (self.closed_issues / total_issues) * 100
|
||||
|
||||
@property
|
||||
def total_issues(self) -> int:
|
||||
"""Get total number of issues in milestone."""
|
||||
return self.open_issues + self.closed_issues
|
||||
|
||||
def is_overdue(self) -> bool:
|
||||
"""Check if milestone is overdue."""
|
||||
if not self.due_date or self.state == "closed":
|
||||
return False
|
||||
return datetime.utcnow() > self.due_date
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if milestone is completed."""
|
||||
return self.state == "closed" or (self.total_issues > 0 and self.completion_percentage >= 100.0)
|
||||
|
||||
def add_issue(self) -> None:
|
||||
"""Add an open issue to the milestone."""
|
||||
self.open_issues += 1
|
||||
|
||||
def close_issue(self) -> None:
|
||||
"""Close an issue in the milestone."""
|
||||
if self.open_issues <= 0:
|
||||
raise MilestoneError(
|
||||
f"Cannot close issue in milestone '{self.title}': no open issues",
|
||||
milestone_id=self.id
|
||||
)
|
||||
self.open_issues -= 1
|
||||
self.closed_issues += 1
|
||||
|
||||
def reopen_issue(self) -> None:
|
||||
"""Reopen an issue in the milestone."""
|
||||
if self.closed_issues <= 0:
|
||||
raise MilestoneError(
|
||||
f"Cannot reopen issue in milestone '{self.title}': no closed issues",
|
||||
milestone_id=self.id
|
||||
)
|
||||
self.closed_issues -= 1
|
||||
self.open_issues += 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
"""Project aggregate root."""
|
||||
name: str
|
||||
description: str
|
||||
state: ProjectState
|
||||
milestones: List[Milestone]
|
||||
kanban_columns: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
archived_at: Optional[datetime] = None
|
||||
|
||||
def get_active_milestones(self) -> List[Milestone]:
|
||||
"""Get milestones that are currently active."""
|
||||
return [milestone for milestone in self.milestones if milestone.state == "open"]
|
||||
|
||||
def get_completed_milestones(self) -> List[Milestone]:
|
||||
"""Get milestones that are completed."""
|
||||
return [milestone for milestone in self.milestones if milestone.is_completed()]
|
||||
|
||||
def get_overdue_milestones(self) -> List[Milestone]:
|
||||
"""Get milestones that are overdue."""
|
||||
return [milestone for milestone in self.milestones if milestone.is_overdue()]
|
||||
|
||||
def calculate_overall_progress(self) -> float:
|
||||
"""Calculate overall project progress based on milestones."""
|
||||
if not self.milestones:
|
||||
return 0.0
|
||||
|
||||
total_completion = sum(milestone.completion_percentage for milestone in self.milestones)
|
||||
return total_completion / len(self.milestones)
|
||||
|
||||
def get_total_issues(self) -> int:
|
||||
"""Get total number of issues across all milestones."""
|
||||
return sum(milestone.total_issues for milestone in self.milestones)
|
||||
|
||||
def get_total_open_issues(self) -> int:
|
||||
"""Get total number of open issues across all milestones."""
|
||||
return sum(milestone.open_issues for milestone in self.milestones)
|
||||
|
||||
def get_total_closed_issues(self) -> int:
|
||||
"""Get total number of closed issues across all milestones."""
|
||||
return sum(milestone.closed_issues for milestone in self.milestones)
|
||||
|
||||
def archive(self) -> None:
|
||||
"""Archive the project."""
|
||||
if self.state == ProjectState.ARCHIVED:
|
||||
return # Already archived
|
||||
|
||||
self.state = ProjectState.ARCHIVED
|
||||
self.archived_at = datetime.utcnow()
|
||||
|
||||
def activate(self) -> None:
|
||||
"""Activate the project."""
|
||||
if self.state == ProjectState.ACTIVE:
|
||||
return # Already active
|
||||
|
||||
self.state = ProjectState.ACTIVE
|
||||
self.archived_at = None
|
||||
|
||||
def add_milestone(self, milestone: Milestone) -> None:
|
||||
"""Add a milestone to the project."""
|
||||
# Check for duplicate milestone IDs
|
||||
if any(m.id == milestone.id for m in self.milestones):
|
||||
raise ValueError(f"Milestone with ID {milestone.id} already exists")
|
||||
|
||||
self.milestones.append(milestone)
|
||||
|
||||
def remove_milestone(self, milestone_id: int) -> None:
|
||||
"""Remove a milestone from the project."""
|
||||
original_count = len(self.milestones)
|
||||
self.milestones = [m for m in self.milestones if m.id != milestone_id]
|
||||
|
||||
if len(self.milestones) == original_count:
|
||||
raise ValueError(f"Milestone with ID {milestone_id} not found")
|
||||
|
||||
def get_milestone(self, milestone_id: int) -> Optional[Milestone]:
|
||||
"""Get a milestone by ID."""
|
||||
for milestone in self.milestones:
|
||||
if milestone.id == milestone_id:
|
||||
return milestone
|
||||
return None
|
||||
189
domain/projects/services.py
Normal file
189
domain/projects/services.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Project domain services.
|
||||
|
||||
Contains business logic for project-related operations.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
from .models import Project, Milestone, ProjectState
|
||||
from .exceptions import ProjectValidationError
|
||||
|
||||
|
||||
class ProjectManagementService:
|
||||
"""Domain service for project management business logic."""
|
||||
|
||||
def determine_project_health(self, project: Project) -> str:
|
||||
"""Determine project health based on business rules."""
|
||||
progress = project.calculate_overall_progress()
|
||||
overdue_milestones = project.get_overdue_milestones()
|
||||
active_milestones = project.get_active_milestones()
|
||||
|
||||
# Business rules for project health assessment
|
||||
if project.state != ProjectState.ACTIVE:
|
||||
return "Inactive"
|
||||
|
||||
if progress >= 95:
|
||||
return "Excellent"
|
||||
elif progress >= 80:
|
||||
return "Good"
|
||||
elif progress >= 60:
|
||||
return "Fair"
|
||||
elif len(overdue_milestones) > 0:
|
||||
return "At Risk"
|
||||
elif len(active_milestones) == 0:
|
||||
return "Stalled"
|
||||
else:
|
||||
return "Needs Attention"
|
||||
|
||||
def calculate_project_velocity(self, project: Project, days_back: int = 30) -> float:
|
||||
"""Calculate project velocity based on recent milestone completions."""
|
||||
completed_milestones = project.get_completed_milestones()
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
|
||||
|
||||
# Count milestones completed in the specified period
|
||||
# Note: This would need milestone completion dates in a real implementation
|
||||
recent_completions = len(completed_milestones) # Simplified for now
|
||||
|
||||
return recent_completions / (days_back / 7) # Issues per week
|
||||
|
||||
def identify_bottlenecks(self, project: Project) -> List[str]:
|
||||
"""Identify potential bottlenecks in the project."""
|
||||
bottlenecks = []
|
||||
|
||||
# Check for overdue milestones
|
||||
overdue_milestones = project.get_overdue_milestones()
|
||||
if overdue_milestones:
|
||||
bottlenecks.append(f"Overdue milestones: {len(overdue_milestones)}")
|
||||
|
||||
# Check for milestones with too many open issues
|
||||
for milestone in project.get_active_milestones():
|
||||
if milestone.open_issues > 20: # Business rule: threshold for too many issues
|
||||
bottlenecks.append(f"Milestone '{milestone.title}' has {milestone.open_issues} open issues")
|
||||
|
||||
# Check for stalled milestones (no progress)
|
||||
for milestone in project.get_active_milestones():
|
||||
if milestone.total_issues > 0 and milestone.completion_percentage == 0:
|
||||
bottlenecks.append(f"Milestone '{milestone.title}' shows no progress")
|
||||
|
||||
return bottlenecks
|
||||
|
||||
def recommend_next_actions(self, project: Project) -> List[str]:
|
||||
"""Recommend next actions based on project state."""
|
||||
recommendations = []
|
||||
health = self.determine_project_health(project)
|
||||
|
||||
if health == "At Risk":
|
||||
overdue_milestones = project.get_overdue_milestones()
|
||||
recommendations.append(f"Address {len(overdue_milestones)} overdue milestone(s)")
|
||||
|
||||
if health == "Stalled":
|
||||
recommendations.append("Create new milestones or reactivate existing ones")
|
||||
|
||||
# Check for milestones nearing completion
|
||||
for milestone in project.get_active_milestones():
|
||||
if milestone.completion_percentage >= 80:
|
||||
recommendations.append(f"Focus on completing milestone '{milestone.title}' ({milestone.completion_percentage:.0f}% done)")
|
||||
|
||||
# Check for unbalanced workload
|
||||
total_open = project.get_total_open_issues()
|
||||
if total_open > 50: # Business rule: threshold for too many open issues
|
||||
recommendations.append(f"Consider breaking down work - {total_open} total open issues")
|
||||
|
||||
return recommendations
|
||||
|
||||
def validate_project_creation(self, name: str, description: str) -> None:
|
||||
"""Validate project creation according to business rules."""
|
||||
if not name or not name.strip():
|
||||
raise ProjectValidationError(
|
||||
"Project name cannot be empty",
|
||||
field="name",
|
||||
value=name
|
||||
)
|
||||
|
||||
if len(name) > 100:
|
||||
raise ProjectValidationError(
|
||||
"Project name cannot exceed 100 characters",
|
||||
field="name",
|
||||
value=name
|
||||
)
|
||||
|
||||
if description and len(description) > 1000:
|
||||
raise ProjectValidationError(
|
||||
"Project description cannot exceed 1000 characters",
|
||||
field="description",
|
||||
value=description
|
||||
)
|
||||
|
||||
def validate_milestone_creation(self, title: str, due_date: datetime = None) -> None:
|
||||
"""Validate milestone creation according to business rules."""
|
||||
if not title or not title.strip():
|
||||
raise ProjectValidationError(
|
||||
"Milestone title cannot be empty",
|
||||
field="title",
|
||||
value=title
|
||||
)
|
||||
|
||||
if len(title) > 100:
|
||||
raise ProjectValidationError(
|
||||
"Milestone title cannot exceed 100 characters",
|
||||
field="title",
|
||||
value=title
|
||||
)
|
||||
|
||||
# Business rule: Due date cannot be in the past
|
||||
if due_date and due_date < datetime.utcnow():
|
||||
raise ProjectValidationError(
|
||||
"Milestone due date cannot be in the past",
|
||||
field="due_date",
|
||||
value=due_date
|
||||
)
|
||||
|
||||
def calculate_milestone_priority(self, milestone: Milestone) -> int:
|
||||
"""Calculate milestone priority based on business rules."""
|
||||
priority_score = 0
|
||||
|
||||
# Higher priority for milestones with more issues
|
||||
priority_score += milestone.total_issues * 2
|
||||
|
||||
# Higher priority for milestones with due dates
|
||||
if milestone.due_date:
|
||||
days_until_due = (milestone.due_date - datetime.utcnow()).days
|
||||
if days_until_due <= 7:
|
||||
priority_score += 50 # Very urgent
|
||||
elif days_until_due <= 30:
|
||||
priority_score += 25 # Urgent
|
||||
else:
|
||||
priority_score += 10 # Normal
|
||||
|
||||
# Higher priority for milestones closer to completion
|
||||
if milestone.completion_percentage >= 75:
|
||||
priority_score += 30 # Push to completion
|
||||
|
||||
# Lower priority for stalled milestones
|
||||
if milestone.total_issues > 0 and milestone.completion_percentage == 0:
|
||||
priority_score -= 20
|
||||
|
||||
return max(0, priority_score) # Ensure non-negative
|
||||
|
||||
def generate_project_summary(self, project: Project) -> Dict[str, Any]:
|
||||
"""Generate a comprehensive project summary."""
|
||||
health = self.determine_project_health(project)
|
||||
bottlenecks = self.identify_bottlenecks(project)
|
||||
recommendations = self.recommend_next_actions(project)
|
||||
|
||||
return {
|
||||
"name": project.name,
|
||||
"state": project.state.value,
|
||||
"health": health,
|
||||
"overall_progress": project.calculate_overall_progress(),
|
||||
"total_milestones": len(project.milestones),
|
||||
"active_milestones": len(project.get_active_milestones()),
|
||||
"completed_milestones": len(project.get_completed_milestones()),
|
||||
"overdue_milestones": len(project.get_overdue_milestones()),
|
||||
"total_issues": project.get_total_issues(),
|
||||
"open_issues": project.get_total_open_issues(),
|
||||
"closed_issues": project.get_total_closed_issues(),
|
||||
"bottlenecks": bottlenecks,
|
||||
"recommendations": recommendations
|
||||
}
|
||||
5
infrastructure/__init__.py
Normal file
5
infrastructure/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Infrastructure layer for MarkiTect project.
|
||||
|
||||
Contains concrete implementations of repositories and external system integrations.
|
||||
"""
|
||||
3
infrastructure/repositories/__init__.py
Normal file
3
infrastructure/repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Repository implementations for external systems.
|
||||
"""
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Configuration management for tddai.
|
||||
|
||||
DEPRECATED: This module is kept for backward compatibility only.
|
||||
New code should use the unified configuration system in the `config` module.
|
||||
|
||||
The tddai framework is project-agnostic and can be configured per project
|
||||
via environment variables:
|
||||
|
||||
@@ -25,9 +28,19 @@ from dataclasses import dataclass
|
||||
|
||||
from .exceptions import ConfigurationError
|
||||
|
||||
# Import unified configuration system
|
||||
try:
|
||||
from config import get_tddai_config as _get_unified_tddai_config
|
||||
_UNIFIED_CONFIG_AVAILABLE = True
|
||||
except ImportError:
|
||||
_UNIFIED_CONFIG_AVAILABLE = False
|
||||
|
||||
|
||||
def load_dotenv_file(env_file: Path) -> None:
|
||||
"""Load environment variables from a .env file."""
|
||||
"""Load environment variables from a .env file.
|
||||
|
||||
DEPRECATED: Use config.loaders.load_env_file() instead.
|
||||
"""
|
||||
if not env_file.exists():
|
||||
return
|
||||
|
||||
@@ -41,7 +54,10 @@ def load_dotenv_file(env_file: Path) -> None:
|
||||
|
||||
@dataclass
|
||||
class TddaiConfig:
|
||||
"""Configuration settings for tddai."""
|
||||
"""Configuration settings for tddai.
|
||||
|
||||
DEPRECATED: Use config.TddaiConfigCompat instead.
|
||||
"""
|
||||
|
||||
# Workspace settings
|
||||
workspace_dir: Path = Path(".tddai_workspace")
|
||||
@@ -114,16 +130,42 @@ _config: Optional[TddaiConfig] = None
|
||||
|
||||
|
||||
def get_config() -> TddaiConfig:
|
||||
"""Get the global configuration instance."""
|
||||
"""Get the global configuration instance.
|
||||
|
||||
DEPRECATED: Use config.get_tddai_config() instead for new code.
|
||||
This function maintains backward compatibility.
|
||||
"""
|
||||
global _config
|
||||
if _config is None:
|
||||
if _UNIFIED_CONFIG_AVAILABLE:
|
||||
# Use unified configuration system if available
|
||||
try:
|
||||
unified_config = _get_unified_tddai_config()
|
||||
_config = TddaiConfig(
|
||||
workspace_dir=unified_config.workspace_dir,
|
||||
gitea_url=unified_config.gitea_url,
|
||||
repo_owner=unified_config.repo_owner,
|
||||
repo_name=unified_config.repo_name,
|
||||
tests_dir=unified_config.tests_dir,
|
||||
test_file_pattern=unified_config.test_file_pattern,
|
||||
claude_code_command=unified_config.claude_code_command
|
||||
)
|
||||
return _config
|
||||
except Exception:
|
||||
# Fall back to legacy behavior if unified config fails
|
||||
pass
|
||||
|
||||
# Legacy fallback
|
||||
_config = TddaiConfig.from_environment()
|
||||
_config.validate()
|
||||
return _config
|
||||
|
||||
|
||||
def set_config(config: TddaiConfig) -> None:
|
||||
"""Set the global configuration instance."""
|
||||
"""Set the global configuration instance.
|
||||
|
||||
DEPRECATED: Use the unified configuration system instead.
|
||||
"""
|
||||
global _config
|
||||
config.validate()
|
||||
_config = config
|
||||
296
tests/conftest.py
Normal file
296
tests/conftest.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Global test configuration and fixtures for MarkiTect project.
|
||||
|
||||
Provides shared fixtures, utilities, and configuration for all test types.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from typing import Generator, Dict, Any
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_workspace() -> Generator[Path, None, None]:
|
||||
"""Create isolated test workspace for file operations."""
|
||||
temp_dir = tempfile.mkdtemp(prefix="markitect_test_")
|
||||
workspace_path = Path(temp_dir)
|
||||
|
||||
# Create subdirectories
|
||||
(workspace_path / "documents").mkdir()
|
||||
(workspace_path / "cache").mkdir()
|
||||
(workspace_path / "workspaces").mkdir()
|
||||
|
||||
yield workspace_path
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_database_path(test_workspace) -> Path:
|
||||
"""Provide path for test database."""
|
||||
return test_workspace / "test.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_database():
|
||||
"""Provide mocked database for testing."""
|
||||
mock_db = Mock()
|
||||
mock_cursor = Mock()
|
||||
mock_db.cursor.return_value = mock_cursor
|
||||
mock_db.execute.return_value = mock_cursor
|
||||
mock_cursor.fetchone.return_value = None
|
||||
mock_cursor.fetchall.return_value = []
|
||||
mock_cursor.lastrowid = 1
|
||||
return mock_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client():
|
||||
"""Provide mocked HTTP client for API tests."""
|
||||
mock_client = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"status": "success"}
|
||||
mock_response.text = '{"status": "success"}'
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.put.return_value = mock_response
|
||||
mock_client.delete.return_value = mock_response
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_async_http_client():
|
||||
"""Provide mocked async HTTP client for API tests."""
|
||||
mock_client = AsyncMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={"status": "success"})
|
||||
mock_response.text = AsyncMock(return_value='{"status": "success"}')
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.put.return_value = mock_response
|
||||
mock_client.delete.return_value = mock_response
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_config(test_workspace) -> Dict[str, Any]:
|
||||
"""Provide test configuration dictionary."""
|
||||
return {
|
||||
"workspace_dir": str(test_workspace / "workspaces"),
|
||||
"database_path": str(test_workspace / "test.db"),
|
||||
"cache_dir": str(test_workspace / "cache"),
|
||||
"gitea_url": "http://test-gitea.com",
|
||||
"gitea_token": "test-token",
|
||||
"repo_owner": "test",
|
||||
"repo_name": "repo",
|
||||
"log_level": "DEBUG"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_environment():
|
||||
"""Provide clean environment variables for testing."""
|
||||
original_env = dict(os.environ)
|
||||
|
||||
# Clear relevant environment variables
|
||||
test_env_vars = [
|
||||
"MARKITECT_WORKSPACE_DIR",
|
||||
"MARKITECT_GITEA_URL",
|
||||
"MARKITECT_GITEA_TOKEN",
|
||||
"MARKITECT_REPO_OWNER",
|
||||
"MARKITECT_REPO_NAME"
|
||||
]
|
||||
|
||||
for var in test_env_vars:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
yield
|
||||
|
||||
# Restore original environment
|
||||
os.environ.clear()
|
||||
os.environ.update(original_env)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_environment(test_workspace, clean_environment):
|
||||
"""Set up isolated environment for CLI testing."""
|
||||
env = {
|
||||
"MARKITECT_WORKSPACE_DIR": str(test_workspace / "workspaces"),
|
||||
"MARKITECT_GITEA_URL": "http://test-gitea.com",
|
||||
"MARKITECT_GITEA_TOKEN": "test-token",
|
||||
"MARKITECT_REPO_OWNER": "test",
|
||||
"MARKITECT_REPO_NAME": "repo",
|
||||
"PYTHONPATH": "."
|
||||
}
|
||||
|
||||
# Update current process environment
|
||||
for key, value in env.items():
|
||||
os.environ[key] = value
|
||||
|
||||
yield env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_markdown_content():
|
||||
"""Provide sample markdown content for testing."""
|
||||
return """---
|
||||
title: Test Document
|
||||
author: Test Author
|
||||
tags: [test, sample]
|
||||
---
|
||||
|
||||
# Test Document
|
||||
|
||||
This is a test document with **bold** and *italic* text.
|
||||
|
||||
## Section 1
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 3
|
||||
|
||||
## Section 2
|
||||
|
||||
Here's a code block:
|
||||
|
||||
```python
|
||||
def hello_world():
|
||||
print("Hello, World!")
|
||||
```
|
||||
|
||||
And a link: [Test Link](https://example.com)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_issue_data():
|
||||
"""Provide sample issue data for testing."""
|
||||
return {
|
||||
"number": 123,
|
||||
"title": "Test Issue",
|
||||
"body": "This is a test issue description",
|
||||
"state": "open",
|
||||
"labels": [
|
||||
{"name": "bug"},
|
||||
{"name": "priority:high"},
|
||||
{"name": "status:in-progress"}
|
||||
],
|
||||
"milestone": {
|
||||
"id": 1,
|
||||
"title": "Version 1.0",
|
||||
"description": "First release"
|
||||
},
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T12:00:00Z"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_project_data():
|
||||
"""Provide sample project data for testing."""
|
||||
return {
|
||||
"name": "Test Project",
|
||||
"description": "A test project for testing",
|
||||
"state": "active",
|
||||
"milestones": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Version 1.0",
|
||||
"description": "First release",
|
||||
"due_date": "2025-12-31T23:59:59Z",
|
||||
"state": "open",
|
||||
"open_issues": 5,
|
||||
"closed_issues": 3
|
||||
}
|
||||
],
|
||||
"kanban_columns": ["Todo", "In Progress", "Review", "Done"],
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
|
||||
# Performance testing fixtures
|
||||
@pytest.fixture
|
||||
def performance_timer():
|
||||
"""Timer fixture for performance testing."""
|
||||
import time
|
||||
|
||||
class Timer:
|
||||
def __init__(self):
|
||||
self.start_time = None
|
||||
self.end_time = None
|
||||
|
||||
def start(self):
|
||||
self.start_time = time.time()
|
||||
|
||||
def stop(self):
|
||||
self.end_time = time.time()
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
if self.start_time is None:
|
||||
raise ValueError("Timer not started")
|
||||
if self.end_time is None:
|
||||
return time.time() - self.start_time
|
||||
return self.end_time - self.start_time
|
||||
|
||||
return Timer()
|
||||
|
||||
|
||||
# Async test helpers
|
||||
@pytest.fixture
|
||||
def async_test_timeout():
|
||||
"""Default timeout for async tests."""
|
||||
return 30.0 # 30 seconds
|
||||
|
||||
|
||||
# Test markers configuration
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest markers."""
|
||||
config.addinivalue_line(
|
||||
"markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "integration: marks tests as integration tests"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "e2e: marks tests as end-to-end tests"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "performance: marks tests as performance tests"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "unit: marks tests as unit tests"
|
||||
)
|
||||
|
||||
|
||||
# Collection hooks
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Modify test collection to add markers based on test location."""
|
||||
for item in items:
|
||||
# Add markers based on test file location
|
||||
if "unit" in str(item.fspath):
|
||||
item.add_marker(pytest.mark.unit)
|
||||
elif "integration" in str(item.fspath):
|
||||
item.add_marker(pytest.mark.integration)
|
||||
elif "e2e" in str(item.fspath):
|
||||
item.add_marker(pytest.mark.e2e)
|
||||
elif "performance" in str(item.fspath):
|
||||
item.add_marker(pytest.mark.performance)
|
||||
3
tests/e2e/__init__.py
Normal file
3
tests/e2e/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
End-to-end tests for MarkiTect workflows.
|
||||
"""
|
||||
3
tests/e2e/cli/__init__.py
Normal file
3
tests/e2e/cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
End-to-end CLI tests.
|
||||
"""
|
||||
348
tests/e2e/cli/test_issue_commands_e2e.py
Normal file
348
tests/e2e/cli/test_issue_commands_e2e.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
End-to-end tests for issue management CLI commands.
|
||||
|
||||
Demonstrates:
|
||||
- CLI command testing with real processes
|
||||
- Environment isolation
|
||||
- Workflow validation
|
||||
- Output verification
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import os
|
||||
|
||||
from tests.utils.assertions import assert_file_exists, assert_directory_exists, assert_file_contains
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestIssueCommandsE2E:
|
||||
"""End-to-end tests for issue management CLI commands."""
|
||||
|
||||
def test_show_issue_command_basic(self, isolated_environment):
|
||||
"""Test basic issue show command."""
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "show-issue", "23"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0, f"Command failed with stderr: {result.stderr}"
|
||||
assert "Issue #23" in result.stdout or "issue 23" in result.stdout.lower()
|
||||
|
||||
def test_show_issue_command_with_invalid_number(self, isolated_environment):
|
||||
"""Test show issue command with invalid issue number."""
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "show-issue", "99999"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Assert - Should handle gracefully
|
||||
# Note: Depending on implementation, this might return 0 or 1
|
||||
assert "not found" in result.stdout.lower() or "error" in result.stderr.lower()
|
||||
|
||||
def test_workspace_status_command(self, isolated_environment):
|
||||
"""Test workspace status command."""
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "workspace-status"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0
|
||||
# Should show workspace information
|
||||
assert "workspace" in result.stdout.lower() or "status" in result.stdout.lower()
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_complete_issue_workflow(self, isolated_environment, test_workspace):
|
||||
"""Test complete issue workflow from start to finish."""
|
||||
workspace_dir = Path(isolated_environment["MARKITECT_WORKSPACE_DIR"])
|
||||
workspace_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Step 1: Check initial workspace status
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "workspace-status"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
# Step 2: Start working on an issue
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "start-issue", "42"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd(),
|
||||
timeout=30 # Prevent hanging
|
||||
)
|
||||
|
||||
# Verify the start command works (might create workspace)
|
||||
if result.returncode == 0:
|
||||
# If successful, check if workspace was created
|
||||
issue_workspace = workspace_dir / "issue_42"
|
||||
if issue_workspace.exists():
|
||||
assert_directory_exists(issue_workspace)
|
||||
|
||||
# Step 3: Check workspace status again
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "workspace-status"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
# Step 4: Try to finish (cleanup)
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "finish-issue"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd(),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# The finish command should work or provide meaningful feedback
|
||||
assert result.returncode in [0, 1] # Allow for various implementation states
|
||||
|
||||
def test_list_open_issues_command(self, isolated_environment):
|
||||
"""Test listing open issues."""
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "list-open-issues"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0
|
||||
# Should return some form of issue listing (even if empty)
|
||||
output = result.stdout.strip()
|
||||
assert len(output) >= 0 # Any output is acceptable
|
||||
|
||||
def test_cli_help_commands(self, isolated_environment):
|
||||
"""Test CLI help functionality."""
|
||||
# Test main help
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "--help"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "usage" in result.stdout.lower() or "commands" in result.stdout.lower()
|
||||
|
||||
# Test specific command help
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "show-issue", "--help"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_cli_with_invalid_command(self, isolated_environment):
|
||||
"""Test CLI behavior with invalid command."""
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "invalid-command"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Assert - Should handle gracefully
|
||||
assert result.returncode != 0
|
||||
assert "error" in result.stderr.lower() or "unknown" in result.stderr.lower()
|
||||
|
||||
def test_cli_error_handling(self, isolated_environment):
|
||||
"""Test CLI error handling for various scenarios."""
|
||||
# Test with missing required argument
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "show-issue"], # Missing issue number
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Should provide helpful error message
|
||||
assert result.returncode != 0
|
||||
assert len(result.stderr) > 0 or "error" in result.stdout.lower()
|
||||
|
||||
@pytest.mark.parametrize("issue_number", ["1", "23", "100"])
|
||||
def test_show_issue_command_multiple_issues(self, isolated_environment, issue_number):
|
||||
"""Test show issue command with multiple issue numbers."""
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "show-issue", issue_number],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd(),
|
||||
timeout=15
|
||||
)
|
||||
|
||||
# Assert - Command should execute without crashing
|
||||
assert result.returncode in [0, 1] # Allow for not found scenarios
|
||||
assert len(result.stdout + result.stderr) > 0 # Should provide some output
|
||||
|
||||
def test_cli_performance(self, isolated_environment, performance_timer):
|
||||
"""Test CLI command performance."""
|
||||
# Act
|
||||
performance_timer.start()
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "workspace-status"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert result.returncode == 0
|
||||
# CLI commands should be reasonably fast
|
||||
assert performance_timer.elapsed < 10.0, f"CLI command took {performance_timer.elapsed:.2f}s"
|
||||
|
||||
def test_cli_output_formatting(self, isolated_environment):
|
||||
"""Test CLI output formatting and structure."""
|
||||
# Test workspace status output
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "workspace-status"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
output = result.stdout
|
||||
# Output should be readable and structured
|
||||
assert len(output.strip()) > 0
|
||||
# Should not contain obvious error traces
|
||||
assert "Traceback" not in output
|
||||
assert "Exception" not in output
|
||||
|
||||
def test_cli_environment_isolation(self, test_workspace):
|
||||
"""Test that CLI commands work in isolated environment."""
|
||||
# Create isolated environment
|
||||
isolated_env = {
|
||||
"MARKITECT_WORKSPACE_DIR": str(test_workspace / "isolated"),
|
||||
"MARKITECT_GITEA_URL": "http://isolated-gitea.com",
|
||||
"MARKITECT_REPO_OWNER": "isolated",
|
||||
"MARKITECT_REPO_NAME": "test",
|
||||
"PYTHONPATH": "."
|
||||
}
|
||||
|
||||
# Update with current env to preserve PATH, etc.
|
||||
full_env = dict(os.environ)
|
||||
full_env.update(isolated_env)
|
||||
|
||||
# Act
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "workspace-status"],
|
||||
env=full_env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
# Assert - Should work with isolated environment
|
||||
assert result.returncode == 0
|
||||
# Should use isolated workspace directory
|
||||
workspace_path = test_workspace / "isolated"
|
||||
workspace_path.mkdir(exist_ok=True)
|
||||
|
||||
def test_cli_concurrent_execution(self, isolated_environment):
|
||||
"""Test concurrent CLI command execution."""
|
||||
import threading
|
||||
import queue
|
||||
|
||||
results_queue = queue.Queue()
|
||||
|
||||
def run_command(command_args):
|
||||
result = subprocess.run(
|
||||
command_args,
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd(),
|
||||
timeout=15
|
||||
)
|
||||
results_queue.put(result)
|
||||
|
||||
# Start multiple commands concurrently
|
||||
commands = [
|
||||
["python", "tddai_cli.py", "workspace-status"],
|
||||
["python", "tddai_cli.py", "show-issue", "1"],
|
||||
["python", "tddai_cli.py", "show-issue", "2"],
|
||||
]
|
||||
|
||||
threads = []
|
||||
for cmd in commands:
|
||||
thread = threading.Thread(target=run_command, args=(cmd,))
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
# Wait for all threads to complete
|
||||
for thread in threads:
|
||||
thread.join(timeout=20)
|
||||
|
||||
# Collect results
|
||||
results = []
|
||||
while not results_queue.empty():
|
||||
results.append(results_queue.get())
|
||||
|
||||
# Assert
|
||||
assert len(results) == len(commands)
|
||||
# At least some commands should succeed
|
||||
successful_commands = [r for r in results if r.returncode == 0]
|
||||
assert len(successful_commands) > 0
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_cli_smoke_test(self, isolated_environment):
|
||||
"""Basic smoke test for CLI functionality."""
|
||||
# Test that the CLI script exists and is executable
|
||||
cli_script = Path("tddai_cli.py")
|
||||
assert_file_exists(cli_script)
|
||||
|
||||
# Test basic command execution
|
||||
result = subprocess.run(
|
||||
["python", "tddai_cli.py", "--help"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Should at least not crash
|
||||
assert result.returncode in [0, 1, 2] # Various help return codes
|
||||
assert len(result.stdout + result.stderr) > 0
|
||||
3
tests/e2e/performance/__init__.py
Normal file
3
tests/e2e/performance/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Performance and load testing for MarkiTect.
|
||||
"""
|
||||
359
tests/e2e/performance/test_domain_performance.py
Normal file
359
tests/e2e/performance/test_domain_performance.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Performance tests for domain operations.
|
||||
|
||||
Demonstrates:
|
||||
- Domain operation performance benchmarks
|
||||
- Memory usage monitoring
|
||||
- Bulk operation testing
|
||||
- Performance regression detection
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import gc
|
||||
from typing import List
|
||||
|
||||
from domain.issues.models import Issue, Label, IssueState
|
||||
from domain.issues.services import IssueStatusService, IssueValidationService
|
||||
from domain.projects.models import Project, Milestone, ProjectState
|
||||
from domain.projects.services import ProjectManagementService
|
||||
|
||||
from tests.utils.test_builders import IssueBuilder, LabelBuilder, MilestoneBuilder, ProjectBuilder
|
||||
from tests.utils.assertions import assert_performance_within_bounds, assert_memory_usage_within_bounds
|
||||
|
||||
|
||||
class TestDomainPerformance:
|
||||
"""Performance tests for domain operations."""
|
||||
|
||||
def test_issue_creation_performance(self, performance_timer):
|
||||
"""Test performance of creating many issues."""
|
||||
# Arrange
|
||||
issue_count = 1000
|
||||
|
||||
# Act
|
||||
performance_timer.start()
|
||||
issues = []
|
||||
for i in range(issue_count):
|
||||
issue = (IssueBuilder()
|
||||
.with_number(i + 1)
|
||||
.with_title(f"Performance Test Issue {i + 1}")
|
||||
.as_bug()
|
||||
.with_priority("medium")
|
||||
.build())
|
||||
issues.append(issue)
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(issues) == issue_count
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 1.0, f"creating {issue_count} issues")
|
||||
print(f"Created {issue_count} issues in {performance_timer.elapsed:.3f}s ({issue_count/performance_timer.elapsed:.0f} issues/sec)")
|
||||
|
||||
def test_label_categorization_performance(self, performance_timer):
|
||||
"""Test performance of label categorization operations."""
|
||||
# Arrange
|
||||
issues = []
|
||||
for i in range(500):
|
||||
issue = (IssueBuilder()
|
||||
.with_number(i + 1)
|
||||
.with_title(f"Issue {i + 1}")
|
||||
.with_labels(
|
||||
"bug", "priority:high", "status:in-progress",
|
||||
"frontend", "needs-testing", "documentation"
|
||||
)
|
||||
.build())
|
||||
issues.append(issue)
|
||||
|
||||
# Act
|
||||
performance_timer.start()
|
||||
categorized_results = []
|
||||
for issue in issues:
|
||||
categories = issue.categorize_labels()
|
||||
categorized_results.append(categories)
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(categorized_results) == 500
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 0.5, "categorizing labels for 500 issues")
|
||||
|
||||
# Verify categorization correctness
|
||||
for categories in categorized_results:
|
||||
assert "bug" in categories.type_labels
|
||||
assert "priority:high" in categories.priority_labels
|
||||
assert "status:in-progress" in categories.state_labels
|
||||
assert "frontend" in categories.other_labels
|
||||
|
||||
def test_issue_status_service_performance(self, performance_timer):
|
||||
"""Test performance of issue status service operations."""
|
||||
# Arrange
|
||||
service = IssueStatusService()
|
||||
issues = []
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Done"]}
|
||||
|
||||
for i in range(1000):
|
||||
labels = ["bug", f"priority:{'high' if i % 3 == 0 else 'medium'}", f"status:{'in-progress' if i % 2 == 0 else 'new'}"]
|
||||
issue = (IssueBuilder()
|
||||
.with_number(i + 1)
|
||||
.with_title(f"Status Test Issue {i + 1}")
|
||||
.with_labels(*labels)
|
||||
.build())
|
||||
issues.append(issue)
|
||||
|
||||
# Act
|
||||
performance_timer.start()
|
||||
results = []
|
||||
for issue in issues:
|
||||
kanban_column = service.determine_kanban_column(issue, project_info)
|
||||
priority_info = service.extract_priority_info(issue)
|
||||
results.append((kanban_column, priority_info))
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(results) == 1000
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 0.8, "processing 1000 issues through status service")
|
||||
|
||||
# Verify correctness
|
||||
in_progress_count = sum(1 for kanban, _ in results if kanban == "In Progress")
|
||||
todo_count = sum(1 for kanban, _ in results if kanban == "Todo")
|
||||
assert in_progress_count > 0
|
||||
assert todo_count > 0
|
||||
|
||||
def test_project_progress_calculation_performance(self, performance_timer):
|
||||
"""Test performance of project progress calculations."""
|
||||
# Arrange
|
||||
projects = []
|
||||
for i in range(100):
|
||||
milestones = []
|
||||
for j in range(20): # 20 milestones per project
|
||||
milestone = (MilestoneBuilder()
|
||||
.with_id(j + 1)
|
||||
.with_title(f"Milestone {j + 1}")
|
||||
.with_issue_counts(
|
||||
open_issues=10 - (j % 8),
|
||||
closed_issues=j % 12
|
||||
)
|
||||
.build())
|
||||
milestones.append(milestone)
|
||||
|
||||
project = (ProjectBuilder()
|
||||
.with_name(f"Performance Project {i + 1}")
|
||||
.with_milestones(*milestones)
|
||||
.build())
|
||||
projects.append(project)
|
||||
|
||||
# Act
|
||||
performance_timer.start()
|
||||
progress_results = []
|
||||
for project in projects:
|
||||
overall_progress = project.calculate_overall_progress()
|
||||
active_milestones = project.get_active_milestones()
|
||||
completed_milestones = project.get_completed_milestones()
|
||||
total_issues = project.get_total_issues()
|
||||
|
||||
progress_results.append({
|
||||
"overall_progress": overall_progress,
|
||||
"active_count": len(active_milestones),
|
||||
"completed_count": len(completed_milestones),
|
||||
"total_issues": total_issues
|
||||
})
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(progress_results) == 100
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 0.5, "calculating progress for 100 projects with 20 milestones each")
|
||||
|
||||
# Verify calculations are reasonable
|
||||
for result in progress_results:
|
||||
assert 0 <= result["overall_progress"] <= 100
|
||||
assert result["total_issues"] > 0
|
||||
|
||||
def test_bulk_issue_validation_performance(self, performance_timer):
|
||||
"""Test performance of bulk issue validation."""
|
||||
# Arrange
|
||||
validation_service = IssueValidationService()
|
||||
issue_data_list = []
|
||||
|
||||
for i in range(2000):
|
||||
issue_data = {
|
||||
"title": f"Validation Test Issue {i + 1}" if i % 10 != 0 else "", # 10% invalid
|
||||
"labels": ["bug", "priority:medium"] if i % 5 != 0 else ["bug", "priority:high", "priority:low"] # 20% invalid
|
||||
}
|
||||
issue_data_list.append(issue_data)
|
||||
|
||||
# Act
|
||||
performance_timer.start()
|
||||
validation_results = []
|
||||
for issue_data in issue_data_list:
|
||||
try:
|
||||
validation_service.validate_issue_creation(issue_data)
|
||||
validation_results.append(True)
|
||||
except Exception:
|
||||
validation_results.append(False)
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(validation_results) == 2000
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 1.0, "validating 2000 issues")
|
||||
|
||||
# Verify validation correctness
|
||||
valid_count = sum(1 for result in validation_results if result)
|
||||
invalid_count = sum(1 for result in validation_results if not result)
|
||||
|
||||
# Expect about 70% valid (90% have valid titles AND 80% have valid labels = 72%)
|
||||
assert 1200 <= valid_count <= 1600 # Allow some tolerance
|
||||
assert 400 <= invalid_count <= 800
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_memory_usage_with_large_datasets(self, performance_timer):
|
||||
"""Test memory usage with large datasets."""
|
||||
import psutil
|
||||
import os
|
||||
|
||||
# Measure initial memory
|
||||
process = psutil.Process(os.getpid())
|
||||
initial_memory_mb = process.memory_info().rss / (1024 * 1024)
|
||||
|
||||
# Create large dataset
|
||||
performance_timer.start()
|
||||
large_issues = []
|
||||
for i in range(10000):
|
||||
issue = (IssueBuilder()
|
||||
.with_number(i + 1)
|
||||
.with_title(f"Large Dataset Issue {i + 1}")
|
||||
.with_labels("bug", "priority:medium", "status:new", "backend", "database")
|
||||
.build())
|
||||
large_issues.append(issue)
|
||||
|
||||
# Perform operations on dataset
|
||||
for issue in large_issues:
|
||||
categories = issue.categorize_labels()
|
||||
# Simulate some processing
|
||||
_ = len(categories.type_labels) + len(categories.priority_labels)
|
||||
|
||||
performance_timer.stop()
|
||||
|
||||
# Measure final memory
|
||||
final_memory_mb = process.memory_info().rss / (1024 * 1024)
|
||||
memory_increase_mb = final_memory_mb - initial_memory_mb
|
||||
|
||||
# Force garbage collection and measure again
|
||||
gc.collect()
|
||||
gc_memory_mb = process.memory_info().rss / (1024 * 1024)
|
||||
gc_reduction_mb = final_memory_mb - gc_memory_mb
|
||||
|
||||
# Assert
|
||||
assert len(large_issues) == 10000
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 5.0, "processing 10,000 issues")
|
||||
assert_memory_usage_within_bounds(memory_increase_mb, 50.0, "creating and processing 10,000 issues")
|
||||
|
||||
print(f"Memory usage: Initial={initial_memory_mb:.2f}MB, Final={final_memory_mb:.2f}MB, "
|
||||
f"Increase={memory_increase_mb:.2f}MB, GC Reduction={gc_reduction_mb:.2f}MB")
|
||||
|
||||
# Memory should be reasonable for the dataset size
|
||||
assert memory_increase_mb > 0 # Should use some memory
|
||||
assert gc_reduction_mb >= 0 # GC should not increase memory
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_concurrent_domain_operations_simulation(self, performance_timer):
|
||||
"""Simulate concurrent domain operations for performance testing."""
|
||||
# Arrange
|
||||
project_service = ProjectManagementService()
|
||||
projects = []
|
||||
|
||||
# Create test projects
|
||||
for i in range(10):
|
||||
milestones = [
|
||||
MilestoneBuilder().with_id(j + 1).with_title(f"M{j + 1}")
|
||||
.with_issue_counts(5, 3).build()
|
||||
for j in range(5)
|
||||
]
|
||||
project = (ProjectBuilder()
|
||||
.with_name(f"Concurrent Project {i + 1}")
|
||||
.with_milestones(*milestones)
|
||||
.build())
|
||||
projects.append(project)
|
||||
|
||||
# Act - Simulate concurrent operations
|
||||
performance_timer.start()
|
||||
results = []
|
||||
|
||||
# Simulate multiple "users" performing operations
|
||||
for iteration in range(100):
|
||||
for project in projects:
|
||||
# Simulate various operations
|
||||
health_report = project_service.calculate_project_health(project)
|
||||
progress = project.calculate_overall_progress()
|
||||
active_milestones = project.get_active_milestones()
|
||||
|
||||
results.append({
|
||||
"iteration": iteration,
|
||||
"project_name": project.name,
|
||||
"health_score": health_report.overall_health_score,
|
||||
"progress": progress,
|
||||
"active_milestones": len(active_milestones)
|
||||
})
|
||||
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
expected_operations = 100 * 10 # 100 iterations * 10 projects
|
||||
assert len(results) == expected_operations
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 2.0, f"simulating {expected_operations} concurrent operations")
|
||||
|
||||
# Verify result consistency
|
||||
for result in results:
|
||||
assert 0 <= result["health_score"] <= 100
|
||||
assert 0 <= result["progress"] <= 100
|
||||
assert result["active_milestones"] >= 0
|
||||
|
||||
ops_per_second = expected_operations / performance_timer.elapsed
|
||||
print(f"Simulated {expected_operations} operations in {performance_timer.elapsed:.3f}s ({ops_per_second:.0f} ops/sec)")
|
||||
|
||||
def test_domain_operation_consistency_under_load(self, performance_timer):
|
||||
"""Test that domain operations remain consistent under load."""
|
||||
# Arrange
|
||||
reference_issue = (IssueBuilder()
|
||||
.with_number(1)
|
||||
.with_title("Reference Issue")
|
||||
.with_labels("bug", "priority:high", "status:blocked")
|
||||
.build())
|
||||
|
||||
# Get reference results
|
||||
reference_categories = reference_issue.categorize_labels()
|
||||
status_service = IssueStatusService()
|
||||
reference_kanban = status_service.determine_kanban_column(reference_issue, {})
|
||||
|
||||
# Act - Perform same operations many times
|
||||
performance_timer.start()
|
||||
consistency_results = []
|
||||
|
||||
for i in range(5000):
|
||||
# Create identical issue
|
||||
test_issue = (IssueBuilder()
|
||||
.with_number(1)
|
||||
.with_title("Reference Issue")
|
||||
.with_labels("bug", "priority:high", "status:blocked")
|
||||
.build())
|
||||
|
||||
# Perform operations
|
||||
categories = test_issue.categorize_labels()
|
||||
kanban = status_service.determine_kanban_column(test_issue, {})
|
||||
|
||||
# Check consistency
|
||||
categories_match = (
|
||||
categories.type_labels == reference_categories.type_labels and
|
||||
categories.priority_labels == reference_categories.priority_labels and
|
||||
categories.state_labels == reference_categories.state_labels
|
||||
)
|
||||
kanban_matches = kanban == reference_kanban
|
||||
|
||||
consistency_results.append(categories_match and kanban_matches)
|
||||
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(consistency_results) == 5000
|
||||
assert all(consistency_results), "All operations should produce consistent results"
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 1.5, "consistency test with 5000 operations")
|
||||
|
||||
print(f"Consistency test: {len(consistency_results)} operations, all consistent, "
|
||||
f"completed in {performance_timer.elapsed:.3f}s")
|
||||
3
tests/fixtures/__init__.py
vendored
Normal file
3
tests/fixtures/__init__.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test fixtures and data builders for MarkiTect tests.
|
||||
"""
|
||||
332
tests/fixtures/api_responses.py
vendored
Normal file
332
tests/fixtures/api_responses.py
vendored
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
API response builders and mock data for testing external integrations.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class GiteaApiResponseBuilder:
|
||||
"""Builder for creating mock Gitea API responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.issue_data = {
|
||||
"number": 1,
|
||||
"title": "Test Issue",
|
||||
"body": "Test issue description",
|
||||
"state": "open",
|
||||
"labels": [],
|
||||
"milestone": None,
|
||||
"assignees": [],
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"closed_at": None,
|
||||
"html_url": "https://test-gitea.com/test/repo/issues/1",
|
||||
"user": {
|
||||
"login": "testuser",
|
||||
"id": 1,
|
||||
"avatar_url": "https://test-gitea.com/avatars/1"
|
||||
}
|
||||
}
|
||||
|
||||
def with_number(self, number: int) -> "GiteaApiResponseBuilder":
|
||||
"""Set issue number."""
|
||||
self.issue_data["number"] = number
|
||||
self.issue_data["html_url"] = f"https://test-gitea.com/test/repo/issues/{number}"
|
||||
return self
|
||||
|
||||
def with_title(self, title: str) -> "GiteaApiResponseBuilder":
|
||||
"""Set issue title."""
|
||||
self.issue_data["title"] = title
|
||||
return self
|
||||
|
||||
def with_body(self, body: str) -> "GiteaApiResponseBuilder":
|
||||
"""Set issue body/description."""
|
||||
self.issue_data["body"] = body
|
||||
return self
|
||||
|
||||
def with_state(self, state: str) -> "GiteaApiResponseBuilder":
|
||||
"""Set issue state (open/closed)."""
|
||||
if state not in ["open", "closed"]:
|
||||
raise ValueError("State must be 'open' or 'closed'")
|
||||
self.issue_data["state"] = state
|
||||
if state == "closed" and self.issue_data["closed_at"] is None:
|
||||
self.issue_data["closed_at"] = "2025-01-02T00:00:00Z"
|
||||
return self
|
||||
|
||||
def with_labels(self, *labels: str) -> "GiteaApiResponseBuilder":
|
||||
"""Add labels to the issue."""
|
||||
self.issue_data["labels"] = [
|
||||
{
|
||||
"id": i + 1,
|
||||
"name": label,
|
||||
"color": "red",
|
||||
"description": f"Label: {label}"
|
||||
}
|
||||
for i, label in enumerate(labels)
|
||||
]
|
||||
return self
|
||||
|
||||
def with_milestone(self, title: str, id: int = 1, state: str = "open") -> "GiteaApiResponseBuilder":
|
||||
"""Add milestone to the issue."""
|
||||
self.issue_data["milestone"] = {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"description": f"Milestone: {title}",
|
||||
"state": state,
|
||||
"open_issues": 5,
|
||||
"closed_issues": 3,
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"due_date": "2025-12-31T23:59:59Z"
|
||||
}
|
||||
return self
|
||||
|
||||
def with_assignees(self, *usernames: str) -> "GiteaApiResponseBuilder":
|
||||
"""Add assignees to the issue."""
|
||||
self.issue_data["assignees"] = [
|
||||
{
|
||||
"login": username,
|
||||
"id": i + 1,
|
||||
"avatar_url": f"https://test-gitea.com/avatars/{i + 1}"
|
||||
}
|
||||
for i, username in enumerate(usernames)
|
||||
]
|
||||
return self
|
||||
|
||||
def with_timestamps(self, created_at: str, updated_at: str, closed_at: Optional[str] = None) -> "GiteaApiResponseBuilder":
|
||||
"""Set issue timestamps."""
|
||||
self.issue_data["created_at"] = created_at
|
||||
self.issue_data["updated_at"] = updated_at
|
||||
if closed_at:
|
||||
self.issue_data["closed_at"] = closed_at
|
||||
return self
|
||||
|
||||
def build(self) -> Dict[str, Any]:
|
||||
"""Build the final issue data."""
|
||||
return self.issue_data.copy()
|
||||
|
||||
|
||||
class GiteaProjectResponseBuilder:
|
||||
"""Builder for creating mock Gitea project/repository responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.project_data = {
|
||||
"id": 1,
|
||||
"name": "test-repo",
|
||||
"full_name": "test/test-repo",
|
||||
"description": "Test repository",
|
||||
"private": False,
|
||||
"fork": False,
|
||||
"html_url": "https://test-gitea.com/test/test-repo",
|
||||
"clone_url": "https://test-gitea.com/test/test-repo.git",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"owner": {
|
||||
"login": "test",
|
||||
"id": 1,
|
||||
"avatar_url": "https://test-gitea.com/avatars/1"
|
||||
},
|
||||
"permissions": {
|
||||
"admin": True,
|
||||
"push": True,
|
||||
"pull": True
|
||||
},
|
||||
"open_issues_count": 5,
|
||||
"stargazers_count": 10,
|
||||
"watchers_count": 3,
|
||||
"forks_count": 2,
|
||||
"size": 1024,
|
||||
"default_branch": "main",
|
||||
"archived": False,
|
||||
"disabled": False
|
||||
}
|
||||
|
||||
def with_name(self, name: str, owner: str = "test") -> "GiteaProjectResponseBuilder":
|
||||
"""Set repository name and owner."""
|
||||
self.project_data["name"] = name
|
||||
self.project_data["full_name"] = f"{owner}/{name}"
|
||||
self.project_data["html_url"] = f"https://test-gitea.com/{owner}/{name}"
|
||||
self.project_data["clone_url"] = f"https://test-gitea.com/{owner}/{name}.git"
|
||||
self.project_data["owner"]["login"] = owner
|
||||
return self
|
||||
|
||||
def with_description(self, description: str) -> "GiteaProjectResponseBuilder":
|
||||
"""Set repository description."""
|
||||
self.project_data["description"] = description
|
||||
return self
|
||||
|
||||
def with_visibility(self, private: bool) -> "GiteaProjectResponseBuilder":
|
||||
"""Set repository visibility."""
|
||||
self.project_data["private"] = private
|
||||
return self
|
||||
|
||||
def with_stats(self, open_issues: int = 5, stars: int = 10, watchers: int = 3, forks: int = 2) -> "GiteaProjectResponseBuilder":
|
||||
"""Set repository statistics."""
|
||||
self.project_data["open_issues_count"] = open_issues
|
||||
self.project_data["stargazers_count"] = stars
|
||||
self.project_data["watchers_count"] = watchers
|
||||
self.project_data["forks_count"] = forks
|
||||
return self
|
||||
|
||||
def with_permissions(self, admin: bool = True, push: bool = True, pull: bool = True) -> "GiteaProjectResponseBuilder":
|
||||
"""Set user permissions."""
|
||||
self.project_data["permissions"] = {
|
||||
"admin": admin,
|
||||
"push": push,
|
||||
"pull": pull
|
||||
}
|
||||
return self
|
||||
|
||||
def build(self) -> Dict[str, Any]:
|
||||
"""Build the final project data."""
|
||||
return self.project_data.copy()
|
||||
|
||||
|
||||
class GiteaMilestoneResponseBuilder:
|
||||
"""Builder for creating mock Gitea milestone responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.milestone_data = {
|
||||
"id": 1,
|
||||
"title": "Version 1.0",
|
||||
"description": "First release milestone",
|
||||
"state": "open",
|
||||
"open_issues": 5,
|
||||
"closed_issues": 3,
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"due_date": "2025-12-31T23:59:59Z"
|
||||
}
|
||||
|
||||
def with_id(self, id: int) -> "GiteaMilestoneResponseBuilder":
|
||||
"""Set milestone ID."""
|
||||
self.milestone_data["id"] = id
|
||||
return self
|
||||
|
||||
def with_title(self, title: str) -> "GiteaMilestoneResponseBuilder":
|
||||
"""Set milestone title."""
|
||||
self.milestone_data["title"] = title
|
||||
return self
|
||||
|
||||
def with_description(self, description: str) -> "GiteaMilestoneResponseBuilder":
|
||||
"""Set milestone description."""
|
||||
self.milestone_data["description"] = description
|
||||
return self
|
||||
|
||||
def with_state(self, state: str) -> "GiteaMilestoneResponseBuilder":
|
||||
"""Set milestone state."""
|
||||
if state not in ["open", "closed"]:
|
||||
raise ValueError("State must be 'open' or 'closed'")
|
||||
self.milestone_data["state"] = state
|
||||
return self
|
||||
|
||||
def with_issue_counts(self, open_issues: int, closed_issues: int) -> "GiteaMilestoneResponseBuilder":
|
||||
"""Set issue counts."""
|
||||
self.milestone_data["open_issues"] = open_issues
|
||||
self.milestone_data["closed_issues"] = closed_issues
|
||||
return self
|
||||
|
||||
def with_due_date(self, due_date: str) -> "GiteaMilestoneResponseBuilder":
|
||||
"""Set milestone due date."""
|
||||
self.milestone_data["due_date"] = due_date
|
||||
return self
|
||||
|
||||
def build(self) -> Dict[str, Any]:
|
||||
"""Build the final milestone data."""
|
||||
return self.milestone_data.copy()
|
||||
|
||||
|
||||
# Pre-built common responses
|
||||
SAMPLE_ISSUE_RESPONSE = (
|
||||
GiteaApiResponseBuilder()
|
||||
.with_number(123)
|
||||
.with_title("Sample Issue")
|
||||
.with_body("This is a sample issue for testing")
|
||||
.with_labels("bug", "priority:high", "status:in-progress")
|
||||
.with_milestone("Version 1.0")
|
||||
.with_assignees("testuser")
|
||||
.build()
|
||||
)
|
||||
|
||||
SAMPLE_PROJECT_RESPONSE = (
|
||||
GiteaProjectResponseBuilder()
|
||||
.with_name("sample-project", "testorg")
|
||||
.with_description("A sample project for testing")
|
||||
.with_stats(open_issues=10, stars=25, watchers=8, forks=3)
|
||||
.build()
|
||||
)
|
||||
|
||||
SAMPLE_MILESTONE_RESPONSE = (
|
||||
GiteaMilestoneResponseBuilder()
|
||||
.with_title("Version 2.0")
|
||||
.with_description("Second major release")
|
||||
.with_issue_counts(8, 12)
|
||||
.with_due_date("2025-06-30T23:59:59Z")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Error responses
|
||||
ERROR_RESPONSES = {
|
||||
"not_found": {
|
||||
"message": "404 Not Found",
|
||||
"documentation_url": "https://docs.gitea.io/en-us/api-usage/"
|
||||
},
|
||||
"unauthorized": {
|
||||
"message": "401 Unauthorized",
|
||||
"documentation_url": "https://docs.gitea.io/en-us/api-usage/"
|
||||
},
|
||||
"forbidden": {
|
||||
"message": "403 Forbidden",
|
||||
"documentation_url": "https://docs.gitea.io/en-us/api-usage/"
|
||||
},
|
||||
"validation_failed": {
|
||||
"message": "Validation Failed",
|
||||
"errors": [
|
||||
{
|
||||
"resource": "Issue",
|
||||
"field": "title",
|
||||
"code": "missing_field"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rate_limit": {
|
||||
"message": "API rate limit exceeded",
|
||||
"documentation_url": "https://docs.gitea.io/en-us/api-usage/"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_paginated_response(items: List[Dict[str, Any]], page: int = 1, per_page: int = 30) -> Dict[str, Any]:
|
||||
"""Create a paginated response wrapper."""
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_items = items[start_idx:end_idx]
|
||||
|
||||
return {
|
||||
"data": page_items,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": len(items),
|
||||
"total_pages": (len(items) + per_page - 1) // per_page,
|
||||
"has_next": end_idx < len(items),
|
||||
"has_prev": page > 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_bulk_issues(count: int, base_number: int = 1) -> List[Dict[str, Any]]:
|
||||
"""Create a list of test issues for bulk operations."""
|
||||
issues = []
|
||||
for i in range(count):
|
||||
issue = (
|
||||
GiteaApiResponseBuilder()
|
||||
.with_number(base_number + i)
|
||||
.with_title(f"Test Issue {base_number + i}")
|
||||
.with_body(f"Description for test issue {base_number + i}")
|
||||
.with_labels("test")
|
||||
.build()
|
||||
)
|
||||
issues.append(issue)
|
||||
return issues
|
||||
302
tests/fixtures/markdown_samples.py
vendored
Normal file
302
tests/fixtures/markdown_samples.py
vendored
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Markdown document builders and sample generators for testing.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
class MarkdownDocumentBuilder:
|
||||
"""Builder pattern for creating test markdown documents."""
|
||||
|
||||
def __init__(self):
|
||||
self.content_parts: List[str] = []
|
||||
self.metadata: Dict[str, str] = {}
|
||||
|
||||
def with_heading(self, text: str, level: int = 1) -> "MarkdownDocumentBuilder":
|
||||
"""Add a heading to the document."""
|
||||
if level < 1 or level > 6:
|
||||
raise ValueError("Heading level must be between 1 and 6")
|
||||
|
||||
heading_marker = "#" * level
|
||||
self.content_parts.append(f"{heading_marker} {text}")
|
||||
return self
|
||||
|
||||
def with_paragraph(self, text: str) -> "MarkdownDocumentBuilder":
|
||||
"""Add a paragraph to the document."""
|
||||
self.content_parts.append(text)
|
||||
return self
|
||||
|
||||
def with_list(self, items: List[str], ordered: bool = False) -> "MarkdownDocumentBuilder":
|
||||
"""Add a list to the document."""
|
||||
if ordered:
|
||||
list_items = [f"{i+1}. {item}" for i, item in enumerate(items)]
|
||||
else:
|
||||
list_items = [f"- {item}" for item in items]
|
||||
|
||||
self.content_parts.append("\n".join(list_items))
|
||||
return self
|
||||
|
||||
def with_code_block(self, code: str, language: str = "python") -> "MarkdownDocumentBuilder":
|
||||
"""Add a code block to the document."""
|
||||
self.content_parts.append(f"```{language}\n{code}\n```")
|
||||
return self
|
||||
|
||||
def with_link(self, text: str, url: str) -> "MarkdownDocumentBuilder":
|
||||
"""Add a link to the document."""
|
||||
self.content_parts.append(f"[{text}]({url})")
|
||||
return self
|
||||
|
||||
def with_metadata(self, key: str, value: str) -> "MarkdownDocumentBuilder":
|
||||
"""Add metadata (front matter) to the document."""
|
||||
self.metadata[key] = value
|
||||
return self
|
||||
|
||||
def with_table(self, headers: List[str], rows: List[List[str]]) -> "MarkdownDocumentBuilder":
|
||||
"""Add a table to the document."""
|
||||
table_lines = []
|
||||
|
||||
# Header row
|
||||
table_lines.append("| " + " | ".join(headers) + " |")
|
||||
|
||||
# Separator row
|
||||
table_lines.append("| " + " | ".join(["-" * len(header) for header in headers]) + " |")
|
||||
|
||||
# Data rows
|
||||
for row in rows:
|
||||
table_lines.append("| " + " | ".join(row) + " |")
|
||||
|
||||
self.content_parts.append("\n".join(table_lines))
|
||||
return self
|
||||
|
||||
def with_blockquote(self, text: str) -> "MarkdownDocumentBuilder":
|
||||
"""Add a blockquote to the document."""
|
||||
quote_lines = [f"> {line}" for line in text.split("\n")]
|
||||
self.content_parts.append("\n".join(quote_lines))
|
||||
return self
|
||||
|
||||
def build(self) -> str:
|
||||
"""Build the final markdown document."""
|
||||
content = "\n\n".join(self.content_parts)
|
||||
|
||||
if self.metadata:
|
||||
metadata_lines = [f"{k}: {v}" for k, v in self.metadata.items()]
|
||||
content = "---\n" + "\n".join(metadata_lines) + "\n---\n\n" + content
|
||||
|
||||
return content
|
||||
|
||||
|
||||
class LargeMarkdownGenerator:
|
||||
"""Generator for creating large markdown documents for performance testing."""
|
||||
|
||||
def __init__(self, seed: Optional[int] = None):
|
||||
self.random = random.Random(seed)
|
||||
|
||||
def generate_document(self, size: str = "1mb") -> str:
|
||||
"""Generate a large markdown document of specified size."""
|
||||
size_bytes = self._parse_size(size)
|
||||
builder = MarkdownDocumentBuilder()
|
||||
|
||||
# Add metadata
|
||||
builder.with_metadata("title", "Large Test Document")
|
||||
builder.with_metadata("author", "Test Generator")
|
||||
builder.with_metadata("size", size)
|
||||
|
||||
# Add content until we reach target size
|
||||
current_size = 0
|
||||
section_count = 0
|
||||
|
||||
while current_size < size_bytes:
|
||||
section_count += 1
|
||||
section_title = f"Section {section_count}"
|
||||
builder.with_heading(section_title, level=2)
|
||||
|
||||
# Add paragraphs
|
||||
for _ in range(self.random.randint(3, 8)):
|
||||
paragraph = self._generate_paragraph()
|
||||
builder.with_paragraph(paragraph)
|
||||
current_size += len(paragraph) + 2 # +2 for newlines
|
||||
|
||||
if current_size >= size_bytes:
|
||||
break
|
||||
|
||||
# Add a list occasionally
|
||||
if self.random.random() < 0.3:
|
||||
items = [self._generate_sentence() for _ in range(self.random.randint(3, 7))]
|
||||
builder.with_list(items)
|
||||
current_size += sum(len(item) for item in items) + len(items) * 3 # Approximate
|
||||
|
||||
# Add a code block occasionally
|
||||
if self.random.random() < 0.2:
|
||||
code = self._generate_code_block()
|
||||
builder.with_code_block(code)
|
||||
current_size += len(code) + 10 # +10 for code block markers
|
||||
|
||||
return builder.build()
|
||||
|
||||
def _parse_size(self, size: str) -> int:
|
||||
"""Parse size string (e.g., '1mb', '500kb') to bytes."""
|
||||
size = size.lower()
|
||||
if size.endswith("kb"):
|
||||
return int(size[:-2]) * 1024
|
||||
elif size.endswith("mb"):
|
||||
return int(size[:-2]) * 1024 * 1024
|
||||
elif size.endswith("gb"):
|
||||
return int(size[:-2]) * 1024 * 1024 * 1024
|
||||
else:
|
||||
return int(size)
|
||||
|
||||
def _generate_paragraph(self) -> str:
|
||||
"""Generate a paragraph of random text."""
|
||||
sentences = []
|
||||
for _ in range(self.random.randint(3, 8)):
|
||||
sentences.append(self._generate_sentence())
|
||||
return " ".join(sentences)
|
||||
|
||||
def _generate_sentence(self) -> str:
|
||||
"""Generate a random sentence."""
|
||||
words = []
|
||||
for _ in range(self.random.randint(5, 15)):
|
||||
words.append(self._generate_word())
|
||||
|
||||
sentence = " ".join(words).capitalize()
|
||||
return sentence + "."
|
||||
|
||||
def _generate_word(self) -> str:
|
||||
"""Generate a random word."""
|
||||
length = self.random.randint(3, 12)
|
||||
return "".join(self.random.choices(string.ascii_lowercase, k=length))
|
||||
|
||||
def _generate_code_block(self) -> str:
|
||||
"""Generate a random code block."""
|
||||
lines = []
|
||||
for _ in range(self.random.randint(5, 15)):
|
||||
line = self._generate_code_line()
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _generate_code_line(self) -> str:
|
||||
"""Generate a line of code-like text."""
|
||||
templates = [
|
||||
"def {func_name}({params}):",
|
||||
" return {expression}",
|
||||
"if {condition}:",
|
||||
" {statement}",
|
||||
"# {comment}",
|
||||
"class {class_name}:",
|
||||
" self.{attr} = {value}",
|
||||
"import {module}",
|
||||
"from {module} import {name}",
|
||||
]
|
||||
|
||||
template = self.random.choice(templates)
|
||||
variables = {
|
||||
"func_name": self._generate_word(),
|
||||
"params": ", ".join([self._generate_word() for _ in range(self.random.randint(0, 3))]),
|
||||
"expression": f"{self._generate_word()}({self._generate_word()})",
|
||||
"condition": f"{self._generate_word()} == {self.random.randint(1, 100)}",
|
||||
"statement": f"{self._generate_word()} = {self.random.randint(1, 100)}",
|
||||
"comment": " ".join([self._generate_word() for _ in range(self.random.randint(2, 6))]),
|
||||
"class_name": self._generate_word().capitalize(),
|
||||
"attr": self._generate_word(),
|
||||
"value": str(self.random.randint(1, 100)),
|
||||
"module": self._generate_word(),
|
||||
"name": self._generate_word(),
|
||||
}
|
||||
|
||||
return template.format(**variables)
|
||||
|
||||
|
||||
# Pre-built sample documents
|
||||
SAMPLE_SIMPLE_DOCUMENT = """# Simple Document
|
||||
|
||||
This is a simple test document.
|
||||
|
||||
## Features
|
||||
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
"""
|
||||
|
||||
SAMPLE_COMPLEX_DOCUMENT = (
|
||||
MarkdownDocumentBuilder()
|
||||
.with_metadata("title", "Complex Test Document")
|
||||
.with_metadata("author", "Test Suite")
|
||||
.with_metadata("tags", "test, complex, sample")
|
||||
.with_heading("Complex Test Document")
|
||||
.with_paragraph("This is a complex test document with various markdown features.")
|
||||
.with_heading("Table of Contents", level=2)
|
||||
.with_list([
|
||||
"Introduction",
|
||||
"Features",
|
||||
"Examples",
|
||||
"Conclusion"
|
||||
], ordered=True)
|
||||
.with_heading("Introduction", level=2)
|
||||
.with_paragraph("This document demonstrates various markdown features.")
|
||||
.with_blockquote("This is an important note about the document.")
|
||||
.with_heading("Features", level=2)
|
||||
.with_list([
|
||||
"**Bold text**",
|
||||
"*Italic text*",
|
||||
"`Code inline`",
|
||||
"[Links](https://example.com)"
|
||||
])
|
||||
.with_heading("Code Example", level=3)
|
||||
.with_code_block('''def hello_world():
|
||||
"""Print hello world message."""
|
||||
print("Hello, World!")
|
||||
return "success"''')
|
||||
.with_heading("Data Table", level=3)
|
||||
.with_table(
|
||||
["Name", "Type", "Description"],
|
||||
[
|
||||
["title", "string", "Document title"],
|
||||
["author", "string", "Document author"],
|
||||
["tags", "array", "Document tags"]
|
||||
]
|
||||
)
|
||||
.with_heading("Conclusion", level=2)
|
||||
.with_paragraph("This document shows the power of markdown for documentation.")
|
||||
.build()
|
||||
)
|
||||
|
||||
SAMPLE_TECHNICAL_DOCUMENT = (
|
||||
MarkdownDocumentBuilder()
|
||||
.with_metadata("title", "API Documentation")
|
||||
.with_metadata("version", "1.0.0")
|
||||
.with_metadata("category", "technical")
|
||||
.with_heading("API Documentation")
|
||||
.with_paragraph("This document describes the REST API endpoints.")
|
||||
.with_heading("Authentication", level=2)
|
||||
.with_paragraph("All API requests require authentication via API key.")
|
||||
.with_code_block('''curl -H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
https://api.example.com/v1/endpoint''', "bash")
|
||||
.with_heading("Endpoints", level=2)
|
||||
.with_heading("GET /users", level=3)
|
||||
.with_paragraph("Retrieve a list of users.")
|
||||
.with_table(
|
||||
["Parameter", "Type", "Required", "Description"],
|
||||
[
|
||||
["limit", "integer", "No", "Maximum number of results"],
|
||||
["offset", "integer", "No", "Number of results to skip"],
|
||||
["filter", "string", "No", "Filter criteria"]
|
||||
]
|
||||
)
|
||||
.with_heading("Response", level=4)
|
||||
.with_code_block('''{
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": 10
|
||||
}''', "json")
|
||||
.build()
|
||||
)
|
||||
3
tests/integration/__init__.py
Normal file
3
tests/integration/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Integration tests for MarkiTect components.
|
||||
"""
|
||||
3
tests/integration/repositories/__init__.py
Normal file
3
tests/integration/repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Integration tests for repository implementations.
|
||||
"""
|
||||
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Integration tests for document repository with real database.
|
||||
|
||||
Demonstrates:
|
||||
- Real database integration testing
|
||||
- Transaction testing
|
||||
- Performance validation
|
||||
- Error scenario handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sqlite3
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from tests.fixtures.markdown_samples import MarkdownDocumentBuilder, SAMPLE_COMPLEX_DOCUMENT
|
||||
from tests.utils.assertions import assert_file_exists, assert_performance_within_bounds
|
||||
|
||||
|
||||
class MockDocument:
|
||||
"""Mock document model for testing."""
|
||||
|
||||
def __init__(self, filename: str, content: str, ast_data: dict = None):
|
||||
self.filename = filename
|
||||
self.content = content
|
||||
self.ast_data = ast_data or {}
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class MockDocumentRepository:
|
||||
"""Mock document repository that simulates real database operations."""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self.db_path = db_path
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""Initialize database schema."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT UNIQUE NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
ast_data TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_filename
|
||||
ON documents(filename)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
async def store_document(self, document: MockDocument) -> int:
|
||||
"""Store a document in the database."""
|
||||
await asyncio.sleep(0.001) # Simulate async database operation
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT INTO documents (filename, content, ast_data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (
|
||||
document.filename,
|
||||
document.content,
|
||||
str(document.ast_data),
|
||||
document.created_at.isoformat(),
|
||||
document.updated_at.isoformat()
|
||||
))
|
||||
|
||||
document_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
return document_id
|
||||
|
||||
except sqlite3.IntegrityError as e:
|
||||
conn.rollback()
|
||||
raise ValueError(f"Document with filename '{document.filename}' already exists") from e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def get_document(self, document_id: int) -> MockDocument:
|
||||
"""Retrieve a document by ID."""
|
||||
await asyncio.sleep(0.001) # Simulate async database operation
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT filename, content, ast_data, created_at, updated_at
|
||||
FROM documents WHERE id = ?
|
||||
""", (document_id,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Document with ID {document_id} not found")
|
||||
|
||||
filename, content, ast_data, created_at, updated_at = row
|
||||
document = MockDocument(filename, content, eval(ast_data) if ast_data else {})
|
||||
document.created_at = datetime.fromisoformat(created_at)
|
||||
document.updated_at = datetime.fromisoformat(updated_at)
|
||||
|
||||
return document
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def update_document(self, document_id: int, content: str, ast_data: dict) -> None:
|
||||
"""Update document content and AST data."""
|
||||
await asyncio.sleep(0.001) # Simulate async database operation
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
UPDATE documents
|
||||
SET content = ?, ast_data = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", (
|
||||
content,
|
||||
str(ast_data),
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
document_id
|
||||
))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError(f"Document with ID {document_id} not found")
|
||||
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def delete_document(self, document_id: int) -> None:
|
||||
"""Delete a document."""
|
||||
await asyncio.sleep(0.001) # Simulate async database operation
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("DELETE FROM documents WHERE id = ?", (document_id,))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError(f"Document with ID {document_id} not found")
|
||||
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def list_all_documents(self):
|
||||
"""List all documents."""
|
||||
await asyncio.sleep(0.001) # Simulate async database operation
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT id, filename, created_at, updated_at
|
||||
FROM documents ORDER BY created_at DESC
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row[0],
|
||||
"filename": row[1],
|
||||
"created_at": row[2],
|
||||
"updated_at": row[3]
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def search_content(self, search_term: str):
|
||||
"""Search documents by content."""
|
||||
await asyncio.sleep(0.005) # Simulate more expensive search operation
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT id, filename, content
|
||||
FROM documents
|
||||
WHERE content LIKE ?
|
||||
ORDER BY filename
|
||||
""", (f"%{search_term}%",))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row[0],
|
||||
"filename": row[1],
|
||||
"content": row[2]
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def close(self):
|
||||
"""Close repository (cleanup)."""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db_path(test_workspace):
|
||||
"""Provide test database path."""
|
||||
return test_workspace / "integration_test.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def document_repository(test_db_path):
|
||||
"""Provide document repository with real database."""
|
||||
repo = MockDocumentRepository(test_db_path)
|
||||
yield repo
|
||||
repo.close()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDocumentRepositoryIntegration:
|
||||
"""Integration tests for document repository with real database."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_and_retrieve_document(self, document_repository, test_db_path):
|
||||
"""Test storing and retrieving a document."""
|
||||
# Arrange
|
||||
assert_file_exists(test_db_path)
|
||||
|
||||
document = MockDocument(
|
||||
filename="test.md",
|
||||
content="# Test Document\nThis is a test.",
|
||||
ast_data={"type": "document", "children": []}
|
||||
)
|
||||
|
||||
# Act
|
||||
document_id = await document_repository.store_document(document)
|
||||
retrieved = await document_repository.get_document(document_id)
|
||||
|
||||
# Assert
|
||||
assert isinstance(document_id, int)
|
||||
assert document_id > 0
|
||||
assert retrieved.filename == "test.md"
|
||||
assert retrieved.content == "# Test Document\nThis is a test."
|
||||
assert retrieved.ast_data["type"] == "document"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_duplicate_filename_raises_error(self, document_repository):
|
||||
"""Test that storing duplicate filename raises error."""
|
||||
# Arrange
|
||||
document1 = MockDocument("duplicate.md", "Content 1")
|
||||
document2 = MockDocument("duplicate.md", "Content 2")
|
||||
|
||||
# Act
|
||||
await document_repository.store_document(document1)
|
||||
|
||||
# Assert
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await document_repository.store_document(document2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_document_content(self, document_repository):
|
||||
"""Test updating document content and AST."""
|
||||
# Arrange
|
||||
document = MockDocument("update.md", "Original content")
|
||||
document_id = await document_repository.store_document(document)
|
||||
|
||||
# Act
|
||||
new_content = "Updated content"
|
||||
new_ast = {"type": "document", "updated": True}
|
||||
await document_repository.update_document(document_id, new_content, new_ast)
|
||||
|
||||
# Verify
|
||||
updated = await document_repository.get_document(document_id)
|
||||
assert updated.content == "Updated content"
|
||||
assert updated.ast_data["updated"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_document(self, document_repository):
|
||||
"""Test deleting a document."""
|
||||
# Arrange
|
||||
document = MockDocument("delete.md", "To be deleted")
|
||||
document_id = await document_repository.store_document(document)
|
||||
|
||||
# Verify document exists
|
||||
retrieved = await document_repository.get_document(document_id)
|
||||
assert retrieved.filename == "delete.md"
|
||||
|
||||
# Act
|
||||
await document_repository.delete_document(document_id)
|
||||
|
||||
# Assert
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await document_repository.get_document(document_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_documents(self, document_repository):
|
||||
"""Test listing all documents."""
|
||||
# Arrange - Store multiple documents
|
||||
documents = [
|
||||
MockDocument("doc1.md", "Content 1"),
|
||||
MockDocument("doc2.md", "Content 2"),
|
||||
MockDocument("doc3.md", "Content 3")
|
||||
]
|
||||
|
||||
for doc in documents:
|
||||
await document_repository.store_document(doc)
|
||||
|
||||
# Act
|
||||
all_docs = await document_repository.list_all_documents()
|
||||
|
||||
# Assert
|
||||
assert len(all_docs) == 3
|
||||
filenames = {doc["filename"] for doc in all_docs}
|
||||
expected_filenames = {"doc1.md", "doc2.md", "doc3.md"}
|
||||
assert filenames == expected_filenames
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_content(self, document_repository):
|
||||
"""Test content search functionality."""
|
||||
# Arrange
|
||||
documents = [
|
||||
MockDocument("api.md", "API documentation for REST endpoints"),
|
||||
MockDocument("guide.md", "User guide for getting started"),
|
||||
MockDocument("readme.md", "Project README with API examples")
|
||||
]
|
||||
|
||||
for doc in documents:
|
||||
await document_repository.store_document(doc)
|
||||
|
||||
# Act
|
||||
api_results = await document_repository.search_content("API")
|
||||
guide_results = await document_repository.search_content("guide")
|
||||
|
||||
# Assert
|
||||
assert len(api_results) == 2 # api.md and readme.md
|
||||
api_filenames = {result["filename"] for result in api_results}
|
||||
assert api_filenames == {"api.md", "readme.md"}
|
||||
|
||||
assert len(guide_results) == 1 # guide.md only
|
||||
assert guide_results[0]["filename"] == "guide.md"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_operations_performance(self, document_repository, performance_timer):
|
||||
"""Test performance of bulk operations."""
|
||||
# Arrange
|
||||
documents = []
|
||||
for i in range(50):
|
||||
content = (MarkdownDocumentBuilder()
|
||||
.with_heading(f"Document {i}")
|
||||
.with_paragraph(f"Content for document {i}")
|
||||
.build())
|
||||
documents.append(MockDocument(f"bulk_{i}.md", content))
|
||||
|
||||
# Act - Bulk storage
|
||||
performance_timer.start()
|
||||
document_ids = []
|
||||
for doc in documents:
|
||||
doc_id = await document_repository.store_document(doc)
|
||||
document_ids.append(doc_id)
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(document_ids) == 50
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 5.0, "bulk document storage")
|
||||
|
||||
# Act - Bulk retrieval
|
||||
performance_timer.start()
|
||||
retrieved_docs = []
|
||||
for doc_id in document_ids:
|
||||
doc = await document_repository.get_document(doc_id)
|
||||
retrieved_docs.append(doc)
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(retrieved_docs) == 50
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 3.0, "bulk document retrieval")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_operations(self, document_repository):
|
||||
"""Test concurrent database operations."""
|
||||
# Arrange
|
||||
async def store_document(index):
|
||||
content = f"# Document {index}\nContent for document {index}"
|
||||
doc = MockDocument(f"concurrent_{index}.md", content)
|
||||
return await document_repository.store_document(doc)
|
||||
|
||||
# Act - Concurrent storage
|
||||
tasks = [store_document(i) for i in range(20)]
|
||||
document_ids = await asyncio.gather(*tasks)
|
||||
|
||||
# Assert
|
||||
assert len(document_ids) == 20
|
||||
assert len(set(document_ids)) == 20 # All IDs should be unique
|
||||
|
||||
# Verify all documents are accessible
|
||||
all_docs = await document_repository.list_all_documents()
|
||||
assert len(all_docs) == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_like_behavior(self, document_repository):
|
||||
"""Test error handling doesn't leave database in inconsistent state."""
|
||||
# Arrange - Store initial document
|
||||
doc1 = MockDocument("initial.md", "Initial content")
|
||||
doc_id = await document_repository.store_document(doc1)
|
||||
|
||||
# Act - Try to update with invalid ID (should fail)
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await document_repository.update_document(99999, "Invalid update", {})
|
||||
|
||||
# Assert - Original document should be unchanged
|
||||
retrieved = await document_repository.get_document(doc_id)
|
||||
assert retrieved.content == "Initial content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_document_handling(self, document_repository, performance_timer):
|
||||
"""Test handling of large documents."""
|
||||
# Arrange - Create large document content
|
||||
from tests.fixtures.markdown_samples import LargeMarkdownGenerator
|
||||
generator = LargeMarkdownGenerator(seed=42)
|
||||
large_content = generator.generate_document(size="100kb")
|
||||
|
||||
document = MockDocument("large.md", large_content)
|
||||
|
||||
# Act
|
||||
performance_timer.start()
|
||||
document_id = await document_repository.store_document(document)
|
||||
retrieved = await document_repository.get_document(document_id)
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert document_id > 0
|
||||
assert len(retrieved.content) > 100000 # At least 100KB
|
||||
assert retrieved.content == large_content
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 1.0, "large document operations")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_search_performance_with_large_dataset(self, document_repository, performance_timer):
|
||||
"""Test search performance with large dataset."""
|
||||
# Arrange - Create many documents with searchable content
|
||||
search_terms = ["API", "database", "testing", "performance", "integration"]
|
||||
|
||||
documents = []
|
||||
for i in range(100):
|
||||
term = search_terms[i % len(search_terms)]
|
||||
content = (MarkdownDocumentBuilder()
|
||||
.with_heading(f"Document {i}")
|
||||
.with_paragraph(f"This document covers {term} functionality in detail.")
|
||||
.with_paragraph("Additional content for search testing.")
|
||||
.build())
|
||||
documents.append(MockDocument(f"search_{i}.md", content))
|
||||
|
||||
# Store all documents
|
||||
for doc in documents:
|
||||
await document_repository.store_document(doc)
|
||||
|
||||
# Act - Perform searches
|
||||
performance_timer.start()
|
||||
api_results = await document_repository.search_content("API")
|
||||
database_results = await document_repository.search_content("database")
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert len(api_results) >= 20 # Should find multiple documents
|
||||
assert len(database_results) >= 20
|
||||
assert_performance_within_bounds(performance_timer.elapsed, 2.0, "search operations")
|
||||
45
tests/requirements-test.txt
Normal file
45
tests/requirements-test.txt
Normal file
@@ -0,0 +1,45 @@
|
||||
# Testing framework dependencies
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.11.0
|
||||
pytest-xdist>=3.3.0
|
||||
pytest-timeout>=2.1.0
|
||||
pytest-benchmark>=4.0.0
|
||||
|
||||
# Property-based testing
|
||||
hypothesis>=6.82.0
|
||||
|
||||
# HTTP mocking
|
||||
aioresponses>=0.7.4
|
||||
responses>=0.23.0
|
||||
|
||||
# Contract testing
|
||||
pact-python>=2.0.0
|
||||
|
||||
# Mutation testing
|
||||
mutmut>=2.4.0
|
||||
|
||||
# Test data generation
|
||||
factory-boy>=3.3.0
|
||||
faker>=19.0.0
|
||||
|
||||
# Performance monitoring
|
||||
psutil>=5.9.0
|
||||
|
||||
# Code quality
|
||||
flake8>=6.0.0
|
||||
black>=23.0.0
|
||||
isort>=5.12.0
|
||||
mypy>=1.4.0
|
||||
|
||||
# Test reporting
|
||||
pytest-html>=3.2.0
|
||||
coverage[toml]>=7.2.0
|
||||
|
||||
# Database testing
|
||||
pytest-postgresql>=5.0.0
|
||||
pytest-sqlite>=0.5.0
|
||||
|
||||
# Async utilities
|
||||
anyio>=3.7.0
|
||||
3
tests/unit/application/__init__.py
Normal file
3
tests/unit/application/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Unit tests for application services layer.
|
||||
"""
|
||||
410
tests/unit/application/test_issue_application_service.py
Normal file
410
tests/unit/application/test_issue_application_service.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
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"
|
||||
287
tests/unit/domain/issues/test_issue_models.py
Normal file
287
tests/unit/domain/issues/test_issue_models.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Unit tests for Issue domain models.
|
||||
|
||||
Tests pure business logic with no external dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from domain.issues.models import Issue, Label, IssueState, LabelCategories
|
||||
from domain.issues.exceptions import IssueStateError
|
||||
|
||||
|
||||
class TestLabel:
|
||||
"""Test Label value object."""
|
||||
|
||||
def test_label_creation(self):
|
||||
# Arrange & Act
|
||||
label = Label(name="bug", color="#ff0000", description="Bug label")
|
||||
|
||||
# Assert
|
||||
assert label.name == "bug"
|
||||
assert label.color == "#ff0000"
|
||||
assert label.description == "Bug label"
|
||||
|
||||
def test_is_state_label(self):
|
||||
# Arrange
|
||||
state_label = Label("status:in-progress")
|
||||
regular_label = Label("bug")
|
||||
|
||||
# Act & Assert
|
||||
assert state_label.is_state_label() is True
|
||||
assert regular_label.is_state_label() is False
|
||||
|
||||
def test_is_priority_label(self):
|
||||
# Arrange
|
||||
priority_label = Label("priority:high")
|
||||
regular_label = Label("bug")
|
||||
|
||||
# Act & Assert
|
||||
assert priority_label.is_priority_label() is True
|
||||
assert regular_label.is_priority_label() is False
|
||||
|
||||
def test_is_type_label(self):
|
||||
# Arrange
|
||||
type_label = Label("bug")
|
||||
priority_label = Label("priority:high")
|
||||
|
||||
# Act & Assert
|
||||
assert type_label.is_type_label() is True
|
||||
assert priority_label.is_type_label() is False
|
||||
|
||||
@pytest.mark.parametrize("label_name,expected", [
|
||||
("bug", True),
|
||||
("enhancement", True),
|
||||
("feature", True),
|
||||
("documentation", True),
|
||||
("custom-label", False),
|
||||
("priority:high", False)
|
||||
])
|
||||
def test_type_label_recognition(self, label_name, expected):
|
||||
# Arrange
|
||||
label = Label(label_name)
|
||||
|
||||
# Act & Assert
|
||||
assert label.is_type_label() == expected
|
||||
|
||||
|
||||
class TestIssue:
|
||||
"""Test Issue aggregate root."""
|
||||
|
||||
def test_issue_creation_with_valid_data(self):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow()
|
||||
updated_at = datetime.utcnow()
|
||||
labels = [Label("bug"), Label("priority:high")]
|
||||
|
||||
# Act
|
||||
issue = Issue(
|
||||
number=123,
|
||||
title="Test Issue",
|
||||
state=IssueState.OPEN,
|
||||
labels=labels,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue.number == 123
|
||||
assert issue.title == "Test Issue"
|
||||
assert issue.state == IssueState.OPEN
|
||||
assert len(issue.labels) == 2
|
||||
assert issue.created_at == created_at
|
||||
assert issue.updated_at == updated_at
|
||||
|
||||
def test_categorize_labels_correctly_separates_types(self):
|
||||
# Arrange
|
||||
labels = [
|
||||
Label("bug"), # type label
|
||||
Label("priority:high"), # priority label
|
||||
Label("status:in-progress"), # state label
|
||||
Label("documentation"), # type label
|
||||
Label("custom-label") # other label
|
||||
]
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=labels,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
categories = issue.categorize_labels()
|
||||
|
||||
# Assert
|
||||
assert "bug" in categories.type_labels
|
||||
assert "documentation" in categories.type_labels
|
||||
assert "priority:high" in categories.priority_labels
|
||||
assert "status:in-progress" in categories.state_labels
|
||||
assert "custom-label" in categories.other_labels
|
||||
|
||||
def test_close_issue_changes_state_and_sets_closed_at(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.close()
|
||||
|
||||
# Assert
|
||||
assert issue.state == IssueState.CLOSED
|
||||
assert issue.closed_at is not None
|
||||
assert isinstance(issue.closed_at, datetime)
|
||||
|
||||
def test_close_already_closed_issue_raises_error(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueStateError) as exc_info:
|
||||
issue.close()
|
||||
|
||||
assert "Issue is already closed" in str(exc_info.value)
|
||||
assert exc_info.value.current_state == "closed"
|
||||
assert exc_info.value.attempted_state == "closed"
|
||||
|
||||
def test_reopen_closed_issue_changes_state_and_clears_closed_at(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.reopen()
|
||||
|
||||
# Assert
|
||||
assert issue.state == IssueState.OPEN
|
||||
assert issue.closed_at is None
|
||||
|
||||
def test_reopen_open_issue_raises_error(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueStateError) as exc_info:
|
||||
issue.reopen()
|
||||
|
||||
assert "Issue is not closed" in str(exc_info.value)
|
||||
|
||||
def test_add_label_to_issue(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = Label("priority:high")
|
||||
|
||||
# Act
|
||||
issue.add_label(new_label)
|
||||
|
||||
# Assert
|
||||
assert len(issue.labels) == 2
|
||||
assert new_label in issue.labels
|
||||
|
||||
def test_add_duplicate_label_does_not_duplicate(self):
|
||||
# Arrange
|
||||
label = Label("bug")
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[label],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.add_label(label)
|
||||
|
||||
# Assert
|
||||
assert len(issue.labels) == 1
|
||||
|
||||
def test_remove_label_from_issue(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug"), Label("priority:high")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.remove_label("bug")
|
||||
|
||||
# Assert
|
||||
assert len(issue.labels) == 1
|
||||
assert not any(label.name == "bug" for label in issue.labels)
|
||||
|
||||
def test_has_label_returns_correct_value(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug"), Label("priority:high")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert issue.has_label("bug") is True
|
||||
assert issue.has_label("priority:high") is True
|
||||
assert issue.has_label("enhancement") is False
|
||||
|
||||
|
||||
class TestLabelCategories:
|
||||
"""Test LabelCategories value object."""
|
||||
|
||||
def test_label_categories_creation(self):
|
||||
# Arrange & Act
|
||||
categories = LabelCategories(
|
||||
state_labels=["status:open"],
|
||||
priority_labels=["priority:high"],
|
||||
type_labels=["bug"],
|
||||
other_labels=["custom"]
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert categories.state_labels == ["status:open"]
|
||||
assert categories.priority_labels == ["priority:high"]
|
||||
assert categories.type_labels == ["bug"]
|
||||
assert categories.other_labels == ["custom"]
|
||||
368
tests/unit/domain/issues/test_issue_services.py
Normal file
368
tests/unit/domain/issues/test_issue_services.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Unit tests for Issue domain services.
|
||||
|
||||
Tests business logic in issue services with no external dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from domain.issues.models import Issue, Label, IssueState
|
||||
from domain.issues.services import IssueStatusService, IssueValidationService
|
||||
from domain.issues.exceptions import IssueValidationError
|
||||
|
||||
|
||||
class TestIssueStatusService:
|
||||
"""Test business logic in issue status service."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return IssueStatusService()
|
||||
|
||||
def test_determine_kanban_column_for_closed_issue(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Closed Issue",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Done"]}
|
||||
|
||||
# Act
|
||||
column = service.determine_kanban_column(issue, project_info)
|
||||
|
||||
# Assert
|
||||
assert column == "Done"
|
||||
|
||||
@pytest.mark.parametrize("status_label,expected_column", [
|
||||
("status:in-progress", "In Progress"),
|
||||
("status:review", "Review"),
|
||||
("status:blocked", "Blocked"),
|
||||
("status:ready", "Ready"),
|
||||
])
|
||||
def test_determine_kanban_column_based_on_status_labels(self, service, status_label, expected_column):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test Issue",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label(status_label)],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Blocked", "Ready", "Done"]}
|
||||
|
||||
# Act
|
||||
column = service.determine_kanban_column(issue, project_info)
|
||||
|
||||
# Assert
|
||||
assert column == expected_column
|
||||
|
||||
def test_determine_kanban_column_defaults_to_todo(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="New Issue",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")], # No status label
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Done"]}
|
||||
|
||||
# Act
|
||||
column = service.determine_kanban_column(issue, project_info)
|
||||
|
||||
# Assert
|
||||
assert column == "Todo"
|
||||
|
||||
@pytest.mark.parametrize("priority_label,expected_level", [
|
||||
("priority:low", "Low"),
|
||||
("priority:medium", "Medium"),
|
||||
("priority:high", "High"),
|
||||
("priority:critical", "Critical"),
|
||||
])
|
||||
def test_extract_priority_info_with_priority_labels(self, service, priority_label, expected_level):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label(priority_label)],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
priority_info = service.extract_priority_info(issue)
|
||||
|
||||
# Assert
|
||||
assert priority_info["level"] == expected_level
|
||||
assert priority_info["label"] == priority_label
|
||||
|
||||
def test_extract_priority_info_defaults_to_medium(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")], # No priority label
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
priority_info = service.extract_priority_info(issue)
|
||||
|
||||
# Assert
|
||||
assert priority_info["level"] == "Medium"
|
||||
assert priority_info["label"] is None
|
||||
|
||||
def test_extract_state_info_for_open_issue(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("status:in-progress")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
state_info = service.extract_state_info(issue)
|
||||
|
||||
# Assert
|
||||
assert state_info["state"] == "open"
|
||||
assert state_info["state_labels"] == ["status:in-progress"]
|
||||
assert state_info["is_closed"] is False
|
||||
assert state_info["closed_at"] is None
|
||||
|
||||
def test_extract_state_info_for_closed_issue(self, service):
|
||||
# Arrange
|
||||
closed_at = datetime.utcnow()
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=closed_at
|
||||
)
|
||||
|
||||
# Act
|
||||
state_info = service.extract_state_info(issue)
|
||||
|
||||
# Assert
|
||||
assert state_info["state"] == "closed"
|
||||
assert state_info["is_closed"] is True
|
||||
assert state_info["closed_at"] == closed_at.isoformat()
|
||||
|
||||
def test_calculate_issue_age_days(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=5)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
age_days = service.calculate_issue_age_days(issue)
|
||||
|
||||
# Assert
|
||||
assert age_days == 5
|
||||
|
||||
def test_is_stale_issue_with_old_open_issue(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=45)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
|
||||
|
||||
# Assert
|
||||
assert is_stale is True
|
||||
|
||||
def test_is_stale_issue_with_recent_open_issue(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=15)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
|
||||
|
||||
# Assert
|
||||
assert is_stale is False
|
||||
|
||||
def test_is_stale_issue_with_closed_issue_never_stale(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=100)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
|
||||
|
||||
# Assert
|
||||
assert is_stale is False
|
||||
|
||||
|
||||
class TestIssueValidationService:
|
||||
"""Test business logic in issue validation service."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return IssueValidationService()
|
||||
|
||||
def test_validate_issue_creation_with_valid_data(self, service):
|
||||
# Arrange
|
||||
title = "Valid Issue Title"
|
||||
labels = ["bug", "priority:high"]
|
||||
|
||||
# Act & Assert - Should not raise exception
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
def test_validate_issue_creation_with_empty_title_raises_error(self, service):
|
||||
# Arrange
|
||||
title = ""
|
||||
labels = ["bug"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue title cannot be empty" in str(exc_info.value)
|
||||
assert exc_info.value.field == "title"
|
||||
|
||||
def test_validate_issue_creation_with_whitespace_only_title_raises_error(self, service):
|
||||
# Arrange
|
||||
title = " "
|
||||
labels = ["bug"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue title cannot be empty" in str(exc_info.value)
|
||||
|
||||
def test_validate_issue_creation_with_too_long_title_raises_error(self, service):
|
||||
# Arrange
|
||||
title = "x" * 256 # Too long
|
||||
labels = ["bug"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue title cannot exceed 255 characters" in str(exc_info.value)
|
||||
|
||||
def test_validate_issue_creation_with_multiple_priority_labels_raises_error(self, service):
|
||||
# Arrange
|
||||
title = "Valid Title"
|
||||
labels = ["bug", "priority:high", "priority:low"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue cannot have multiple priority labels" in str(exc_info.value)
|
||||
assert exc_info.value.field == "labels"
|
||||
|
||||
def test_validate_issue_creation_with_multiple_state_labels_raises_error(self, service):
|
||||
# Arrange
|
||||
title = "Valid Title"
|
||||
labels = ["bug", "status:open", "status:in-progress"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue cannot have multiple state labels" in str(exc_info.value)
|
||||
|
||||
def test_validate_title_update_with_valid_title(self, service):
|
||||
# Arrange
|
||||
new_title = "Updated Title"
|
||||
|
||||
# Act & Assert - Should not raise exception
|
||||
service.validate_title_update(new_title)
|
||||
|
||||
def test_validate_label_addition_to_issue_without_conflicts(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = "enhancement"
|
||||
|
||||
# Act & Assert - Should not raise exception
|
||||
service.validate_label_addition(issue, new_label)
|
||||
|
||||
def test_validate_label_addition_with_duplicate_label_raises_error(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = "bug"
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_label_addition(issue, new_label)
|
||||
|
||||
assert "Issue already has label 'bug'" in str(exc_info.value)
|
||||
|
||||
def test_validate_label_addition_with_conflicting_priority_raises_error(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("priority:high")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = "priority:low"
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_label_addition(issue, new_label)
|
||||
|
||||
assert "Issue already has priority label" in str(exc_info.value)
|
||||
assert "Cannot add 'priority:low'" in str(exc_info.value)
|
||||
607
tests/unit/domain/projects/test_project_models.py
Normal file
607
tests/unit/domain/projects/test_project_models.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""
|
||||
Unit tests for Project domain models.
|
||||
|
||||
Tests pure business logic with no external dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from domain.projects.models import Project, Milestone, ProjectState
|
||||
from domain.projects.exceptions import MilestoneError
|
||||
|
||||
|
||||
class TestMilestone:
|
||||
"""Test Milestone entity."""
|
||||
|
||||
def test_milestone_creation(self):
|
||||
# Arrange
|
||||
due_date = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
# Act
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Version 1.0",
|
||||
description="First release",
|
||||
due_date=due_date,
|
||||
state="open",
|
||||
open_issues=5,
|
||||
closed_issues=3
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert milestone.id == 1
|
||||
assert milestone.title == "Version 1.0"
|
||||
assert milestone.description == "First release"
|
||||
assert milestone.due_date == due_date
|
||||
assert milestone.state == "open"
|
||||
assert milestone.open_issues == 5
|
||||
assert milestone.closed_issues == 3
|
||||
|
||||
def test_completion_percentage_calculation(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=2,
|
||||
closed_issues=8
|
||||
)
|
||||
|
||||
# Act
|
||||
percentage = milestone.completion_percentage
|
||||
|
||||
# Assert
|
||||
assert percentage == 80.0 # 8/(2+8) * 100
|
||||
|
||||
def test_completion_percentage_with_no_issues(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=0,
|
||||
closed_issues=0
|
||||
)
|
||||
|
||||
# Act
|
||||
percentage = milestone.completion_percentage
|
||||
|
||||
# Assert
|
||||
assert percentage == 0.0
|
||||
|
||||
def test_total_issues_property(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=3,
|
||||
closed_issues=7
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.total_issues == 10
|
||||
|
||||
def test_is_overdue_with_past_due_date(self):
|
||||
# Arrange
|
||||
past_date = datetime.utcnow() - timedelta(days=1)
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=past_date,
|
||||
state="open",
|
||||
open_issues=1,
|
||||
closed_issues=0
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.is_overdue() is True
|
||||
|
||||
def test_is_overdue_with_future_due_date(self):
|
||||
# Arrange
|
||||
future_date = datetime.utcnow() + timedelta(days=1)
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=future_date,
|
||||
state="open",
|
||||
open_issues=1,
|
||||
closed_issues=0
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.is_overdue() is False
|
||||
|
||||
def test_is_overdue_with_no_due_date(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=1,
|
||||
closed_issues=0
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.is_overdue() is False
|
||||
|
||||
def test_is_overdue_with_closed_milestone(self):
|
||||
# Arrange
|
||||
past_date = datetime.utcnow() - timedelta(days=1)
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=past_date,
|
||||
state="closed",
|
||||
open_issues=0,
|
||||
closed_issues=5
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.is_overdue() is False
|
||||
|
||||
def test_is_completed_with_closed_state(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="closed",
|
||||
open_issues=0,
|
||||
closed_issues=5
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.is_completed() is True
|
||||
|
||||
def test_is_completed_with_100_percent_completion(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=0,
|
||||
closed_issues=5
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.is_completed() is True
|
||||
|
||||
def test_is_completed_with_partial_completion(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=2,
|
||||
closed_issues=3
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert milestone.is_completed() is False
|
||||
|
||||
def test_add_issue_increments_open_count(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=3,
|
||||
closed_issues=2
|
||||
)
|
||||
|
||||
# Act
|
||||
milestone.add_issue()
|
||||
|
||||
# Assert
|
||||
assert milestone.open_issues == 4
|
||||
assert milestone.closed_issues == 2
|
||||
|
||||
def test_close_issue_moves_from_open_to_closed(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=3,
|
||||
closed_issues=2
|
||||
)
|
||||
|
||||
# Act
|
||||
milestone.close_issue()
|
||||
|
||||
# Assert
|
||||
assert milestone.open_issues == 2
|
||||
assert milestone.closed_issues == 3
|
||||
|
||||
def test_close_issue_with_no_open_issues_raises_error(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=0,
|
||||
closed_issues=5
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(MilestoneError) as exc_info:
|
||||
milestone.close_issue()
|
||||
|
||||
assert "no open issues" in str(exc_info.value)
|
||||
assert exc_info.value.milestone_id == 1
|
||||
|
||||
def test_reopen_issue_moves_from_closed_to_open(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=2,
|
||||
closed_issues=3
|
||||
)
|
||||
|
||||
# Act
|
||||
milestone.reopen_issue()
|
||||
|
||||
# Assert
|
||||
assert milestone.open_issues == 3
|
||||
assert milestone.closed_issues == 2
|
||||
|
||||
def test_reopen_issue_with_no_closed_issues_raises_error(self):
|
||||
# Arrange
|
||||
milestone = Milestone(
|
||||
id=1,
|
||||
title="Test",
|
||||
description=None,
|
||||
due_date=None,
|
||||
state="open",
|
||||
open_issues=5,
|
||||
closed_issues=0
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(MilestoneError) as exc_info:
|
||||
milestone.reopen_issue()
|
||||
|
||||
assert "no closed issues" in str(exc_info.value)
|
||||
assert exc_info.value.milestone_id == 1
|
||||
|
||||
|
||||
class TestProject:
|
||||
"""Test Project aggregate root."""
|
||||
|
||||
def test_project_creation(self):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow()
|
||||
updated_at = datetime.utcnow()
|
||||
milestones = [
|
||||
Milestone(1, "M1", None, None, "open", 2, 1),
|
||||
Milestone(2, "M2", None, None, "closed", 0, 3)
|
||||
]
|
||||
|
||||
# Act
|
||||
project = Project(
|
||||
name="Test Project",
|
||||
description="A test project",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=milestones,
|
||||
kanban_columns=["Todo", "In Progress", "Done"],
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert project.name == "Test Project"
|
||||
assert project.description == "A test project"
|
||||
assert project.state == ProjectState.ACTIVE
|
||||
assert len(project.milestones) == 2
|
||||
assert project.kanban_columns == ["Todo", "In Progress", "Done"]
|
||||
|
||||
def test_get_active_milestones(self):
|
||||
# Arrange
|
||||
milestones = [
|
||||
Milestone(1, "M1", None, None, "open", 2, 1),
|
||||
Milestone(2, "M2", None, None, "closed", 0, 3),
|
||||
Milestone(3, "M3", None, None, "open", 1, 0)
|
||||
]
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=milestones,
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
active_milestones = project.get_active_milestones()
|
||||
|
||||
# Assert
|
||||
assert len(active_milestones) == 2
|
||||
assert all(m.state == "open" for m in active_milestones)
|
||||
|
||||
def test_get_completed_milestones(self):
|
||||
# Arrange
|
||||
milestones = [
|
||||
Milestone(1, "M1", None, None, "open", 2, 1), # Not completed
|
||||
Milestone(2, "M2", None, None, "closed", 0, 3), # Completed (closed)
|
||||
Milestone(3, "M3", None, None, "open", 0, 5) # Completed (100%)
|
||||
]
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=milestones,
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
completed_milestones = project.get_completed_milestones()
|
||||
|
||||
# Assert
|
||||
assert len(completed_milestones) == 2
|
||||
|
||||
def test_get_overdue_milestones(self):
|
||||
# Arrange
|
||||
past_date = datetime.utcnow() - timedelta(days=1)
|
||||
future_date = datetime.utcnow() + timedelta(days=1)
|
||||
milestones = [
|
||||
Milestone(1, "M1", None, past_date, "open", 2, 1), # Overdue
|
||||
Milestone(2, "M2", None, future_date, "open", 1, 0), # Not overdue
|
||||
Milestone(3, "M3", None, None, "open", 1, 0) # No due date
|
||||
]
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=milestones,
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
overdue_milestones = project.get_overdue_milestones()
|
||||
|
||||
# Assert
|
||||
assert len(overdue_milestones) == 1
|
||||
assert overdue_milestones[0].id == 1
|
||||
|
||||
def test_calculate_overall_progress(self):
|
||||
# Arrange
|
||||
milestones = [
|
||||
Milestone(1, "M1", None, None, "open", 1, 4), # 80% complete
|
||||
Milestone(2, "M2", None, None, "open", 3, 2) # 40% complete
|
||||
]
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=milestones,
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
progress = project.calculate_overall_progress()
|
||||
|
||||
# Assert
|
||||
assert progress == 60.0 # (80 + 40) / 2
|
||||
|
||||
def test_calculate_overall_progress_with_no_milestones(self):
|
||||
# Arrange
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
progress = project.calculate_overall_progress()
|
||||
|
||||
# Assert
|
||||
assert progress == 0.0
|
||||
|
||||
def test_get_total_issues(self):
|
||||
# Arrange
|
||||
milestones = [
|
||||
Milestone(1, "M1", None, None, "open", 2, 3), # 5 total
|
||||
Milestone(2, "M2", None, None, "open", 1, 4) # 5 total
|
||||
]
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=milestones,
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert project.get_total_issues() == 10
|
||||
assert project.get_total_open_issues() == 3
|
||||
assert project.get_total_closed_issues() == 7
|
||||
|
||||
def test_archive_project(self):
|
||||
# Arrange
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
project.archive()
|
||||
|
||||
# Assert
|
||||
assert project.state == ProjectState.ARCHIVED
|
||||
assert project.archived_at is not None
|
||||
|
||||
def test_activate_project(self):
|
||||
# Arrange
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ARCHIVED,
|
||||
milestones=[],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
archived_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
project.activate()
|
||||
|
||||
# Assert
|
||||
assert project.state == ProjectState.ACTIVE
|
||||
assert project.archived_at is None
|
||||
|
||||
def test_add_milestone(self):
|
||||
# Arrange
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
milestone = Milestone(1, "New Milestone", None, None, "open", 0, 0)
|
||||
|
||||
# Act
|
||||
project.add_milestone(milestone)
|
||||
|
||||
# Assert
|
||||
assert len(project.milestones) == 1
|
||||
assert project.milestones[0] == milestone
|
||||
|
||||
def test_add_duplicate_milestone_raises_error(self):
|
||||
# Arrange
|
||||
milestone1 = Milestone(1, "Milestone 1", None, None, "open", 0, 0)
|
||||
milestone2 = Milestone(1, "Milestone 2", None, None, "open", 0, 0) # Same ID
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[milestone1],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="Milestone with ID 1 already exists"):
|
||||
project.add_milestone(milestone2)
|
||||
|
||||
def test_remove_milestone(self):
|
||||
# Arrange
|
||||
milestone = Milestone(1, "Milestone", None, None, "open", 0, 0)
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[milestone],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
project.remove_milestone(1)
|
||||
|
||||
# Assert
|
||||
assert len(project.milestones) == 0
|
||||
|
||||
def test_remove_nonexistent_milestone_raises_error(self):
|
||||
# Arrange
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="Milestone with ID 999 not found"):
|
||||
project.remove_milestone(999)
|
||||
|
||||
def test_get_milestone(self):
|
||||
# Arrange
|
||||
milestone = Milestone(1, "Milestone", None, None, "open", 0, 0)
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[milestone],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
found_milestone = project.get_milestone(1)
|
||||
|
||||
# Assert
|
||||
assert found_milestone == milestone
|
||||
|
||||
def test_get_nonexistent_milestone_returns_none(self):
|
||||
# Arrange
|
||||
project = Project(
|
||||
name="Test",
|
||||
description="",
|
||||
state=ProjectState.ACTIVE,
|
||||
milestones=[],
|
||||
kanban_columns=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
found_milestone = project.get_milestone(999)
|
||||
|
||||
# Assert
|
||||
assert found_milestone is None
|
||||
3
tests/unit/infrastructure/__init__.py
Normal file
3
tests/unit/infrastructure/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Unit tests for infrastructure components.
|
||||
"""
|
||||
287
tests/unit/infrastructure/test_testing_infrastructure.py
Normal file
287
tests/unit/infrastructure/test_testing_infrastructure.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Tests to validate the testing infrastructure works correctly.
|
||||
|
||||
Demonstrates:
|
||||
- Test fixtures functionality
|
||||
- Mock factories usage
|
||||
- Test builders patterns
|
||||
- Custom assertions
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from tests.fixtures.markdown_samples import MarkdownDocumentBuilder, SAMPLE_SIMPLE_DOCUMENT
|
||||
from tests.fixtures.api_responses import GiteaApiResponseBuilder, SAMPLE_ISSUE_RESPONSE
|
||||
from tests.utils.test_builders import IssueBuilder, LabelBuilder, create_sample_issue
|
||||
from tests.utils.mock_factories import MockRepositoryFactory, MockConfigFactory
|
||||
from tests.utils.assertions import (
|
||||
assert_issue_equal, assert_file_exists, assert_directory_exists,
|
||||
assert_performance_within_bounds, validate_issue_data
|
||||
)
|
||||
|
||||
|
||||
class TestTestingInfrastructure:
|
||||
"""Test that the testing infrastructure components work correctly."""
|
||||
|
||||
def test_test_workspace_fixture(self, test_workspace):
|
||||
"""Test that test workspace fixture creates proper isolated environment."""
|
||||
# Assert
|
||||
assert_directory_exists(test_workspace)
|
||||
assert_directory_exists(test_workspace / "documents")
|
||||
assert_directory_exists(test_workspace / "cache")
|
||||
assert_directory_exists(test_workspace / "workspaces")
|
||||
|
||||
# Test creating files in workspace
|
||||
test_file = test_workspace / "test_file.txt"
|
||||
test_file.write_text("test content")
|
||||
assert_file_exists(test_file)
|
||||
|
||||
def test_markdown_document_builder(self):
|
||||
"""Test markdown document builder functionality."""
|
||||
# Act
|
||||
doc = (MarkdownDocumentBuilder()
|
||||
.with_metadata("title", "Test Doc")
|
||||
.with_metadata("author", "Test Author")
|
||||
.with_heading("Main Title")
|
||||
.with_paragraph("This is a test paragraph.")
|
||||
.with_list(["Item 1", "Item 2", "Item 3"])
|
||||
.with_code_block("print('hello')", "python")
|
||||
.build())
|
||||
|
||||
# Assert
|
||||
assert "title: Test Doc" in doc
|
||||
assert "author: Test Author" in doc
|
||||
assert "# Main Title" in doc
|
||||
assert "This is a test paragraph." in doc
|
||||
assert "- Item 1" in doc
|
||||
assert "```python" in doc
|
||||
assert "print('hello')" in doc
|
||||
|
||||
def test_gitea_api_response_builder(self):
|
||||
"""Test Gitea API response builder functionality."""
|
||||
# Act
|
||||
response = (GiteaApiResponseBuilder()
|
||||
.with_number(42)
|
||||
.with_title("Test Issue")
|
||||
.with_labels("bug", "priority:high")
|
||||
.with_milestone("Version 1.0")
|
||||
.build())
|
||||
|
||||
# Assert
|
||||
assert response["number"] == 42
|
||||
assert response["title"] == "Test Issue"
|
||||
assert len(response["labels"]) == 2
|
||||
assert response["labels"][0]["name"] == "bug"
|
||||
assert response["labels"][1]["name"] == "priority:high"
|
||||
assert response["milestone"]["title"] == "Version 1.0"
|
||||
|
||||
def test_issue_builder_functionality(self):
|
||||
"""Test issue builder creates proper domain objects."""
|
||||
# Act
|
||||
issue = (IssueBuilder()
|
||||
.with_number(123)
|
||||
.with_title("Test Issue")
|
||||
.as_bug()
|
||||
.with_priority("high")
|
||||
.with_status("in-progress")
|
||||
.build())
|
||||
|
||||
# Assert
|
||||
assert issue.number == 123
|
||||
assert issue.title == "Test Issue"
|
||||
assert len(issue.labels) == 3
|
||||
|
||||
# Check label categorization
|
||||
categories = issue.categorize_labels()
|
||||
assert "bug" in categories.type_labels
|
||||
assert "priority:high" in categories.priority_labels
|
||||
assert "status:in-progress" in categories.state_labels
|
||||
|
||||
def test_label_builder_functionality(self):
|
||||
"""Test label builder creates correct labels."""
|
||||
# Act
|
||||
state_label = LabelBuilder().as_state_label("blocked").build()
|
||||
priority_label = LabelBuilder().as_priority_label("critical").build()
|
||||
type_label = LabelBuilder().as_type_label("bug").build()
|
||||
custom_label = LabelBuilder().with_custom_name("frontend").build()
|
||||
|
||||
# Assert
|
||||
assert state_label.name == "status:blocked"
|
||||
assert state_label.is_state_label()
|
||||
|
||||
assert priority_label.name == "priority:critical"
|
||||
assert priority_label.is_priority_label()
|
||||
|
||||
assert type_label.name == "bug"
|
||||
assert type_label.is_type_label()
|
||||
|
||||
assert custom_label.name == "frontend"
|
||||
assert not custom_label.is_state_label()
|
||||
assert not custom_label.is_priority_label()
|
||||
assert not custom_label.is_type_label()
|
||||
|
||||
def test_mock_repository_factory(self):
|
||||
"""Test mock repository factory creates proper mocks."""
|
||||
# Act
|
||||
issue_repo = MockRepositoryFactory.create_issue_repository()
|
||||
project_repo = MockRepositoryFactory.create_project_repository()
|
||||
document_repo = MockRepositoryFactory.create_document_repository()
|
||||
|
||||
# Assert
|
||||
assert hasattr(issue_repo, 'get_issue')
|
||||
assert hasattr(issue_repo, 'create_issue')
|
||||
assert hasattr(issue_repo, 'update_issue')
|
||||
assert hasattr(issue_repo, 'list_issues')
|
||||
|
||||
assert hasattr(project_repo, 'get_project')
|
||||
assert hasattr(project_repo, 'get_issue_project_info')
|
||||
|
||||
assert hasattr(document_repo, 'store_document')
|
||||
assert hasattr(document_repo, 'search_content')
|
||||
|
||||
def test_mock_config_factory(self):
|
||||
"""Test mock configuration factory."""
|
||||
# Act
|
||||
config = MockConfigFactory.create_test_config({
|
||||
"custom_setting": "test_value",
|
||||
"workspace_dir": "/custom/workspace"
|
||||
})
|
||||
|
||||
# Assert
|
||||
assert config.workspace_dir == "/custom/workspace"
|
||||
assert config.custom_setting == "test_value"
|
||||
assert config.gitea_url == "http://test-gitea.com" # Default value
|
||||
assert config.log_level == "DEBUG" # Default value
|
||||
|
||||
def test_custom_assertions(self):
|
||||
"""Test custom assertion functions."""
|
||||
# Arrange
|
||||
issue1 = create_sample_issue(1, "Test Issue")
|
||||
issue2 = create_sample_issue(1, "Test Issue")
|
||||
|
||||
# Act & Assert - Should not raise
|
||||
assert_issue_equal(issue1, issue2, ignore_timestamps=True)
|
||||
|
||||
# Test performance assertion
|
||||
execution_time = 0.05 # 50ms
|
||||
assert_performance_within_bounds(execution_time, 0.1, "test operation")
|
||||
|
||||
# Test data validation
|
||||
valid_issue_data = {
|
||||
"number": 1,
|
||||
"title": "Valid Issue",
|
||||
"state": "open",
|
||||
"labels": [],
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
assert validate_issue_data(valid_issue_data) is True
|
||||
|
||||
invalid_issue_data = {
|
||||
"number": -1, # Invalid number
|
||||
"title": "", # Empty title
|
||||
"state": "invalid_state", # Invalid state
|
||||
}
|
||||
assert validate_issue_data(invalid_issue_data) is False
|
||||
|
||||
def test_sample_constants(self):
|
||||
"""Test that sample constants are properly formed."""
|
||||
# Test markdown samples
|
||||
assert len(SAMPLE_SIMPLE_DOCUMENT) > 0
|
||||
assert "# Simple Document" in SAMPLE_SIMPLE_DOCUMENT
|
||||
|
||||
# Test API response samples
|
||||
assert SAMPLE_ISSUE_RESPONSE["number"] == 123
|
||||
assert SAMPLE_ISSUE_RESPONSE["title"] == "Sample Issue"
|
||||
assert len(SAMPLE_ISSUE_RESPONSE["labels"]) > 0
|
||||
|
||||
def test_performance_timer_fixture(self, performance_timer):
|
||||
"""Test performance timer fixture functionality."""
|
||||
# Act
|
||||
performance_timer.start()
|
||||
|
||||
# Simulate some work
|
||||
import time
|
||||
time.sleep(0.01) # 10ms
|
||||
|
||||
performance_timer.stop()
|
||||
|
||||
# Assert
|
||||
assert performance_timer.elapsed > 0
|
||||
assert performance_timer.elapsed < 0.1 # Should be much less than 100ms
|
||||
|
||||
def test_test_config_fixture(self, test_config):
|
||||
"""Test test configuration fixture."""
|
||||
# Assert
|
||||
assert "workspace_dir" in test_config
|
||||
assert "database_path" in test_config
|
||||
assert "gitea_url" in test_config
|
||||
assert test_config["gitea_url"] == "http://test-gitea.com"
|
||||
|
||||
def test_sample_markdown_content_fixture(self, sample_markdown_content):
|
||||
"""Test sample markdown content fixture."""
|
||||
# Assert
|
||||
assert "Test Document" in sample_markdown_content
|
||||
assert "title:" in sample_markdown_content # Front matter
|
||||
assert "**bold**" in sample_markdown_content
|
||||
assert "```python" in sample_markdown_content
|
||||
|
||||
def test_sample_issue_data_fixture(self, sample_issue_data):
|
||||
"""Test sample issue data fixture."""
|
||||
# Assert
|
||||
assert sample_issue_data["number"] == 123
|
||||
assert sample_issue_data["title"] == "Test Issue"
|
||||
assert sample_issue_data["state"] == "open"
|
||||
assert len(sample_issue_data["labels"]) > 0
|
||||
|
||||
def test_isolated_environment_fixture(self, isolated_environment):
|
||||
"""Test isolated environment fixture."""
|
||||
# Assert
|
||||
assert "MARKITECT_WORKSPACE_DIR" in isolated_environment
|
||||
assert "MARKITECT_GITEA_URL" in isolated_environment
|
||||
assert isolated_environment["MARKITECT_GITEA_URL"] == "http://test-gitea.com"
|
||||
|
||||
@pytest.mark.parametrize("priority,expected", [
|
||||
("low", "Low"),
|
||||
("medium", "Medium"),
|
||||
("high", "High"),
|
||||
("critical", "Critical")
|
||||
])
|
||||
def test_parametrized_testing_works(self, priority, expected):
|
||||
"""Test that parametrized testing works with our infrastructure."""
|
||||
# Act
|
||||
issue = (IssueBuilder()
|
||||
.with_number(1)
|
||||
.with_title("Priority Test")
|
||||
.with_priority(priority)
|
||||
.build())
|
||||
|
||||
# Assert
|
||||
categories = issue.categorize_labels()
|
||||
priority_labels = categories.priority_labels
|
||||
assert len(priority_labels) == 1
|
||||
assert priority_labels[0] == f"priority:{priority}"
|
||||
|
||||
def test_test_markers_work(self):
|
||||
"""Test that our custom test markers work."""
|
||||
# This test validates that the marker configuration is working
|
||||
# The markers are defined in pytest.ini and should not cause warnings
|
||||
pass
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unit_marker_works(self):
|
||||
"""Test that unit marker works."""
|
||||
assert True
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_performance_marker_works(self, performance_timer):
|
||||
"""Test that performance marker works."""
|
||||
performance_timer.start()
|
||||
# Simulate quick operation
|
||||
result = sum(range(100))
|
||||
performance_timer.stop()
|
||||
|
||||
assert result == 4950 # Mathematical verification
|
||||
assert performance_timer.elapsed < 0.01 # Should be very fast
|
||||
3
tests/utils/__init__.py
Normal file
3
tests/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test utilities and helpers for MarkiTect tests.
|
||||
"""
|
||||
274
tests/utils/assertions.py
Normal file
274
tests/utils/assertions.py
Normal 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
|
||||
346
tests/utils/mock_factories.py
Normal file
346
tests/utils/mock_factories.py
Normal 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
|
||||
338
tests/utils/test_builders.py
Normal file
338
tests/utils/test_builders.py
Normal 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())
|
||||
Reference in New Issue
Block a user