""" Tests for Book migration 005. 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, so test order never matters for the schema the rest of the suite needs. """ 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() class TestMigration005RoundTrip: """Migration 005 must upgrade and downgrade cleanly (dossier verification #2).""" @pytest.mark.asyncio async def test_upgrade_creates_book_tables(self, test_database_url): result = run_alembic(test_database_url, "upgrade", "head") assert result.returncode == 0, result.stderr for table in ("book_records", "book_tags", "book_record_links", "book_attachments", "book_events"): 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: make sure we start at head. baseline = run_alembic(test_database_url, "upgrade", "head") assert baseline.returncode == 0, baseline.stderr down = run_alembic(test_database_url, "downgrade", "004") assert down.returncode == 0, down.stderr assert not await _table_exists(test_database_url, "book_records") assert not await _table_exists(test_database_url, "book_events") # Leave the schema at head for the rest of the suite. up = run_alembic(test_database_url, "upgrade", "005") assert up.returncode == 0, up.stderr assert await _table_exists(test_database_url, "book_records") assert await _table_exists(test_database_url, "book_events") class TestBookEventsAppendOnly: """The trigger must block UPDATE and DELETE on book_events, no exceptions.""" @pytest.mark.asyncio async def test_update_on_book_events_raises(self, clean_book_tables): from src.domains.book.models import BookEvent, BookRecord session = clean_book_tables record = BookRecord(kind="note", body="trigger test", status="active", created_by="test-harness") session.add(record) await session.flush() event = BookEvent(record_id=record.id, identity="test-harness", client=None, action="add", diff={}) session.add(event) await session.commit() with pytest.raises(Exception) as exc_info: await session.execute( text("UPDATE book_events SET action = 'tampered' WHERE id = :id").bindparams(id=event.id) ) await session.commit() assert "append-only" in str(exc_info.value).lower() or "book_events" in str(exc_info.value) await session.rollback() @pytest.mark.asyncio async def test_delete_on_book_events_raises(self, clean_book_tables): from src.domains.book.models import BookEvent, BookRecord session = clean_book_tables record = BookRecord(kind="note", body="trigger test", status="active", created_by="test-harness") session.add(record) await session.flush() event = BookEvent(record_id=record.id, identity="test-harness", client=None, action="add", diff={}) session.add(event) await session.commit() with pytest.raises(Exception) as exc_info: await session.execute( text("DELETE FROM book_events WHERE id = :id").bindparams(id=event.id) ) await session.commit() assert "append-only" in str(exc_info.value).lower() or "book_events" in str(exc_info.value) await session.rollback()