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>
270 lines
8.8 KiB
Python
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"
|
|
)
|