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
This commit is contained in:
419
tests/unit/test_migrations.py
Normal file
419
tests/unit/test_migrations.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user