Files
meshcore-hub/tests/test_common/test_db_migrate.py
T
Louis King 34b6e6b328 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>
2026-06-14 21:11:02 +01:00

208 lines
7.3 KiB
Python

"""Tests for the SQLite -> Postgres data migration helper.
The full round-trip is validated against a live Postgres; here we cover the
dialect-agnostic pieces (the Postgres-target guard, tz-aware column detection, and
the Core copy/stream/normalize logic exercised SQLite -> SQLite) so they run in CI.
"""
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,
)
from meshcore_hub.common.models import Base
# Import models so their tables register on Base.metadata.
import meshcore_hub.common.models.node # noqa: F401
import meshcore_hub.common.models.raw_packet # noqa: F401
def test_target_must_be_postgres() -> None:
"""The command refuses a non-Postgres target."""
with pytest.raises(ValueError, match="PostgreSQL"):
migrate_sqlite_to_postgres("sqlite:///a.db", "sqlite:///b.db")
def test_tz_aware_columns_detects_timestamptz() -> None:
"""Timezone-aware DateTime columns are identified for UTC normalization."""
cols = _tz_aware_columns(Base.metadata.tables["raw_packets"])
assert "received_at" in cols
assert "created_at" in cols
assert "packet_hash" not in cols # not a datetime
def test_copy_table_roundtrips_rows_and_boolean() -> None:
"""_copy_table streams rows across engines, preserving the boolean value.
(UTC normalization of naive datetimes is validated against Postgres, where
timestamptz round-trips reliably; SQLite does not retain tzinfo.)
"""
nodes = Base.metadata.tables["nodes"]
src = create_engine("sqlite:///:memory:")
dst = create_engine("sqlite:///:memory:")
Base.metadata.create_all(src)
Base.metadata.create_all(dst)
now = datetime(2026, 6, 13, 10, 0)
with src.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(3)
],
)
with dst.begin() as conn:
copied = _copy_table(src, conn, nodes, batch_size=2) # forces >1 batch
assert copied == 3
with dst.connect() as conn:
assert conn.execute(select(func.count()).select_from(nodes)).scalar() == 3
observers = conn.execute(
select(func.count()).select_from(nodes).where(nodes.c.is_observer.is_(True))
).scalar()
assert observers == 1 # boolean preserved across the copy
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)