strat-gameplay-webapp/backend/tests/integration/database/test_migrations.py
Cal Corum 9d0d29ef18 CLAUDE: Add Alembic migrations and database session injection
Database Infrastructure:
- Added Alembic migration system (alembic.ini, env.py)
- Migration 001: Initial schema
- Migration 004: Stat materialized views (enhanced)
- Migration 005: Composite indexes for performance
- operations.py: Session injection support for test isolation
- session.py: Enhanced session management

Application Updates:
- main.py: Integration with new database infrastructure
- health.py: Enhanced health checks with pool monitoring

Integration Tests:
- conftest.py: Session injection pattern for reliable tests
- test_operations.py: Database operations tests
- test_migrations.py: Migration verification tests

Session injection pattern enables:
- Production: Auto-commit per operation
- Testing: Shared session with automatic rollback
- Transactions: Multiple ops, single commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 12:09:09 -06:00

270 lines
8.8 KiB
Python

"""
Tests for Alembic database migrations.
Verifies that migrations can be applied and rolled back correctly.
These tests use the actual database and should be run with caution
on shared environments.
What: Tests for database migration integrity
Why: Ensures migrations can be applied cleanly on fresh databases
and rolled back safely for disaster recovery
"""
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from app.config import get_settings
@pytest.fixture
def alembic_config():
"""Create Alembic config pointing to the backend directory."""
config = Config("/mnt/NV2/Development/strat-gameplay-webapp/backend/alembic.ini")
return config
@pytest.fixture
def sync_engine():
"""Create synchronous engine for migration testing."""
settings = get_settings()
sync_url = settings.database_url.replace("+asyncpg", "+psycopg2")
return create_engine(sync_url)
class TestMigrationHistory:
"""Tests for migration chain integrity."""
def test_migration_history_valid(self, alembic_config):
"""
Verify migration chain is valid and has no gaps.
What: Checks that all migrations reference existing predecessors
Why: Broken migration chains cause 'revision not found' errors
"""
from alembic.script import ScriptDirectory
script = ScriptDirectory.from_config(alembic_config)
revisions = list(script.walk_revisions())
# Should have at least 2 migrations (001 and 004)
assert len(revisions) >= 2, "Expected at least 2 migrations"
# All revisions except base should have a down_revision that exists
for rev in revisions:
if rev.down_revision is not None:
# The down_revision should be in our revisions list
down_revs = (
rev.down_revision
if isinstance(rev.down_revision, tuple)
else (rev.down_revision,)
)
for down_rev in down_revs:
assert script.get_revision(down_rev) is not None, (
f"Migration {rev.revision} references non-existent "
f"down_revision {down_rev}"
)
def test_head_is_reachable(self, alembic_config):
"""
Verify we can traverse from base to head.
What: Ensures migration path from base to head is unbroken
Why: Broken path means new databases cannot be fully initialized
"""
from alembic.script import ScriptDirectory
script = ScriptDirectory.from_config(alembic_config)
heads = script.get_heads()
assert len(heads) == 1, f"Expected single head, got {heads}"
assert heads[0] == "004", f"Expected head to be 004, got {heads[0]}"
class TestMigrationContent:
"""Tests for migration content correctness."""
def test_initial_migration_creates_all_tables(self, alembic_config):
"""
Verify initial migration (001) creates all expected tables.
What: Checks that 001 migration creates the full schema
Why: Missing tables would cause application failures
"""
from alembic.script import ScriptDirectory
script = ScriptDirectory.from_config(alembic_config)
rev_001 = script.get_revision("001")
# Read the migration file
import importlib.util
spec = importlib.util.spec_from_file_location("001", rev_001.path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Check upgrade function exists
assert hasattr(module, "upgrade"), "Migration 001 missing upgrade function"
assert hasattr(module, "downgrade"), "Migration 001 missing downgrade function"
# Get source code to verify table creation
import inspect as py_inspect
source = py_inspect.getsource(module.upgrade)
expected_tables = [
"games",
"plays",
"lineups",
"game_sessions",
"rolls",
"roster_links",
"game_cardset_links",
]
for table in expected_tables:
assert (
f"'{table}'" in source
), f"Migration 001 missing table creation for '{table}'"
def test_materialized_views_migration_content(self, alembic_config):
"""
Verify materialized views migration (004) creates expected views.
What: Checks that 004 migration creates statistics views
Why: Missing views would break box score functionality
"""
from alembic.script import ScriptDirectory
script = ScriptDirectory.from_config(alembic_config)
rev_004 = script.get_revision("004")
import importlib.util
spec = importlib.util.spec_from_file_location("004", rev_004.path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
import inspect as py_inspect
source = py_inspect.getsource(module.upgrade)
expected_views = [
"batting_game_stats",
"pitching_game_stats",
"game_stats",
]
for view in expected_views:
assert (
view in source
), f"Migration 004 missing materialized view '{view}'"
class TestCurrentDatabaseState:
"""Tests for current database migration state."""
def test_database_at_head(self, sync_engine):
"""
Verify database is at the latest migration revision.
What: Checks alembic_version table shows current head
Why: Ensures database schema is up to date
"""
with sync_engine.connect() as conn:
result = conn.execute(text("SELECT version_num FROM alembic_version"))
versions = list(result)
assert len(versions) == 1, f"Expected 1 version row, got {len(versions)}"
assert versions[0][0] == "004", f"Expected version 004, got {versions[0][0]}"
def test_all_tables_exist(self, sync_engine):
"""
Verify all expected tables exist in the database.
What: Inspects database to confirm table presence
Why: Validates migrations were applied correctly
"""
inspector = inspect(sync_engine)
tables = set(inspector.get_table_names())
expected_tables = {
"games",
"plays",
"lineups",
"game_sessions",
"rolls",
"roster_links",
"game_cardset_links",
"alembic_version", # Created by Alembic
}
missing = expected_tables - tables
assert not missing, f"Missing tables: {missing}"
def test_materialized_views_exist(self, sync_engine):
"""
Verify all materialized views exist in the database.
What: Checks for statistics views created by migration 004
Why: Box score functionality depends on these views
"""
with sync_engine.connect() as conn:
result = conn.execute(
text(
"""
SELECT matviewname FROM pg_matviews
WHERE schemaname = 'public'
ORDER BY matviewname
"""
)
)
views = {row[0] for row in result}
expected_views = {
"batting_game_stats",
"pitching_game_stats",
"game_stats",
}
missing = expected_views - views
assert not missing, f"Missing materialized views: {missing}"
class TestMigrationDowngrade:
"""Tests for migration downgrade safety (non-destructive checks only)."""
def test_downgrade_functions_exist(self, alembic_config):
"""
Verify all migrations have downgrade functions.
What: Checks each migration has a downgrade() implementation
Why: Rollback capability is critical for disaster recovery
"""
from alembic.script import ScriptDirectory
script = ScriptDirectory.from_config(alembic_config)
for rev in script.walk_revisions():
import importlib.util
spec = importlib.util.spec_from_file_location(rev.revision, rev.path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert hasattr(
module, "downgrade"
), f"Migration {rev.revision} missing downgrade function"
import inspect as py_inspect
source = py_inspect.getsource(module.downgrade)
# Downgrade should not just be 'pass' for data-creating migrations
if rev.revision in ["001", "004"]:
assert "pass" not in source.replace(
" ", ""
).replace("\n", ""), (
f"Migration {rev.revision} downgrade should not be empty"
)