"""Migration hygiene (TEST-3), DB-lane. Two things the prod boot never checks: - **model <-> schema drift:** `entrypoint.sh` only ever runs `alembic upgrade head`; nothing verifies the migrated schema still matches `Base.metadata`. A model column added without a migration (or a migration that drifted from the model) ships silently. - **the downgrade path:** every migration defines `downgrade()`, but it never runs, so a broken downgrade is only discovered during an emergency rollback. Both use the `db` fixture, which has already brought the schema to head via `_schema`. Scope note: we gate on COLUMN/TABLE drift — a table or column present on one side and not the other, or a column whose type/nullability disagrees. `compare_metadata` also reports index/constraint/sequence differences, but those are dominated by migration-only objects (partial/expression/GIN indexes, CHECK constraints, SERIAL sequences) that can't be expressed on `Base.metadata`, so comparing them wholesale is noise; they're filtered out. The column/table signal is the "someone changed a model without a migration" case worth failing on. """ from alembic import command from alembic.autogenerate import compare_metadata from alembic.config import Config from alembic.migration import MigrationContext from app.db import Base, engine # The diff ops that mean a real table/column disagreement (presence AND shape) — not the index/ # constraint/sequence noise from migration-only objects. _DRIFT_OPS = { "add_table", "remove_table", "add_column", "remove_column", "modify_type", "modify_nullable", } def _structural_drift(): """Table/column presence diffs between the ORM models and the migrated schema (`compare_type=True` mirrors alembic/env.py). compare_metadata nests per-column ops in a sub-list per table and returns table-level ops as bare tuples; flatten both and keep only the presence ops.""" with engine.connect() as conn: ctx = MigrationContext.configure(conn, opts={"compare_type": True}) diffs = compare_metadata(ctx, Base.metadata) drift = [] for d in diffs: for entry in d if isinstance(d, list) else [d]: if isinstance(entry, tuple) and entry and entry[0] in _DRIFT_OPS: drift.append(entry) return drift def test_models_match_the_migrated_schema(db): # Non-empty == the ORM models and the alembic-built schema disagree on a table/column — almost # always a model change that forgot its migration. drift = _structural_drift() assert drift == [], "model/schema drift — add or fix a migration:\n" + "\n".join( str(d) for d in drift ) def test_head_migration_downgrades_and_reupgrades(db): # Exercise the newest migration's downgrade + re-upgrade so a broken downgrade is caught the sprint # it lands, not during a rollback. Leaves the schema back at head for the rest of the session. cfg = Config("alembic.ini") command.downgrade(cfg, "-1") reupgraded = False try: command.upgrade(cfg, "head") reupgraded = True assert _structural_drift() == [] finally: # If the re-upgrade itself failed, restore the SHARED schema to head so the failure doesn't # cascade into every later DB-lane test. Skip it when the upgrade already succeeded — re-running # it would be a redundant no-op and could mask a failing assert with the finally's own error. if not reupgraded: command.upgrade(cfg, "head")