Skip to content

Commit fafc90a

Browse files
authored
test: coverage improvements, # mrt: ignore, Django dynamic verification fix (#21)
* test: cover _is_auto_pk SERIAL/nextval paths and json exit-1 on warnings * feat: add # mrt: ignore annotation + fix Django dynamic verification - Add # mrt: ignore line-level suppression in analyze_migrations() and analyze_django_migrations(): warnings whose triggering line contains the annotation are silently dropped, same as # noqa - Fix Django dynamic verification: switch test fixture from sqlite:///:memory: (NullPool) to a named temp file so Django's connection and SQLAlchemy's engine see the same database; without this SchemaSnapshot comparisons were always vacuously equal - Add tests/django_bad_app/ with a RunSQL noop-reverse migration and implement test_django_verifier_noop_downgrade_fails to confirm schema drift is detected (Issue #17)
1 parent 83e06af commit fafc90a

10 files changed

Lines changed: 242 additions & 27 deletions

File tree

pytest_mrt/adapters/django_detector.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,15 @@ def analyze_django_migrations(migrations_dir: str) -> list[RiskWarning]:
512512
)
513513
)
514514
continue
515+
source_lines = path.read_text().splitlines()
515516
for check in _DJANGO_CHECKS:
516-
warnings.extend(check(m))
517+
for w in check(m):
518+
if (
519+
w.line is not None
520+
and w.line <= len(source_lines)
521+
and "# mrt: ignore" in source_lines[w.line - 1]
522+
):
523+
continue
524+
warnings.append(w)
517525

518526
return warnings

pytest_mrt/core/detector.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -814,8 +814,16 @@ def analyze_migrations(versions_dir: str) -> list[RiskWarning]:
814814
)
815815
continue
816816
migrations.append(m)
817+
source_lines = source.splitlines()
817818
for check in _PER_FILE_CHECKS:
818-
warnings.extend(check(m))
819+
for w in check(m):
820+
if (
821+
w.line is not None
822+
and w.line <= len(source_lines)
823+
and "# mrt: ignore" in source_lines[w.line - 1]
824+
):
825+
continue
826+
warnings.append(w)
819827

820828
warnings.extend(_check_multiple_heads(migrations))
821829
warnings.extend(analyze_migration_graph(versions_dir))

tests/django_bad_app/__init__.py

Whitespace-only changes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Migration with a no-op downgrade — used to verify that DjangoRollbackVerifier
3+
detects schema drift when the rollback path does nothing.
4+
5+
Forward: creates a table via raw SQL
6+
Backward: RunSQL.noop — the table is never dropped, leaving schema drift.
7+
"""
8+
9+
from django.db import migrations
10+
11+
12+
class Migration(migrations.Migration):
13+
dependencies = []
14+
15+
operations = [
16+
migrations.RunSQL(
17+
sql=(
18+
"CREATE TABLE django_bad_app_leaked "
19+
"(id INTEGER PRIMARY KEY, val TEXT NOT NULL DEFAULT '')"
20+
),
21+
reverse_sql=migrations.RunSQL.noop,
22+
),
23+
]

tests/django_bad_app/migrations/__init__.py

Whitespace-only changes.

tests/test_cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ def test_check_json_safe_exits_0(tmp_path, versions_dir):
111111
assert result.exit_code == 0
112112

113113

114+
def test_check_json_warnings_only_exits_1(tmp_path, versions_dir):
115+
"""--format json with warnings but no errors must exit 1."""
116+
(versions_dir / "001.py").write_text(textwrap.dedent("""
117+
revision = '001'
118+
down_revision = None
119+
branch_labels = None
120+
depends_on = None
121+
from alembic import op
122+
def upgrade():
123+
op.create_index('ix_name', 'users', ['name'])
124+
def downgrade():
125+
op.drop_index('ix_name', table_name='users')
126+
"""))
127+
result = runner.invoke(app, ["check", str(versions_dir), "--format", "json"])
128+
assert result.exit_code == 1
129+
130+
114131
def test_check_strict_exits_1_on_warnings(tmp_path, versions_dir):
115132
# A migration with only warnings (not errors) exits 0 normally
116133
(versions_dir / "001.py").write_text(textwrap.dedent("""

