mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 19:03:03 +02:00
test: cover Postgres migration helper and CLI command
Raise patch coverage on the v0.14.0 Postgres backend: - test_db_migrate: _is_superuser plus the full migrate_sqlite_to_postgres flow (dry-run, copy, non-empty-target refusal, --truncate, missing schema), routing create_database_engine to SQLite files so a postgresql:// target satisfies the guard while the run executes SQLite -> SQLite in CI. - test_main: the `db migrate-to-postgres` CLI command via CliRunner (success, dry-run, row-count mismatch, ValueError -> ClickException). - conftest: neutralise dotenv.load_dotenv before collection. Importing the CLI entrypoint runs load_dotenv() at import time, which leaked a local .env into os.environ and broke unrelated config/redis tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user