Build reusable bot framework
This commit is contained in:
170
tests/unit/test_migration_engine.py
Normal file
170
tests/unit/test_migration_engine.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Unit tests for migration discovery and history decisions."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
|
||||
from core import migrations
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migrationRoot():
|
||||
with TemporaryDirectory(prefix=".migration-test-", dir=Path.cwd()) as directory:
|
||||
yield Path(directory)
|
||||
|
||||
|
||||
def _writeMigration(root, relativePath, content="SELECT 1;"):
|
||||
path = root / relativePath
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, applied=None, historyTable=True):
|
||||
self.applied = list(applied or [])
|
||||
self.historyTable = historyTable
|
||||
self.executed = []
|
||||
self._one = None
|
||||
self._all = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def execute(self, query, params=None):
|
||||
normalized = " ".join(query.split())
|
||||
self.executed.append((normalized, params))
|
||||
if "SELECT to_regclass" in normalized:
|
||||
self._one = {
|
||||
"table_name": "schema_migrations" if self.historyTable else None
|
||||
}
|
||||
elif normalized.startswith("SELECT namespace, version, checksum"):
|
||||
self._all = self.applied
|
||||
|
||||
def fetchone(self):
|
||||
return self._one
|
||||
|
||||
def fetchall(self):
|
||||
return self._all
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor):
|
||||
self.activeCursor = cursor
|
||||
|
||||
def cursor(self, **_kwargs):
|
||||
return self.activeCursor
|
||||
|
||||
|
||||
def _connectionFor(cursor):
|
||||
@contextmanager
|
||||
def fakeConnection():
|
||||
yield FakeConnection(cursor)
|
||||
|
||||
return fakeConnection
|
||||
|
||||
|
||||
def test_discovery_orders_core_before_feature_namespaces(migrationRoot):
|
||||
_writeMigration(migrationRoot, "config/migrations/0002_second.sql", "SELECT 2;")
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
|
||||
_writeMigration(migrationRoot, "modules/zeta/migrations/0001_zeta.sql")
|
||||
_writeMigration(migrationRoot, "modules/alpha/migrations/0002_alpha.sql")
|
||||
|
||||
found = migrations.discover_migrations(migrationRoot)
|
||||
|
||||
assert [(item.namespace, item.version) for item in found] == [
|
||||
("core", 1),
|
||||
("core", 2),
|
||||
("alpha", 2),
|
||||
("zeta", 1),
|
||||
]
|
||||
assert len(found[0].checksum) == 64
|
||||
assert found[0].path == Path(
|
||||
migrationRoot, "config/migrations/0001_first.sql"
|
||||
)
|
||||
|
||||
|
||||
def test_discovery_rejects_bad_names_and_duplicate_versions(migrationRoot):
|
||||
_writeMigration(migrationRoot, "config/migrations/not-numbered.sql")
|
||||
with pytest.raises(migrations.MigrationError, match="Invalid migration filename"):
|
||||
migrations.discover_migrations(migrationRoot)
|
||||
|
||||
Path(migrationRoot, "config/migrations/not-numbered.sql").unlink()
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql")
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_duplicate.sql")
|
||||
with pytest.raises(migrations.MigrationError, match="Duplicate migration"):
|
||||
migrations.discover_migrations(migrationRoot)
|
||||
|
||||
|
||||
def test_upgrade_applies_pending_and_skips_matching_history(
|
||||
migrationRoot, monkeypatch
|
||||
):
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 42;")
|
||||
migration = migrations.discover_migrations(migrationRoot)[0]
|
||||
cursor = FakeCursor()
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
|
||||
assert migrations.upgrade(migrationRoot) == [migration]
|
||||
queries = [query for query, _params in cursor.executed]
|
||||
assert any("pg_advisory_xact_lock" in query for query in queries)
|
||||
assert "SELECT 42;" in queries
|
||||
assert any(query.startswith("INSERT INTO schema_migrations") for query in queries)
|
||||
|
||||
cursor = FakeCursor(
|
||||
applied=[
|
||||
{
|
||||
"namespace": "core",
|
||||
"version": 1,
|
||||
"checksum": migration.checksum,
|
||||
"applied_at": None,
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
assert migrations.upgrade(migrationRoot) == []
|
||||
assert "SELECT 42;" not in [query for query, _params in cursor.executed]
|
||||
|
||||
|
||||
def test_upgrade_rejects_changed_applied_migration(migrationRoot, monkeypatch):
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
|
||||
cursor = FakeCursor(
|
||||
applied=[
|
||||
{
|
||||
"namespace": "core",
|
||||
"version": 1,
|
||||
"checksum": "0" * 64,
|
||||
"applied_at": None,
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
|
||||
with pytest.raises(migrations.MigrationError, match="checksum changed"):
|
||||
migrations.upgrade(migrationRoot)
|
||||
|
||||
|
||||
def test_status_handles_new_database_and_missing_source(migrationRoot, monkeypatch):
|
||||
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
|
||||
cursor = FakeCursor(historyTable=False)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
assert migrations.migration_status(migrationRoot)[0]["state"] == "pending"
|
||||
|
||||
cursor = FakeCursor(
|
||||
applied=[
|
||||
{
|
||||
"namespace": "removed_feature",
|
||||
"version": 3,
|
||||
"checksum": "a" * 64,
|
||||
"applied_at": "earlier",
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
|
||||
status = migrations.migration_status(migrationRoot)
|
||||
assert [item["state"] for item in status] == ["pending", "missing"]
|
||||
assert status[1]["namespace"] == "removed_feature"
|
||||
Reference in New Issue
Block a user