Files
coinhunter-cli/tests/test_cli.py
Tacit Lab e37993c8b5 feat: flatten opportunity commands, add config management, fix completions
- Flatten opportunity into top-level portfolio and opportunity commands
- Add interactive config get/set/key/secret with type coercion
- Rewrite --doc to show TUI vs JSON schema per command
- Unify agent mode output to JSON only
- Make init prompt for API key/secret interactively
- Fix coin tab completion alias binding
- Fix set_config_value reading from wrong path
- Fail loudly on invalid numeric config values

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 08:43:30 +08:00

251 lines
11 KiB
Python

"""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)