"""CLI tests for CoinHunter V2.""" from __future__ import annotations import io import unittest from unittest.mock import patch from coinhunter import cli class CLITestCase(unittest.TestCase): def test_help_includes_v2_commands(self): parser = cli.build_parser() help_text = parser.format_help() self.assertIn("init", help_text) self.assertIn("account", help_text) self.assertIn("buy", help_text) self.assertIn("sell", help_text) self.assertIn("portfolio", help_text) self.assertIn("opportunity", help_text) self.assertIn("--doc", help_text) def test_init_dispatches(self): captured = {} with ( patch.object(cli, "ensure_init_files", return_value={"force": True, "root": "/tmp/ch"}), patch.object( cli, "install_shell_completion", return_value={"shell": "zsh", "installed": True, "path": "/tmp/ch/_coinhunter"}, ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ), ): result = cli.main(["init", "--force"]) self.assertEqual(result, 0) self.assertTrue(captured["payload"]["force"]) self.assertIn("completion", captured["payload"]) def test_old_command_is_rejected(self): with self.assertRaises(SystemExit): cli.main(["exec", "bal"]) def test_runtime_error_is_rendered_cleanly(self): stderr = io.StringIO() with patch.object(cli, "load_config", side_effect=RuntimeError("boom")), patch("sys.stderr", stderr): result = cli.main(["market", "tickers", "BTCUSDT"]) self.assertEqual(result, 1) self.assertIn("error: boom", stderr.getvalue()) def test_buy_dispatches(self): captured = {} with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object( cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"} ), patch.object( cli, "SpotBinanceClient" ), patch.object( cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}} ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ): result = cli.main(["buy", "BTCUSDT", "-Q", "100"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN") def test_sell_dispatches(self): captured = {} with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object( cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"} ), patch.object( cli, "SpotBinanceClient" ), patch.object( cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}} ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ): result = cli.main(["sell", "BTCUSDT", "-q", "0.01"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN") def test_doc_flag_prints_tui_documentation(self): stdout = io.StringIO() with patch("sys.stdout", stdout): result = cli.main(["market", "tickers", "--doc"]) self.assertEqual(result, 0) output = stdout.getvalue() self.assertIn("TUI Output", output) self.assertIn("Last Price", output) self.assertIn("BTCUSDT", output) def test_doc_flag_prints_json_documentation(self): stdout = io.StringIO() with patch("sys.stdout", stdout): result = cli.main(["market", "tickers", "--doc", "--agent"]) self.assertEqual(result, 0) output = stdout.getvalue() self.assertIn("JSON Output", output) self.assertIn("last_price", output) self.assertIn("BTCUSDT", output) def test_account_dispatches(self): captured = {} with ( patch.object( cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "trading": {"dust_usdt_threshold": 10.0}} ), patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}), patch.object(cli, "SpotBinanceClient"), patch.object( cli.account_service, "get_balances", return_value={"balances": [{"asset": "BTC", "is_dust": False}]} ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ), ): result = cli.main(["account"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["balances"][0]["asset"], "BTC") def test_upgrade_dispatches(self): captured = {} with ( patch.object(cli, "self_upgrade", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ), ): result = cli.main(["upgrade"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["returncode"], 0) def test_portfolio_dispatches(self): captured = {} with ( patch.object( cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}} ), patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}), patch.object(cli, "SpotBinanceClient"), patch.object( cli.opportunity_service, "analyze_portfolio", return_value={"scores": [{"asset": "BTC", "score": 0.75}]} ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ), ): result = cli.main(["portfolio"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["scores"][0]["asset"], "BTC") def test_opportunity_dispatches(self): captured = {} with ( patch.object( cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}} ), patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}), patch.object(cli, "SpotBinanceClient"), patch.object( cli.opportunity_service, "scan_opportunities", return_value={"opportunities": [{"symbol": "BTCUSDT", "score": 0.82}]}, ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ), ): result = cli.main(["opportunity", "-s", "BTCUSDT", "ETHUSDT"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["opportunities"][0]["symbol"], "BTCUSDT") def test_catlog_dispatches(self): captured = {} with ( patch.object( cli, "read_audit_log", return_value=[{"timestamp": "2026-04-17T12:00:00Z", "event": "test_event"}] ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ), ): result = cli.main(["catlog", "-n", "5", "-o", "10"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["limit"], 5) self.assertEqual(captured["payload"]["offset"], 10) self.assertIn("entries", captured["payload"]) self.assertEqual(captured["payload"]["total"], 1) def test_config_get_dispatches(self): captured = {} with ( patch.object(cli, "load_config", return_value={"binance": {"recv_window": 5000}}), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ), ): result = cli.main(["config", "get", "binance.recv_window"]) self.assertEqual(result, 0) self.assertEqual(captured["payload"]["binance.recv_window"], 5000) def test_config_set_dispatches(self): import tempfile with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write('[binance]\nrecv_window = 5000\n') tmp_path = f.name with patch.object(cli, "get_runtime_paths") as mock_paths: mock_paths.return_value.config_file = __import__("pathlib").Path(tmp_path) result = cli.main(["config", "set", "binance.recv_window", "10000"]) self.assertEqual(result, 0) # Verify the file was updated content = __import__("pathlib").Path(tmp_path).read_text() self.assertIn("recv_window = 10000", content) __import__("os").unlink(tmp_path) def test_config_key_dispatches(self): import tempfile with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: f.write("BINANCE_API_KEY=\n") tmp_path = f.name with patch.object(cli, "get_runtime_paths") as mock_paths: mock_paths.return_value.env_file = __import__("pathlib").Path(tmp_path) result = cli.main(["config", "key", "test_key_value"]) self.assertEqual(result, 0) content = __import__("pathlib").Path(tmp_path).read_text() self.assertIn("BINANCE_API_KEY=test_key_value", content) __import__("os").unlink(tmp_path) def test_config_secret_dispatches(self): import tempfile with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: f.write("BINANCE_API_SECRET=\n") tmp_path = f.name with patch.object(cli, "get_runtime_paths") as mock_paths: mock_paths.return_value.env_file = __import__("pathlib").Path(tmp_path) result = cli.main(["config", "secret", "test_secret_value"]) self.assertEqual(result, 0) content = __import__("pathlib").Path(tmp_path).read_text() self.assertIn("BINANCE_API_SECRET=test_secret_value", content) __import__("os").unlink(tmp_path)