""" Tests for Scribe migrations 006, 009 and 010. Runs against `core_api_test` only (see conftest.py — skip when TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`). Self-contained: starts and ends at head regardless of what ran before it. """ import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import NullPool from tests.conftest import run_alembic async def _table_exists(url: str, table: str) -> bool: asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1) engine = create_async_engine(asyncpg_url, poolclass=NullPool) try: async with engine.connect() as conn: result = await conn.execute( text("SELECT to_regclass(:name) IS NOT NULL AS present"), {"name": table}, ) return bool(result.scalar()) finally: await engine.dispose() async def _is_nullable(url: str, table: str, column: str) -> bool: asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1) engine = create_async_engine(asyncpg_url, poolclass=NullPool) try: async with engine.connect() as conn: result = await conn.execute( text( "SELECT is_nullable FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column" ), {"table": table, "column": column}, ) return result.scalar() == "YES" finally: await engine.dispose() async def _clear_scribe_messages(url: str) -> None: """Truncate just the two row-bearing Scribe tables that 010's downgrade inspects — never `scribe_addresses`/`tenant_consumers`, which other modules' seeded grants depend on. Self-contained so this file's row-level checks never depend on what another test module's data left behind (there is no FK from either table to `scribe_addresses`).""" asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1) engine = create_async_engine(asyncpg_url, poolclass=NullPool) try: async with engine.begin() as conn: await conn.execute(text("TRUNCATE scribe_mailbox_entries, scribe_messages")) finally: await engine.dispose() async def _insert_sender_less_message(url: str) -> None: """Insert one scribe_messages row with `from_identity` NULL directly — bypassing the API, which (even under T-15's exemption) always exists for the one shape it allows; this proves the migration's own constraint/refusal, not the service's.""" asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1) engine = create_async_engine(asyncpg_url, poolclass=NullPool) try: async with engine.begin() as conn: await conn.execute( text( "INSERT INTO scribe_messages (owner_tenant, topics, from_identity, body) " "VALUES ('jpmschweitzer', ARRAY['tower-control'], NULL, 'no sender')" ) ) finally: await engine.dispose() async def _columns(url: str, table: str) -> set: asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1) engine = create_async_engine(asyncpg_url, poolclass=NullPool) try: async with engine.connect() as conn: result = await conn.execute( text( "SELECT column_name FROM information_schema.columns " "WHERE table_name = :table" ), {"table": table}, ) return {row[0] for row in result.all()} finally: await engine.dispose() class TestMigration006RoundTrip: """Migration 006 must upgrade and downgrade cleanly (dossier verification #1).""" @pytest.mark.asyncio async def test_upgrade_creates_scribe_tables(self, test_database_url): result = run_alembic(test_database_url, "upgrade", "head") assert result.returncode == 0, result.stderr for table in ("scribe_addresses", "scribe_messages", "scribe_mailbox_entries"): assert await _table_exists(test_database_url, table), f"{table} missing after upgrade" @pytest.mark.asyncio async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url): baseline = run_alembic(test_database_url, "upgrade", "head") assert baseline.returncode == 0, baseline.stderr down = run_alembic(test_database_url, "downgrade", "005") assert down.returncode == 0, down.stderr assert not await _table_exists(test_database_url, "scribe_addresses") assert not await _table_exists(test_database_url, "scribe_messages") assert not await _table_exists(test_database_url, "scribe_mailbox_entries") # Book tables, created by the migration below this one, must survive # a downgrade of 006 — proves 006 only touches its own tables. assert await _table_exists(test_database_url, "book_records") # Leave the schema at head for the rest of the suite (core-api T-14). up = run_alembic(test_database_url, "upgrade", "head") assert up.returncode == 0, up.stderr assert await _table_exists(test_database_url, "scribe_addresses") assert await _table_exists(test_database_url, "scribe_messages") assert await _table_exists(test_database_url, "scribe_mailbox_entries") class TestMigration009RoundTrip: """Migration 009 (core-api T-13) must upgrade and downgrade cleanly — dossier verification #2: 008 -> 009 -> 008 -> 009.""" @pytest.mark.asyncio async def test_upgrade_adds_archived_and_trashed_at(self, test_database_url): result = run_alembic(test_database_url, "upgrade", "head") assert result.returncode == 0, result.stderr columns = await _columns(test_database_url, "scribe_mailbox_entries") assert {"archived_at", "trashed_at"} <= columns @pytest.mark.asyncio async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url): baseline = run_alembic(test_database_url, "upgrade", "head") assert baseline.returncode == 0, baseline.stderr down = run_alembic(test_database_url, "downgrade", "008") assert down.returncode == 0, down.stderr columns_at_008 = await _columns(test_database_url, "scribe_mailbox_entries") assert "archived_at" not in columns_at_008 assert "trashed_at" not in columns_at_008 # The table itself, created by 006, must survive a downgrade of # 009 — proves 009 only adds/drops its own two columns. assert await _table_exists(test_database_url, "scribe_mailbox_entries") # Leave the schema at head (009 is head) for the rest of the suite. up = run_alembic(test_database_url, "upgrade", "head") assert up.returncode == 0, up.stderr columns_at_head = await _columns(test_database_url, "scribe_mailbox_entries") assert {"archived_at", "trashed_at"} <= columns_at_head class TestMigration010RoundTrip: """Migration 010 (core-api T-15 / D-70) must upgrade and downgrade cleanly — dossier verification #2: 009 -> 010 -> 009 -> 010 — and its downgrade must name a decision about a sender-less row rather than silently inventing one (the brief: refusing is acceptable, fabricating a sender is not). `_clear_scribe_messages` scopes each test to its own row state rather than trusting what another test module left behind.""" @pytest.mark.asyncio async def test_upgrade_makes_from_identity_nullable(self, test_database_url): result = run_alembic(test_database_url, "upgrade", "head") assert result.returncode == 0, result.stderr assert await _is_nullable(test_database_url, "scribe_messages", "from_identity") @pytest.mark.asyncio async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url): baseline = run_alembic(test_database_url, "upgrade", "head") assert baseline.returncode == 0, baseline.stderr await _clear_scribe_messages(test_database_url) # no sender-less row in the way down = run_alembic(test_database_url, "downgrade", "009") assert down.returncode == 0, down.stderr assert not await _is_nullable(test_database_url, "scribe_messages", "from_identity") # Leave the schema at head (010 is head) for the rest of the suite. up = run_alembic(test_database_url, "upgrade", "head") assert up.returncode == 0, up.stderr assert await _is_nullable(test_database_url, "scribe_messages", "from_identity") @pytest.mark.asyncio async def test_downgrade_refuses_while_a_sender_less_row_exists(self, test_database_url): """The chosen behaviour, named: the downgrade does not run, and the column stays nullable — nothing silently invents a sender for the row it found.""" baseline = run_alembic(test_database_url, "upgrade", "head") assert baseline.returncode == 0, baseline.stderr await _clear_scribe_messages(test_database_url) await _insert_sender_less_message(test_database_url) down = run_alembic(test_database_url, "downgrade", "009") assert down.returncode != 0 assert "migration 010 downgrade refused" in (down.stdout + down.stderr) assert await _is_nullable(test_database_url, "scribe_messages", "from_identity") # Leave the schema at head, clean, for the rest of the suite. await _clear_scribe_messages(test_database_url) up = run_alembic(test_database_url, "upgrade", "head") assert up.returncode == 0, up.stderr