"""Tests for the PRAGMA user_version migration runner.""" from __future__ import annotations from pathlib import Path import pytest import sqlalchemy as sa import sqlalchemy.exc from cyclone import db_migrate def _fresh_engine(tmp_path: Path) -> sa.Engine: return sa.create_engine(f"sqlite:///{tmp_path}/m.db", future=True) def _user_version(engine: sa.Engine) -> int: with engine.connect() as c: return c.exec_driver_sql("PRAGMA user_version").scalar() or 0 def test_run_on_empty_db_bumps_to_latest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path) (tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t (id INTEGER);\n") engine = _fresh_engine(tmp_path) db_migrate.run(engine) assert _user_version(engine) == 1 with engine.connect() as c: rows = c.exec_driver_sql("SELECT name FROM sqlite_master WHERE type='table' AND name='t'").all() assert len(rows) == 1 def test_run_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path) (tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t (id INTEGER);\n") engine = _fresh_engine(tmp_path) db_migrate.run(engine) db_migrate.run(engine) # second call should be a no-op assert _user_version(engine) == 1 def test_run_skips_already_applied_migrations(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path) (tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t1 (id INTEGER);\n") (tmp_path / "0002_add_col.sql").write_text("-- version: 2\nALTER TABLE t1 ADD COLUMN name TEXT;\n") engine = _fresh_engine(tmp_path) db_migrate.run(engine) assert _user_version(engine) == 2 # Modify 0002 (a no-op for SQLite ALTER, but we want to verify the # runner doesn't re-run already-applied migrations). db_migrate.run(engine) assert _user_version(engine) == 2 def test_run_raises_on_missing_version_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path) (tmp_path / "0001_bad.sql").write_text("CREATE TABLE t (id INTEGER);\n") # no header engine = _fresh_engine(tmp_path) with pytest.raises(RuntimeError, match="missing '-- version"): db_migrate.run(engine) def test_run_rolls_back_version_on_failed_statement( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """If a DDL statement fails, the version bump must also roll back.""" monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path) (tmp_path / "0001_initial.sql").write_text( "-- version: 1\nCREATE TABLE t (id INTEGER);\n" ) engine = _fresh_engine(tmp_path) db_migrate.run(engine) # applies 0001, user_version=1 assert _user_version(engine) == 1 # Now drop in a second migration whose DDL conflicts with the first. # The runner should apply 0001 and 0002 in one call; 0002 fails, the # transaction rolls back, and user_version must stay at 1 (not bump to 2). (tmp_path / "0002_bad.sql").write_text( "-- version: 2\nCREATE TABLE t (id INTEGER);\n" # table already exists! ) with pytest.raises(sqlalchemy.exc.SQLAlchemyError): db_migrate.run(engine) assert _user_version(engine) == 1 # bumped atomically, rolled back on failure def test_run_ignores_non_sql_files( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """README.md and similar files in MIGRATIONS_DIR must not be executed.""" monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path) (tmp_path / "0001_initial.sql").write_text( "-- version: 1\nCREATE TABLE t (id INTEGER);\n" ) (tmp_path / "README.md").write_text("Don't run me!\nCREATE TABLE should_not_exist (x INT);\n") engine = _fresh_engine(tmp_path) db_migrate.run(engine) with engine.connect() as c: rows = c.exec_driver_sql( "SELECT name FROM sqlite_master WHERE type='table' AND name='should_not_exist'" ).all() assert len(rows) == 0 assert _user_version(engine) == 1