Aniworld/tests/unit/test_migrations.py
Lukas 77da614091 feat: Add database migrations, performance testing, and security testing
 Features Added:

Database Migration System:
- Complete migration framework with base classes, runner, and validator
- Initial schema migration for all core tables (users, anime, episodes, downloads, config)
- Rollback support with error handling
- Migration history tracking
- 22 passing unit tests

Performance Testing Suite:
- API load testing with concurrent request handling
- Download system stress testing
- Response time benchmarks
- Memory leak detection
- Concurrency testing
- 19 comprehensive performance tests
- Complete documentation in tests/performance/README.md

Security Testing Suite:
- Authentication and authorization security tests
- Input validation and XSS protection
- SQL injection prevention (classic, blind, second-order)
- NoSQL and ORM injection protection
- File upload security
- OWASP Top 10 coverage
- 40+ security test methods
- Complete documentation in tests/security/README.md

📊 Test Results:
- Migration tests: 22/22 passing (100%)
- Total project tests: 736+ passing (99.8% success rate)
- New code: ~2,600 lines (code + tests + docs)

📝 Documentation:
- Updated instructions.md (removed completed tasks)
- Added COMPLETION_SUMMARY.md with detailed implementation notes
- Comprehensive README files for test suites
- Type hints and docstrings throughout

🎯 Quality:
- Follows PEP 8 standards
- Comprehensive error handling
- Structured logging
- Type annotations
- Full test coverage
2025-10-24 10:11:51 +02:00

420 lines
13 KiB
Python

