Every round-trip test across test_book_migration.py, test_scribe_migration.py and test_tenants_migration.py re-upgraded to its own revision on the way back up rather than to head, so a full suite run left core_api_test one or two migrations behind (008, with 009/010 released since). Each now ends on `upgrade head`; a new TestSchemaEndsAtHeadAfterTheMigrationSuite class checks the final state directly with no upgrade of its own, as a regression guard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
213 lines
9.6 KiB
Python
213 lines
9.6 KiB
Python
"""
|
|
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
|