diff --git a/tests/conftest.py b/tests/conftest.py index ed0a8ab..bb1b26e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Shared pytest fixtures for all tests.""" +import dotenv import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -7,6 +8,14 @@ from sqlalchemy.orm import sessionmaker from meshcore_hub.common import config as config_module from meshcore_hub.common.models import Base +# The CLI entrypoint (meshcore_hub.__main__) calls load_dotenv() at import time so +# deployments can drop a .env in place. Importing it during collection (e.g. from +# test_main.py) would otherwise leak a developer's repo-root .env straight into +# os.environ for the whole session — bypassing _ignore_dotenv, which only stops +# pydantic-settings from reading the file. conftest.py is imported before any test +# module is collected, so neutralising load_dotenv here binds first. +dotenv.load_dotenv = lambda *args, **kwargs: False + def _settings_classes(): """CommonSettings and every subclass (recursively).""" diff --git a/tests/test_common/test_db_migrate.py b/tests/test_common/test_db_migrate.py index 3ad0a6c..d886679 100644 --- a/tests/test_common/test_db_migrate.py +++ b/tests/test_common/test_db_migrate.py @@ -10,8 +10,10 @@ from datetime import datetime import pytest from sqlalchemy import create_engine, func, select +from meshcore_hub.common import db_migrate from meshcore_hub.common.db_migrate import ( _copy_table, + _is_superuser, _tz_aware_columns, migrate_sqlite_to_postgres, ) @@ -79,3 +81,127 @@ def test_copy_table_roundtrips_rows_and_boolean() -> None: src.dispose() dst.dispose() + + +def test_is_superuser_false_for_non_postgres() -> None: + """session_replication_role is Postgres-only; SQLite is never a superuser target.""" + engine = create_engine("sqlite:///:memory:") + try: + assert _is_superuser(engine) is False + finally: + engine.dispose() + + +def _seed_nodes(engine, count: int) -> None: + nodes = Base.metadata.tables["nodes"] + now = datetime(2026, 6, 13, 10, 0) + with engine.begin() as conn: + conn.execute( + nodes.insert(), + [ + { + "id": f"n{i}", + "public_key": f"key{i}", + "is_observer": i == 0, + "first_seen": now, + "created_at": now, + "updated_at": now, + } + for i in range(count) + ], + ) + + +@pytest.fixture +def _patch_engines(monkeypatch, tmp_path): + """Route create_database_engine to SQLite files keyed by URL. + + Lets the Postgres-targeting migration flow run end-to-end SQLite -> SQLite in CI: + a ``postgresql://`` target URL satisfies the guard while the real engine is a + local SQLite file, so the schema/empty/truncate/copy logic is exercised without + a live Postgres. + """ + src_url = f"sqlite:///{tmp_path / 'src.db'}" + target_url = "postgresql://fake/target" # satisfies the Postgres guard + src_engine = create_engine(src_url) + tgt_engine = create_engine(f"sqlite:///{tmp_path / 'tgt.db'}") + Base.metadata.create_all(src_engine) + + def fake_create_engine(url, echo=False, schema=None): + if url == src_url: + return src_engine + if url == target_url: + return tgt_engine + raise AssertionError(f"unexpected url {url!r}") + + monkeypatch.setattr(db_migrate, "create_database_engine", fake_create_engine) + return src_url, target_url, src_engine, tgt_engine + + +def _count_rows(engine) -> int: + nodes = Base.metadata.tables["nodes"] + with engine.connect() as conn: + return int(conn.execute(select(func.count()).select_from(nodes)).scalar() or 0) + + +def test_migrate_dry_run_reports_counts_without_writing(_patch_engines) -> None: + """Dry run reports source/target counts and leaves the target untouched.""" + src_url, target_url, src_engine, tgt_engine = _patch_engines + Base.metadata.create_all(tgt_engine) + _seed_nodes(src_engine, 3) + + result = migrate_sqlite_to_postgres(src_url, target_url, dry_run=True) + + assert result.dry_run is True + nodes_result = next(t for t in result.tables if t.name == "nodes") + assert nodes_result.source_rows == 3 + assert nodes_result.target_rows == 0 + assert _count_rows(tgt_engine) == 0 # nothing written + + +def test_migrate_copies_all_rows(_patch_engines) -> None: + """A full run copies rows and reconciles source/target counts as OK.""" + src_url, target_url, src_engine, tgt_engine = _patch_engines + Base.metadata.create_all(tgt_engine) + _seed_nodes(src_engine, 5) + + result = migrate_sqlite_to_postgres(src_url, target_url, batch_size=2) + + assert result.ok is True + assert _count_rows(tgt_engine) == 5 + nodes_result = next(t for t in result.tables if t.name == "nodes") + assert nodes_result.source_rows == nodes_result.target_rows == 5 + + +def test_migrate_refuses_non_empty_target(_patch_engines) -> None: + """Without --truncate, a non-empty target is refused before any write.""" + src_url, target_url, src_engine, tgt_engine = _patch_engines + Base.metadata.create_all(tgt_engine) + _seed_nodes(src_engine, 2) + _seed_nodes(tgt_engine, 1) + + with pytest.raises(RuntimeError, match="not empty"): + migrate_sqlite_to_postgres(src_url, target_url) + + +def test_migrate_truncate_overwrites_target(_patch_engines) -> None: + """--truncate clears existing target rows before loading from source.""" + src_url, target_url, src_engine, tgt_engine = _patch_engines + Base.metadata.create_all(tgt_engine) + _seed_nodes(src_engine, 2) + _seed_nodes(tgt_engine, 4) + + result = migrate_sqlite_to_postgres(src_url, target_url, truncate=True) + + assert result.ok is True + assert _count_rows(tgt_engine) == 2 + + +def test_migrate_errors_when_target_schema_missing(_patch_engines) -> None: + """A target without the schema (no create_all) fails with a clear message.""" + src_url, target_url, src_engine, tgt_engine = _patch_engines + # Note: target schema intentionally not created. + _seed_nodes(src_engine, 1) + + with pytest.raises(RuntimeError, match="Run 'meshcore-hub db upgrade'"): + migrate_sqlite_to_postgres(src_url, target_url) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..8a1618e --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,127 @@ +"""Tests for the top-level CLI, focused on ``db migrate-to-postgres``. + +The migration engine itself is covered in test_common/test_db_migrate.py; here we +verify the command's wiring: option plumbing, dry-run vs. real output, and how the +MigrationResult (or an error) maps onto exit codes and messages. +""" + +from unittest.mock import patch + +from click.testing import CliRunner + +from meshcore_hub.__main__ import cli +from meshcore_hub.common.db_migrate import MigrationResult, TableResult + + +def _result(*tables: TableResult, dry_run: bool = False) -> MigrationResult: + return MigrationResult(tables=list(tables), dry_run=dry_run) + + +def test_migrate_to_postgres_success() -> None: + """A matching run reports OK per table and exits 0.""" + runner = CliRunner() + fake = _result(TableResult("nodes", 5, 5)) + + with patch( + "meshcore_hub.common.db_migrate.migrate_sqlite_to_postgres", + return_value=fake, + ) as mock_migrate: + result = runner.invoke( + cli, + [ + "db", + "migrate-to-postgres", + "--source", + "sqlite:///src.db", + "--target", + "postgresql://u@h/db", + ], + ) + + assert result.exit_code == 0 + assert "nodes" in result.output + assert "OK" in result.output + assert "Migration complete." in result.output + # Flags thread through to the engine call. + _, kwargs = mock_migrate.call_args + assert kwargs["dry_run"] is False + assert kwargs["truncate"] is False + + +def test_migrate_to_postgres_dry_run() -> None: + """Dry run prints a preview and never renders OK/MISMATCH judgements.""" + runner = CliRunner() + fake = _result(TableResult("nodes", 3, 0), dry_run=True) + + with patch( + "meshcore_hub.common.db_migrate.migrate_sqlite_to_postgres", + return_value=fake, + ) as mock_migrate: + result = runner.invoke( + cli, + [ + "db", + "migrate-to-postgres", + "--source", + "sqlite:///src.db", + "--target", + "postgresql://u@h/db", + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "dry-run" in result.output + assert "Dry run complete." in result.output + assert "OK" not in result.output + assert mock_migrate.call_args.kwargs["dry_run"] is True + + +def test_migrate_to_postgres_mismatch_exits_nonzero() -> None: + """A row-count mismatch surfaces as a ClickException (non-zero exit).""" + runner = CliRunner() + fake = _result(TableResult("nodes", 5, 4)) # ok == False + + with patch( + "meshcore_hub.common.db_migrate.migrate_sqlite_to_postgres", + return_value=fake, + ): + result = runner.invoke( + cli, + [ + "db", + "migrate-to-postgres", + "--source", + "sqlite:///src.db", + "--target", + "postgresql://u@h/db", + ], + ) + + assert result.exit_code != 0 + assert "MISMATCH" in result.output + assert "mismatch" in result.output.lower() + + +def test_migrate_to_postgres_value_error_becomes_click_exception() -> None: + """A ValueError from the engine (e.g. bad target) maps to a clean CLI error.""" + runner = CliRunner() + + with patch( + "meshcore_hub.common.db_migrate.migrate_sqlite_to_postgres", + side_effect=ValueError("Target must be a PostgreSQL database URL"), + ): + result = runner.invoke( + cli, + [ + "db", + "migrate-to-postgres", + "--source", + "sqlite:///src.db", + "--target", + "sqlite:///bad.db", + ], + ) + + assert result.exit_code != 0 + assert "PostgreSQL" in result.output