""" Tests for the `decnet db-reset` CLI command. No live MySQL required — the async worker is mocked. """ from unittest.mock import AsyncMock, patch import pytest from typer.testing import CliRunner from decnet.cli import app, _db_reset_mysql_async runner = CliRunner() # ── Guard-rails ─────────────────────────────────────────────────────────────── class TestDbResetGuards: def test_refuses_when_backend_is_sqlite(self, monkeypatch): monkeypatch.setenv("DECNET_DB_TYPE", "sqlite") result = runner.invoke(app, ["db-reset", "--i-know-what-im-doing"]) assert result.exit_code == 2 assert "MySQL-only" in result.stdout def test_refuses_invalid_mode(self, monkeypatch): monkeypatch.setenv("DECNET_DB_TYPE", "mysql") result = runner.invoke(app, ["db-reset", "--mode", "nuke"]) assert result.exit_code == 2 assert "Invalid --mode" in result.stdout def test_reports_missing_connection_info(self, monkeypatch): """With no URL and no component env vars, build_mysql_url raises — surface it.""" monkeypatch.setenv("DECNET_DB_TYPE", "mysql") for v in ("DECNET_DB_URL", "DECNET_DB_PASSWORD"): monkeypatch.delenv(v, raising=False) # Strip pytest env so build_mysql_url's safety check trips (needs a # password when we're "not in tests" per its own heuristic). import os for k in list(os.environ): if k.startswith("PYTEST"): monkeypatch.delenv(k, raising=False) result = runner.invoke(app, ["db-reset"]) assert result.exit_code == 2 assert "DECNET_DB_PASSWORD" in result.stdout # ── Dry-run vs. confirmed execution ─────────────────────────────────────────── class TestDbResetDispatch: def test_dry_run_skips_destructive_phase(self, monkeypatch): """Without the flag, the command must still call into the worker (to show row counts) but signal confirm=False.""" monkeypatch.setenv("DECNET_DB_TYPE", "mysql") monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://u:p@h/d") mock = AsyncMock() with patch("decnet.cli.db._db_reset_mysql_async", new=mock): result = runner.invoke(app, ["db-reset"]) assert result.exit_code == 0, result.stdout mock.assert_awaited_once() kwargs = mock.await_args.kwargs assert kwargs["confirm"] is False assert kwargs["mode"] == "truncate" def test_confirmed_execution_passes_confirm_true(self, monkeypatch): monkeypatch.setenv("DECNET_DB_TYPE", "mysql") monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://u:p@h/d") mock = AsyncMock() with patch("decnet.cli.db._db_reset_mysql_async", new=mock): result = runner.invoke(app, ["db-reset", "--i-know-what-im-doing"]) assert result.exit_code == 0, result.stdout assert mock.await_args.kwargs["confirm"] is True def test_drop_tables_mode_propagates(self, monkeypatch): monkeypatch.setenv("DECNET_DB_TYPE", "mysql") monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://u:p@h/d") mock = AsyncMock() with patch("decnet.cli.db._db_reset_mysql_async", new=mock): result = runner.invoke( app, ["db-reset", "--mode", "drop-tables", "--i-know-what-im-doing"] ) assert result.exit_code == 0, result.stdout assert mock.await_args.kwargs["mode"] == "drop-tables" def test_explicit_url_overrides_env(self, monkeypatch): monkeypatch.setenv("DECNET_DB_TYPE", "mysql") monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://from-env/db") mock = AsyncMock() with patch("decnet.cli.db._db_reset_mysql_async", new=mock): result = runner.invoke(app, [ "db-reset", "--url", "mysql+aiomysql://override/db2", ]) assert result.exit_code == 0, result.stdout # First positional arg to the async worker is the DSN. assert mock.await_args.args[0] == "mysql+aiomysql://override/db2" # ── Destructive-phase skip when flag is absent ─────────────────────────────── class TestDbResetWorker: @pytest.mark.anyio async def test_dry_run_does_not_open_begin_transaction(self): """Confirm=False must stop after the row-count inspection — no DDL/DML.""" from unittest.mock import MagicMock mock_conn = AsyncMock() # Every table shows as "missing" so row-count loop exits cleanly. mock_conn.execute.side_effect = Exception("no such table") mock_connect_cm = AsyncMock() mock_connect_cm.__aenter__.return_value = mock_conn mock_connect_cm.__aexit__.return_value = False mock_engine = MagicMock() mock_engine.connect.return_value = mock_connect_cm mock_engine.begin = MagicMock() # must NOT be awaited in dry-run mock_engine.dispose = AsyncMock() with patch("sqlalchemy.ext.asyncio.create_async_engine", return_value=mock_engine): await _db_reset_mysql_async( "mysql+aiomysql://u:p@h/d", mode="truncate", confirm=False ) mock_engine.begin.assert_not_called() mock_engine.dispose.assert_awaited_once()