mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 18:06:06 +00:00
462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""
|
|
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'
|