tests/test_detector.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,41 @@ def test_syntax_error_migration_reported(tmp_path):
636636
warnings = analyze_migrations(str(tmp_path))
637637
assert any(w.pattern == "Syntax error" for w in warnings)
638638
assert any(w.severity == "error" for w in warnings)
639+
640+
641+
def test_mrt_ignore_suppresses_warning(tmp_path):
642+
"""# mrt: ignore on the same line suppresses that warning."""
643+
migration(tmp_path, "001.py", """
644+
revision = '001'
645+
down_revision = None
646+
branch_labels = None
647+
depends_on = None
648+
from alembic import op
649+
def upgrade():
650+
op.drop_column('users', 'email') # mrt: ignore
651+
def downgrade():
652+
op.add_column('users', 'email')
653+
""")
654+
warnings = analyze_migrations(str(tmp_path))
655+
assert not any(w.pattern == "DROP COLUMN in upgrade" for w in warnings)
656+
657+
658+
def test_mrt_ignore_only_suppresses_annotated_line(tmp_path):
659+
"""# mrt: ignore on one line does not suppress warnings on other lines."""
660+
migration(tmp_path, "001.py", """
661+
revision = '001'
662+
down_revision = None
663+
branch_labels = None
664+
depends_on = None
665+
from alembic import op
666+
def upgrade():
667+
op.drop_column('users', 'email') # mrt: ignore
668+
op.drop_column('users', 'phone')
669+
def downgrade():
670+
op.add_column('users', 'email')
671+
op.add_column('users', 'phone')
672+
""")
673+
warnings = analyze_migrations(str(tmp_path))
674+
drop_warnings = [w for w in warnings if w.pattern == "DROP COLUMN in upgrade"]
675+
assert len(drop_warnings) == 1
676+
assert "phone" in drop_warnings[0].message

tests/test_django_detector.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,49 @@ class Migration(migrations.Migration):
384384
""")
385385
warnings = analyze_django_migrations(str(tmp_path))
386386
assert any(w.pattern == "Missing atomic=False" for w in warnings)
387+
388+
389+
def test_mrt_ignore_suppresses_django_warning(tmp_path):
390+
"""# mrt: ignore on the same line suppresses that Django migration warning."""
391+
app_dir = tmp_path / "myapp" / "migrations"
392+
app_dir.mkdir(parents=True, exist_ok=True)
393+
(app_dir / "0002_remove.py").write_text(textwrap.dedent("""
394+
from django.db import migrations
395+
396+
class Migration(migrations.Migration):
397+
dependencies = [('myapp', '0001_initial')]
398+
operations = [
399+
migrations.RemoveField( # mrt: ignore
400+
model_name='user',
401+
name='phone',
402+
),
403+
]
404+
""").lstrip())
405+
warnings = analyze_django_migrations(str(tmp_path))
406+
assert not any(w.pattern == "RemoveField" for w in warnings)
407+
408+
409+
def test_mrt_ignore_only_suppresses_annotated_django_line(tmp_path):
410+
"""# mrt: ignore on one operation does not suppress others."""
411+
app_dir = tmp_path / "myapp" / "migrations"
412+
app_dir.mkdir(parents=True, exist_ok=True)
413+
(app_dir / "0002_multi.py").write_text(textwrap.dedent("""
414+
from django.db import migrations
415+
416+
class Migration(migrations.Migration):
417+
dependencies = [('myapp', '0001_initial')]
418+
operations = [
419+
migrations.RemoveField( # mrt: ignore
420+
model_name='user',
421+
name='phone',
422+
),
423+
migrations.RemoveField(
424+
model_name='user',
425+
name='email',
426+
),
427+
]
428+
""").lstrip())
429+
warnings = analyze_django_migrations(str(tmp_path))
430+
remove_warnings = [w for w in warnings if w.pattern == "RemoveField"]
431+
assert len(remove_warnings) == 1
432+
assert "email" in remove_warnings[0].message

