""" SQLite migration integration tests. Tests real Alembic migration behavior using temporary SQLite databases. Validates the migration workflow from .github/workflows/test-migrations.yml. Run: uv run pytest tests/integration/persistence/test_migrations.py -q """ from __future__ import annotations import json import pytest import sqlalchemy as sa from alembic.config import Config from alembic.script import ScriptDirectory from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.entity.persistence import ( agent as agent_models, agent_run as agent_run_models, agent_runner_state as agent_runner_state_models, bot as bot_models, metadata as metadata_models, monitoring as monitoring_models, ) from langbot.pkg.entity.persistence.base import Base from langbot.pkg.persistence.alembic_runner import ( _ALEMBIC_DIR, get_alembic_current, run_alembic_stamp, run_alembic_upgrade, ) def _get_script_directory() -> ScriptDirectory: """Load the repository's Alembic revision graph.""" cfg = Config() cfg.set_main_option('script_location', _ALEMBIC_DIR) return ScriptDirectory.from_config(cfg) def _get_script_head() -> str: """Resolve the only Alembic head without hardcoding a revision.""" return _get_script_directory().get_current_head() pytestmark = pytest.mark.integration class TestAlembicRevisionGraph: """Static release gates for the Alembic graph.""" def test_revision_ids_fit_alembic_version_column_and_graph_has_one_head(self): script = _get_script_directory() revisions = list(script.walk_revisions()) assert script.get_bases() == ['0001_baseline'] assert script.get_heads() == ['0012_monitoring_tool_calls'] assert all(len(item.revision) <= 32 for item in revisions), { item.revision: len(item.revision) for item in revisions if len(item.revision) > 32 } @pytest.fixture def sqlite_db_url(tmp_path): """Create SQLite URL with temporary database file.""" db_file = tmp_path / 'test_migrations.db' return f'sqlite+aiosqlite:///{db_file}' @pytest.fixture async def sqlite_engine(sqlite_db_url): """Create async SQLite engine.""" engine = create_async_engine(sqlite_db_url) yield engine await engine.dispose() class TestSQLiteMigrationBaseline: """Tests for baseline stamp workflow.""" @pytest.mark.asyncio async def test_baseline_stamp_sets_revision(self, sqlite_engine): """ Stamp baseline on existing tables sets correct revision. Workflow: 1. Create tables via Base.metadata.create_all 2. Stamp with '0001_baseline' 3. Verify current revision is '0001_baseline' """ # Create all tables (simulates existing DB created by ORM) async with sqlite_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Stamp baseline await run_alembic_stamp(sqlite_engine, '0001_baseline') # Verify revision rev = await get_alembic_current(sqlite_engine) assert rev == '0001_baseline', f"Expected '0001_baseline', got {rev}" @pytest.mark.asyncio async def test_baseline_stamp_on_empty_db(self, sqlite_engine): """ Stamp on empty database (no tables) still sets revision. This is an edge case - stamping without tables. """ # Don't create tables - stamp directly await run_alembic_stamp(sqlite_engine, '0001_baseline') rev = await get_alembic_current(sqlite_engine) assert rev == '0001_baseline' class TestSQLiteMigrationUpgrade: """Tests for upgrade to head workflow.""" @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): """ Upgrade from baseline to head applies all migrations. Workflow: 1. Create tables 2. Stamp baseline 3. Upgrade to head 4. Verify current revision is head """ # Create tables async with sqlite_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Stamp baseline await run_alembic_stamp(sqlite_engine, '0001_baseline') # Upgrade to head await run_alembic_upgrade(sqlite_engine, 'head') # Verify revision rev = await get_alembic_current(sqlite_engine) assert rev is not None, 'Expected a revision after upgrade' # Head should be the latest migration. Resolve the actual head from the # Alembic script directory instead of hardcoding a revision number, so # adding a new migration doesn't require editing this assertion. assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}' @pytest.mark.asyncio async def test_upgrade_idempotent(self, sqlite_engine): """ Running upgrade to head multiple times is idempotent. Workflow: 1. Upgrade to head 2. Get revision 3. Upgrade to head again 4. Verify same revision """ # Create tables async with sqlite_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Stamp and upgrade await run_alembic_stamp(sqlite_engine, '0001_baseline') await run_alembic_upgrade(sqlite_engine, 'head') rev1 = await get_alembic_current(sqlite_engine) # Upgrade again - should be idempotent await run_alembic_upgrade(sqlite_engine, 'head') rev2 = await get_alembic_current(sqlite_engine) assert rev2 == rev1, f'Expected {rev1}, got {rev2}' @pytest.mark.asyncio async def test_upgrade_from_mcp_resource_branch_creates_agent_and_monitoring_schema(self, sqlite_engine): """The MCP branch can converge into the Agent branch and the current head.""" await run_alembic_stamp(sqlite_engine, '0008_mcp_resource_prefs') await run_alembic_upgrade(sqlite_engine, 'head') def inspect_schema(sync_conn): inspector = sa.inspect(sync_conn) tables = set(inspector.get_table_names()) monitoring_indexes = { index['name'] for index in inspector.get_indexes(monitoring_models.MonitoringToolCall.__tablename__) } return tables, monitoring_indexes async with sqlite_engine.connect() as conn: tables, monitoring_indexes = await conn.run_sync(inspect_schema) expected_agent_tables = { agent_models.Agent.__tablename__, agent_run_models.AgentRun.__tablename__, agent_run_models.AgentRunEvent.__tablename__, agent_run_models.AgentRuntime.__tablename__, agent_runner_state_models.AgentRunnerState.__tablename__, } expected_monitoring_indexes = {index.name for index in monitoring_models.MonitoringToolCall.__table__.indexes} assert expected_agent_tables <= tables assert monitoring_models.MonitoringToolCall.__tablename__ in tables assert expected_monitoring_indexes <= monitoring_indexes assert await get_alembic_current(sqlite_engine) == _get_script_head() @pytest.mark.asyncio async def test_bot_admin_data_migrates_when_create_all_already_created_table(self, sqlite_engine): """0007 must migrate config admins even when the ORM created its table first.""" config = { 'admins': ['group_admin-1', 'person_user_with_underscore', 'malformed'], 'preserved': True, } async with sqlite_engine.begin() as conn: await conn.run_sync(bot_models.Bot.__table__.create) await conn.run_sync(bot_models.BotAdmin.__table__.create) await conn.run_sync(metadata_models.Metadata.__table__.create) await conn.execute( sa.insert(bot_models.Bot).values( uuid='bot-1', name='Bot', description='', adapter='test', adapter_config={}, enable=True, ) ) await conn.execute( sa.insert(bot_models.BotAdmin).values( bot_uuid='bot-1', launcher_type='group', launcher_id='admin-1', ) ) await conn.execute( sa.insert(metadata_models.Metadata).values( key='instance_config', value=json.dumps(config), ) ) await run_alembic_stamp(sqlite_engine, '0006_normalize_mcp_remote_mode') await run_alembic_upgrade(sqlite_engine, '0007_add_bot_admins') async with sqlite_engine.connect() as conn: admin_rows = ( await conn.execute( sa.select( bot_models.BotAdmin.launcher_type, bot_models.BotAdmin.launcher_id, ).order_by(bot_models.BotAdmin.launcher_type, bot_models.BotAdmin.launcher_id) ) ).all() stored_config = ( await conn.execute( sa.select(metadata_models.Metadata.value).where(metadata_models.Metadata.key == 'instance_config') ) ).scalar_one() assert admin_rows == [('group', 'admin-1'), ('person', 'user_with_underscore')] assert json.loads(stored_config) == {'preserved': True} @pytest.mark.asyncio async def test_pipeline_routing_rules_preserve_message_filters(self, sqlite_engine): """Every legacy routing rule keeps its matching semantics in event bindings.""" routing_rules = [ { 'type': 'launcher_type', 'operator': 'eq', 'value': 'group', 'pipeline_uuid': 'pipeline-group', }, { 'type': 'launcher_id', 'operator': 'regex', 'value': '^room-', 'pipeline_uuid': 'pipeline-room', }, { 'type': 'message_content', 'operator': 'contains', 'value': 'urgent', 'pipeline_uuid': 'pipeline-content', }, { 'type': 'message_has_element', 'operator': 'eq', 'value': 'Image', 'pipeline_uuid': 'pipeline-image', }, { 'type': 'message_has_element', 'operator': 'neq', 'value': 'Voice', 'pipeline_uuid': 'pipeline-no-voice', }, ] async with sqlite_engine.begin() as conn: await conn.execute( sa.text( 'CREATE TABLE bots (' 'uuid VARCHAR(255) PRIMARY KEY, ' 'use_pipeline_uuid VARCHAR(255), ' 'pipeline_routing_rules JSON NOT NULL, ' 'event_bindings JSON NOT NULL' ')' ) ) await conn.execute( sa.text( 'INSERT INTO bots ' '(uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings) ' 'VALUES (:uuid, :default_pipeline, :rules, :bindings)' ), { 'uuid': 'bot-routing', 'default_pipeline': 'pipeline-default', 'rules': json.dumps(routing_rules), 'bindings': '[]', }, ) await run_alembic_stamp(sqlite_engine, '0008_agent_product_surface') await run_alembic_upgrade(sqlite_engine, '0009_migrate_event_bindings') async with sqlite_engine.connect() as conn: raw_bindings = ( await conn.execute(sa.text("SELECT event_bindings FROM bots WHERE uuid = 'bot-routing'")) ).scalar_one() bindings = json.loads(raw_bindings) filters_by_pipeline = {binding['target_uuid']: binding['filters'] for binding in bindings} assert filters_by_pipeline == { 'pipeline-group': [{'field': 'chat_type', 'operator': 'eq', 'value': 'group'}], 'pipeline-room': [{'field': 'chat_id', 'operator': 'regex', 'value': '^room-'}], 'pipeline-content': [{'field': 'message_text', 'operator': 'contains', 'value': 'urgent'}], 'pipeline-image': [{'field': 'message_element_types', 'operator': 'contains', 'value': 'Image'}], 'pipeline-no-voice': [{'field': 'message_element_types', 'operator': 'not_contains', 'value': 'Voice'}], 'pipeline-default': [], } class TestSQLiteMigrationFreshDatabase: """Tests for fresh database workflow.""" @pytest.mark.asyncio async def test_fresh_db_upgrade_from_scratch(self, tmp_path): """ Fresh database (no tables) can be upgraded directly to head. Workflow: 1. Create fresh engine with new DB file 2. Create tables 3. Upgrade to head 4. Verify revision """ # Use different DB file for fresh test fresh_db_file = tmp_path / 'test_migrations_fresh.db' fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}' fresh_engine = create_async_engine(fresh_url) # Create tables on fresh DB async with fresh_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Upgrade to head directly (no baseline stamp) await run_alembic_upgrade(fresh_engine, 'head') # Verify revision rev = await get_alembic_current(fresh_engine) assert rev is not None, 'Expected a revision on fresh DB' await fresh_engine.dispose() @pytest.mark.asyncio async def test_fresh_db_without_create_all_behavior(self, tmp_path): """ Fresh database without create_all - test actual behavior. This tests what happens when migrations run on truly empty DB. The behavior is determined by Alembic and migration scripts. EXPECTED: Either: 1. Migration succeeds (if scripts handle empty DB) 2. Migration fails with specific error (if scripts require tables) IMPORTANT: This test verifies the ACTUAL behavior, not accepting any arbitrary failure with try-except pass. """ fresh_db_file = tmp_path / 'test_empty_migrations.db' fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}' fresh_engine = create_async_engine(fresh_url) # Capture the actual behavior actual_result = None actual_error = None try: await run_alembic_upgrade(fresh_engine, 'head') rev = await get_alembic_current(fresh_engine) actual_result = rev except Exception as e: actual_error = e await fresh_engine.dispose() # Verify specific behavior - one of two outcomes is expected if actual_result is not None: # Migration succeeded - verify revision exists assert actual_result is not None, 'Revision should exist after successful migration' else: # Migration failed - verify the error type is known # Alembic typically raises specific errors for missing tables assert actual_error is not None, 'Error should be captured if migration failed' # Log the error type for documentation (don't silently pass) error_type = type(actual_error).__name__ # Acceptable error types for empty DB scenarios acceptable_errors = [ 'OperationalError', # SQLite table not found 'ProgrammingError', # SQLAlchemy errors 'CommandError', # Alembic command errors ] assert error_type in acceptable_errors, ( f'Unexpected error type: {error_type}. ' f'This may indicate a regression in migration behavior. ' f'Error: {actual_error}' ) class TestSQLiteMigrationGetCurrent: """Tests for get_alembic_current behavior.""" @pytest.mark.asyncio async def test_get_current_on_unstamped_db_returns_none(self, sqlite_engine): """ get_alembic_current returns None for unstamped database. """ # Create tables but don't stamp async with sqlite_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # No stamp - should return None rev = await get_alembic_current(sqlite_engine) assert rev is None, f'Expected None for unstamped DB, got {rev}' @pytest.mark.asyncio async def test_get_current_after_stamp_returns_revision(self, sqlite_engine): """ get_alembic_current returns correct revision after stamp. """ async with sqlite_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) await run_alembic_stamp(sqlite_engine, '0001_baseline') rev = await get_alembic_current(sqlite_engine) assert rev == '0001_baseline'