Files
siftlode/backend/tests/test_migrations.py
T
peter 472cefed55 test(migrations)+fix: widen drift check to column shape; notifications.created_at NOT NULL (S2 review #2)
The drift check dropped every diff except table/column presence, so a column
type or nullability change would slip through. Add modify_type/modify_nullable
to the ops it fails on. That immediately surfaced a real pre-existing drift:
notifications.created_at was created NULLABLE by migration 0017 but the model
has always declared it NOT NULL (Mapped[datetime] + server_default=func.now()).
Migration 0066 enforces NOT NULL to match the model — safe, since the
server_default has filled every row since day one.
2026-08-18 01:39:46 +02:00

64 lines
3.0 KiB
Python

"""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")
command.upgrade(cfg, "head")
assert _structural_drift() == []