c7d67031d4
Merge origin/main into feature branch, resolving 18 conflicts: - app/api/__init__.py: add classification_rules_router alongside system_reset_router - app/config.py: keep system reset settings from main - app/models.py: keep ClassificationRuleModel alongside main's models - app/utils/settings_service.py: keep factory reset settings from main - app/views/__init__.py: keep system_reset_router from main - tests/conftest.py: add ClassificationRuleModel import - migrations/env.py: add ClassificationRuleModel import - .env.demo, docs/*, frontend/*, mobile/*: keep additions from main - BUILD_DATE, GIT_SHA, RUNTIME_INFO, VERSION, CHANGELOG.md: accept main's version Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
200 lines
7.8 KiB
Python
200 lines
7.8 KiB
Python
"""Tests for scripts/check_alembic_migrations.py."""
|
|
|
|
# The script lives outside of the ``app`` package, so we import it by path.
|
|
import importlib.util
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_alembic_migrations.py"
|
|
_spec = importlib.util.spec_from_file_location("check_alembic_migrations", _SCRIPT)
|
|
assert _spec and _spec.loader
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
|
|
|
|
check_migrations = _mod.check_migrations
|
|
main = _mod.main
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_migration(
|
|
directory: Path, filename: str, revision: str, down_revision: str | tuple[str, ...] | None
|
|
) -> Path:
|
|
"""Helper to create a minimal migration file."""
|
|
if down_revision is None:
|
|
down_rev_str = "None"
|
|
elif isinstance(down_revision, tuple):
|
|
down_rev_str = repr(down_revision)
|
|
else:
|
|
down_rev_str = f'"{down_revision}"'
|
|
|
|
content = textwrap.dedent(f'''\
|
|
"""Test migration."""
|
|
from typing import Union
|
|
revision: str = "{revision}"
|
|
down_revision: Union[str, None] = {down_rev_str}
|
|
depends_on: Union[str, None] = None
|
|
def upgrade() -> None:
|
|
pass
|
|
def downgrade() -> None:
|
|
pass
|
|
''')
|
|
path = directory / filename
|
|
path.write_text(content)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def versions_dir(tmp_path: Path) -> Path:
|
|
"""Return a temporary versions directory."""
|
|
d = tmp_path / "versions"
|
|
d.mkdir()
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCheckMigrations:
|
|
"""Tests for the check_migrations function."""
|
|
|
|
def test_valid_linear_chain(self, versions_dir: Path) -> None:
|
|
"""A simple linear chain should pass with no errors."""
|
|
_write_migration(versions_dir, "001_initial.py", "001_initial", None)
|
|
_write_migration(versions_dir, "002_add_col.py", "002_add_col", "001_initial")
|
|
_write_migration(versions_dir, "003_add_table.py", "003_add_table", "002_add_col")
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert errors == []
|
|
|
|
def test_valid_merge_migration(self, versions_dir: Path) -> None:
|
|
"""A chain with a merge point should pass."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
|
|
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
|
|
|
|
# Merge file with tuple down_revision
|
|
content = textwrap.dedent('''\
|
|
"""Merge."""
|
|
from typing import Union
|
|
revision: str = "003_merge"
|
|
down_revision: Union[str, tuple] = ("002_a", "002_b")
|
|
depends_on: Union[str, None] = None
|
|
def upgrade() -> None:
|
|
pass
|
|
def downgrade() -> None:
|
|
pass
|
|
''')
|
|
(versions_dir / "003_merge.py").write_text(content)
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert errors == []
|
|
|
|
def test_multiple_heads_detected(self, versions_dir: Path) -> None:
|
|
"""Two unmerged branches should report multiple heads."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
|
|
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert len(errors) == 1
|
|
assert "Multiple migration heads" in errors[0]
|
|
assert "002_a" in errors[0]
|
|
assert "002_b" in errors[0]
|
|
|
|
def test_broken_down_revision(self, versions_dir: Path) -> None:
|
|
"""A migration pointing to a non-existent parent should be flagged."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
_write_migration(versions_dir, "002_orphan.py", "002_orphan", "NONEXISTENT")
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert any("Broken chain" in e for e in errors)
|
|
assert any("NONEXISTENT" in e for e in errors)
|
|
|
|
def test_duplicate_revision(self, versions_dir: Path) -> None:
|
|
"""Two files declaring the same revision should be flagged."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
_write_migration(versions_dir, "002_first.py", "002_dup", "001_base")
|
|
_write_migration(versions_dir, "002_second.py", "002_dup", "001_base")
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert any("Duplicate revision" in e for e in errors)
|
|
|
|
def test_filename_mismatch(self, versions_dir: Path) -> None:
|
|
"""A file whose revision doesn't match its filename should be flagged."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
# filename stem is "002_wrong_name" but revision says "002_correct_name"
|
|
_write_migration(versions_dir, "002_wrong_name.py", "002_correct_name", "001_base")
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert any("Filename mismatch" in e for e in errors)
|
|
|
|
def test_empty_directory(self, versions_dir: Path) -> None:
|
|
"""An empty versions directory should report an error."""
|
|
errors = check_migrations(versions_dir)
|
|
assert len(errors) == 1
|
|
assert "No migration files found" in errors[0]
|
|
|
|
def test_init_py_is_skipped(self, versions_dir: Path) -> None:
|
|
"""__init__.py files should be ignored."""
|
|
(versions_dir / "__init__.py").write_text("")
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert errors == []
|
|
|
|
def test_non_migration_file_skipped(self, versions_dir: Path) -> None:
|
|
"""A .py file without a revision variable should be silently skipped."""
|
|
(versions_dir / "helper.py").write_text("# just a helper\nx = 1\n")
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
|
|
errors = check_migrations(versions_dir)
|
|
assert errors == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMainCLI:
|
|
"""Tests for the CLI entry-point."""
|
|
|
|
def test_success_returns_zero(self, versions_dir: Path) -> None:
|
|
"""Valid chain should exit 0."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
rc = main(["--versions-dir", str(versions_dir)])
|
|
assert rc == 0
|
|
|
|
def test_failure_returns_one(self, versions_dir: Path) -> None:
|
|
"""Invalid chain should exit 1."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
|
|
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
|
|
|
|
rc = main(["--versions-dir", str(versions_dir)])
|
|
assert rc == 1
|
|
|
|
def test_missing_directory_returns_two(self, tmp_path: Path) -> None:
|
|
"""Non-existent versions directory should exit 2."""
|
|
rc = main(["--versions-dir", str(tmp_path / "does_not_exist")])
|
|
assert rc == 2
|
|
|
|
def test_verbose_flag(self, versions_dir: Path) -> None:
|
|
"""The --verbose flag should not crash."""
|
|
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
|
rc = main(["--versions-dir", str(versions_dir), "--verbose"])
|
|
assert rc == 0
|
|
|
|
def test_real_migrations(self) -> None:
|
|
"""Smoke test against the actual project migrations."""
|
|
real_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
|
|
if not real_dir.is_dir():
|
|
pytest.skip("migrations/versions directory not found in working tree")
|
|
rc = main(["--versions-dir", str(real_dir)])
|
|
assert rc == 0
|