"""
Tests for database migration system.
This module tests the migration runner, validator, and base classes.
"""
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.server.database.migrations.base import (
Migration,
MigrationError,
MigrationHistory,
)
from src.server.database.migrations.runner import MigrationRunner
from src.server.database.migrations.validator import MigrationValidator
class TestMigration:
"""Tests for base Migration class."""
def test_migration_initialization(self):
"""Test migration can be initialized with basic attributes."""
class TestMig(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig = TestMig(
version="20250124_001", description="Test migration"
)
assert mig.version == "20250124_001"
assert mig.description == "Test migration"
assert isinstance(mig.created_at, datetime)
def test_migration_equality(self):
"""Test migrations are equal based on version."""
class TestMig1(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
class TestMig2(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig1 = TestMig1(version="20250124_001", description="Test 1")
mig2 = TestMig2(version="20250124_001", description="Test 2")
mig3 = TestMig1(version="20250124_002", description="Test 3")
assert mig1 == mig2
assert mig1 != mig3
assert hash(mig1) == hash(mig2)
assert hash(mig1) != hash(mig3)
def test_migration_repr(self):
"""Test migration string representation."""
class TestMig(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig = TestMig(
version="20250124_001", description="Test migration"
)
assert "20250124_001" in repr(mig)
assert "Test migration" in repr(mig)
class TestMigrationHistory:
"""Tests for MigrationHistory class."""
def test_history_initialization(self):
"""Test migration history record can be created."""
history = MigrationHistory(
version="20250124_001",
description="Test migration",
applied_at=datetime.now(),
execution_time_ms=1500,
success=True,
)
assert history.version == "20250124_001"
assert history.description == "Test migration"
assert history.execution_time_ms == 1500
assert history.success is True
assert history.error_message is None
def test_history_with_error(self):
"""Test migration history with error message."""
history = MigrationHistory(
version="20250124_001",
description="Failed migration",
applied_at=datetime.now(),
execution_time_ms=500,
success=False,
error_message="Test error",
)
assert history.success is False
assert history.error_message == "Test error"
class TestMigrationValidator:
"""Tests for MigrationValidator class."""
def test_validator_initialization(self):
"""Test validator can be initialized."""
validator = MigrationValidator()
assert isinstance(validator.errors, list)
assert isinstance(validator.warnings, list)
assert len(validator.errors) == 0
def test_validate_version_format_valid(self):
"""Test validation of valid version formats."""
validator = MigrationValidator()
assert validator._validate_version_format("20250124_001")
assert validator._validate_version_format("20231201_099")
assert validator._validate_version_format("20250124_001_description")
def test_validate_version_format_invalid(self):
"""Test validation of invalid version formats."""
validator = MigrationValidator()
assert not validator._validate_version_format("")
assert not validator._validate_version_format("20250124")
assert not validator._validate_version_format("invalid_001")
assert not validator._validate_version_format("202501_001")
def test_validate_migration_valid(self):
"""Test validation of valid migration."""
class TestMig(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig = TestMig(
version="20250124_001",
description="Valid test migration",
)
validator = MigrationValidator()
assert validator.validate_migration(mig) is True
assert len(validator.errors) == 0
def test_validate_migration_invalid_version(self):
"""Test validation fails for invalid version."""
class TestMig(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig = TestMig(
version="invalid",
description="Valid description",
)
validator = MigrationValidator()
assert validator.validate_migration(mig) is False
assert len(validator.errors) > 0
def test_validate_migration_missing_description(self):
"""Test validation fails for missing description."""
class TestMig(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig = TestMig(version="20250124_001", description="")
validator = MigrationValidator()
assert validator.validate_migration(mig) is False
assert any("description" in e.lower() for e in validator.errors)
def test_validate_migrations_duplicate_version(self):
"""Test validation detects duplicate versions."""
class TestMig1(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
class TestMig2(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig1 = TestMig1(version="20250124_001", description="First")
mig2 = TestMig2(version="20250124_001", description="Duplicate")
validator = MigrationValidator()
assert validator.validate_migrations([mig1, mig2]) is False
assert any("duplicate" in e.lower() for e in validator.errors)
def test_check_migration_conflicts(self):
"""Test detection of migration conflicts."""
class TestMig(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
old_mig = TestMig(version="20250101_001", description="Old")
new_mig = TestMig(version="20250124_001", description="New")
validator = MigrationValidator()
# No conflict when pending is newer
conflict = validator.check_migration_conflicts(
[new_mig], ["20250101_001"]
)
assert conflict is None
# Conflict when pending is older
conflict = validator.check_migration_conflicts(
[old_mig], ["20250124_001"]
)
assert conflict is not None
assert "older" in conflict.lower()
def test_get_validation_report(self):
"""Test validation report generation."""
validator = MigrationValidator()
validator.errors.append("Test error")
validator.warnings.append("Test warning")
report = validator.get_validation_report()
assert "Test error" in report
assert "Test warning" in report
assert "Validation Errors:" in report
assert "Validation Warnings:" in report
def test_raise_if_invalid(self):
"""Test exception raising on validation failure."""
validator = MigrationValidator()
validator.errors.append("Test error")
with pytest.raises(MigrationError):
validator.raise_if_invalid()
@pytest.mark.asyncio
class TestMigrationRunner:
"""Tests for MigrationRunner class."""
@pytest.fixture
def mock_session(self):
"""Create mock database session."""
session = AsyncMock()
session.execute = AsyncMock()
session.commit = AsyncMock()
session.rollback = AsyncMock()
return session
@pytest.fixture
def migrations_dir(self, tmp_path):
"""Create temporary migrations directory."""
return tmp_path / "migrations"
async def test_runner_initialization(
self, migrations_dir, mock_session
):
"""Test migration runner can be initialized."""
runner = MigrationRunner(migrations_dir, mock_session)
assert runner.migrations_dir == migrations_dir
assert runner.session == mock_session
assert isinstance(runner._migrations, list)
async def test_initialize_creates_table(
self, migrations_dir, mock_session
):
"""Test initialization creates migration_history table."""
runner = MigrationRunner(migrations_dir, mock_session)
await runner.initialize()
mock_session.execute.assert_called()
mock_session.commit.assert_called()
async def test_load_migrations_empty_dir(
self, migrations_dir, mock_session
):
"""Test loading migrations from empty directory."""
runner = MigrationRunner(migrations_dir, mock_session)
runner.load_migrations()
assert len(runner._migrations) == 0
async def test_get_applied_migrations(
self, migrations_dir, mock_session
):
"""Test retrieving list of applied migrations."""
# Mock database response
mock_result = Mock()
mock_result.fetchall.return_value = [
("20250124_001",),
("20250124_002",),
]
mock_session.execute.return_value = mock_result
runner = MigrationRunner(migrations_dir, mock_session)
applied = await runner.get_applied_migrations()
assert len(applied) == 2
assert "20250124_001" in applied
assert "20250124_002" in applied
async def test_apply_migration_success(
self, migrations_dir, mock_session
):
"""Test successful migration application."""
class TestMig(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig = TestMig(version="20250124_001", description="Test")
runner = MigrationRunner(migrations_dir, mock_session)
await runner.apply_migration(mig)
mock_session.commit.assert_called()
async def test_apply_migration_failure(
self, migrations_dir, mock_session
):
"""Test migration application handles failures."""
class FailingMig(Migration):
async def upgrade(self, session):
raise Exception("Test failure")
async def downgrade(self, session):
return None
mig = FailingMig(version="20250124_001", description="Failing")
runner = MigrationRunner(migrations_dir, mock_session)
with pytest.raises(MigrationError):
await runner.apply_migration(mig)
mock_session.rollback.assert_called()
async def test_get_pending_migrations(
self, migrations_dir, mock_session
):
"""Test retrieving pending migrations."""
class TestMig1(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
class TestMig2(Migration):
async def upgrade(self, session):
return None
async def downgrade(self, session):
return None
mig1 = TestMig1(version="20250124_001", description="Applied")
mig2 = TestMig2(version="20250124_002", description="Pending")
runner = MigrationRunner(migrations_dir, mock_session)
runner._migrations = [mig1, mig2]
# Mock only mig1 as applied
mock_result = Mock()
mock_result.fetchall.return_value = [("20250124_001",)]
mock_session.execute.return_value = mock_result
pending = await runner.get_pending_migrations()
assert len(pending) == 1
assert pending[0].version == "20250124_002"