tests/test_django_dynamic.py

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
Django dynamic rollback integration tests.
33
44
Tests DjangoMigrationRunner and DjangoRollbackVerifier using an in-process
5-
Django configuration with SQLite — no external database required.
5+
Django configuration with a file-backed SQLite database — no external database
6+
required, but Django must be installed.
7+
8+
Uses a named SQLite file (via tmp_path_factory) so that both Django's own
9+
connection and SQLAlchemy's engine see the same database. The :memory: URI
10+
with NullPool would give each engine.connect() call a fresh, empty database,
11+
making SchemaSnapshot comparisons meaningless.
612
713
Skipped automatically when Django is not installed.
814
"""
@@ -20,26 +26,24 @@
2026
"django.contrib.contenttypes",
2127
"django.contrib.auth",
2228
"tests.django_app",
29+
"tests.django_bad_app",
2330
]
2431

25-
DB_URL = "sqlite:///:memory:"
32+
33+
# ── fixtures ──────────────────────────────────────────────────────────────────
2634

2735

28-
def _make_runner():
36+
@pytest.fixture(scope="module")
37+
def django_runner(tmp_path_factory):
2938
from pytest_mrt.adapters.django_runner import DjangoMigrationRunner
3039

31-
return DjangoMigrationRunner(
32-
db_url=DB_URL,
40+
db_path = tmp_path_factory.mktemp("django_db") / "test.db"
41+
db_url = f"sqlite:///{db_path}"
42+
43+
runner = DjangoMigrationRunner(
44+
db_url=db_url,
3345
installed_apps=INSTALLED_APPS,
3446
)
35-
36-
37-
# ── fixtures ──────────────────────────────────────────────────────────────────
38-
39-
40-
@pytest.fixture(scope="module")
41-
def django_runner():
42-
runner = _make_runner()
4347
yield runner
4448
runner.dispose()
4549

@@ -55,8 +59,8 @@ def test_django_runner_get_migrations(django_runner):
5559

5660

5761
def test_django_runner_upgrade_downgrade(django_runner):
58-
"""upgrade() then downgrade() leaves DB at original state."""
59-
from pytest_mrt.core.schema import SchemaSnapshot
62+
"""upgrade() then downgrade() leaves DB schema unchanged."""
63+
from pytest_mrt.core.schema import SchemaSnapshot, SchemaDiff
6064

6165
migrations = django_runner.get_migrations(apps=["django_app"])
6266
assert migrations, "No migrations found in django_app"
@@ -69,8 +73,6 @@ def test_django_runner_upgrade_downgrade(django_runner):
6973
django_runner.downgrade(first.app_label, first.name)
7074

7175
snap_after = SchemaSnapshot.capture(django_runner.engine)
72-
from pytest_mrt.core.schema import SchemaDiff
73-
7476
issues = SchemaDiff().verify_restored(snap_before, snap_after)
7577
assert issues == [], [i.message for i in issues]
7678

@@ -87,11 +89,21 @@ def test_django_verifier_check_all_pass(django_runner):
8789
assert not failed, "\n".join(r.failure_summary() for r in failed)
8890

8991

90-
def test_django_verifier_noop_downgrade_fails(tmp_path):
91-
"""DjangoRollbackVerifier catches a no-op downgrade migration."""
92-
pytest.skip(
93-
"No-op downgrade detection requires a separate Django app fixture — "
94-
"covered by static analysis (D-pattern checks)"
92+
def test_django_verifier_noop_downgrade_fails(django_runner):
93+
"""DjangoRollbackVerifier detects a migration whose downgrade is a no-op."""
94+
from pytest_mrt.adapters.django_verifier import DjangoRollbackVerifier
95+
96+
verifier = DjangoRollbackVerifier(django_runner, timeout=30)
97+
results = verifier.check_all(apps=["django_bad_app"])
98+
99+
assert results, "No results — django_bad_app migration not found"
100+
# The first result must be a failure: schema drift (leaked table not dropped)
101+
first = results[0]
102+
assert not first.passed, (
103+
"Expected noop downgrade to be detected as a failure, but it passed"
104+
)
105+
assert any("leaked" in f.lower() or "still exists" in f.lower() for f in first.failures), (
106+
f"Expected 'still exists' schema error, got: {first.failures}"
95107
)
96108

97109

@@ -100,14 +112,14 @@ def test_mrt_config_django_mode():
100112
from pytest_mrt.config import MRTConfig
101113

102114
cfg = MRTConfig(
103-
db_url=DB_URL,
104-
django_settings=None, # not activating Django mode here
115+
db_url="sqlite:///test.db",
116+
django_settings=None,
105117
)
106118
assert cfg.django_settings is None
107119
assert cfg.django_apps is None
108120

109121
cfg_django = MRTConfig(
110-
db_url=DB_URL,
122+
db_url="sqlite:///test.db",
111123
django_settings="myproject.settings_test",
112124
django_apps=["users"],
113125
)

tests/test_seeder.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,17 @@ def test_topological_order_self_reference():
263263
assert "nodes" in order
264264

265265

266+
def test_topological_order_mutual_fk_no_infinite_loop():
267+
"""Circular FK between two tables must not loop infinitely."""
268+
tables = {
269+
"a": TableInfo("a", fk_tables=["b"]),
270+
"b": TableInfo("b", fk_tables=["a"]),
271+
}
272+
order = _topological_order(tables)
273+
assert set(order) == {"a", "b"}
274+
assert len(order) == 2
275+
276+
266277
# ── SmartSeeder ───────────────────────────────────────────────────────
267278

268279
def test_seeder_seed_table_basic(engine):
@@ -421,3 +432,55 @@ def test_seeder_skips_nullable_columns(engine):
421432
assert len(rows) == 3
422433
# body is nullable, so it should be NULL in seeded rows
423434
assert all(row[1] is None for row in rows)
435+
436+
437+
# ── SmartSeeder._is_auto_pk ───────────────────────────────────────────
438+
439+
def test_is_auto_pk_serial_type(engine):
440+
"""SERIAL type string is recognised as auto-generated PK."""
441+
seeder = SmartSeeder(engine)
442+
col = ColumnInfo(name="id", type_str="SERIAL", nullable=False, primary_key=True)
443+
assert seeder._is_auto_pk(col) is True
444+
445+
446+
def test_is_auto_pk_auto_increment_type(engine):
447+
"""AUTO_INCREMENT (MySQL) is recognised as auto-generated PK."""
448+
seeder = SmartSeeder(engine)
449+
col = ColumnInfo(name="id", type_str="INT AUTO_INCREMENT", nullable=False, primary_key=True)
450+
assert seeder._is_auto_pk(col) is True
451+
452+
453+
def test_is_auto_pk_nextval_default(engine):
454+
"""Sequence-backed PK detected via nextval() in the column default."""
455+
seeder = SmartSeeder(engine)
456+
col = ColumnInfo(name="id", type_str="INTEGER", nullable=False, primary_key=True,
457+
default="nextval('items_id_seq'::regclass)")
458+
assert seeder._is_auto_pk(col) is True
459+
460+
461+
def test_is_auto_pk_plain_integer_is_not_auto(engine):
462+
"""Plain INTEGER PK with no default is not auto-generated."""
463+
seeder = SmartSeeder(engine)
464+
col = ColumnInfo(name="id", type_str="INTEGER", nullable=False, primary_key=True)
465+
assert seeder._is_auto_pk(col) is False
466+
467+
468+
def test_seed_table_skips_serial_pk_row(engine):
469+
"""seed_table inserts nothing when the only non-nullable column is a SERIAL PK."""
470+
with engine.begin() as conn:
471+
conn.execute(text("CREATE TABLE only_serial (id INTEGER NOT NULL PRIMARY KEY)"))
472+
473+
seeder = SmartSeeder(engine)
474+
# Construct TableInfo manually so type_str is SERIAL (as PG would reflect it)
475+
table = TableInfo(
476+
name="only_serial",
477+
columns={
478+
"id": ColumnInfo(name="id", type_str="SERIAL", nullable=False, primary_key=True),
479+
},
480+
pk_cols=["id"],
481+
)
482+
seeder.seed_table(table)
483+
484+
with engine.connect() as conn:
485+
count = conn.execute(text("SELECT COUNT(*) FROM only_serial")).scalar()
486+
assert count == 0

0 commit comments

Comments
 (0)