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.
|
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
|
The tddai framework is project-agnostic and can be configured per project
|
||||||
via environment variables:
|
via environment variables:
|
||||||
|
|
||||||
@@ -25,9 +28,19 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
from .exceptions import ConfigurationError
|
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:
|
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():
|
if not env_file.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -41,7 +54,10 @@ def load_dotenv_file(env_file: Path) -> None:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TddaiConfig:
|
class TddaiConfig:
|
||||||
"""Configuration settings for tddai."""
|
"""Configuration settings for tddai.
|
||||||
|
|
||||||
|
DEPRECATED: Use config.TddaiConfigCompat instead.
|
||||||
|
"""
|
||||||
|
|
||||||
# Workspace settings
|
# Workspace settings
|
||||||
workspace_dir: Path = Path(".tddai_workspace")
|
workspace_dir: Path = Path(".tddai_workspace")
|
||||||
@@ -114,16 +130,42 @@ _config: Optional[TddaiConfig] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_config() -> TddaiConfig:
|
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
|
global _config
|
||||||
if _config is None:
|
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 = TddaiConfig.from_environment()
|
||||||
_config.validate()
|
_config.validate()
|
||||||
return _config
|
return _config
|
||||||
|
|
||||||
|
|
||||||
def set_config(config: TddaiConfig) -> None:
|
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
|
global _config
|
||||||
config.validate()
|
config.validate()
|
||||||
_config = config
|
_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