"""Unit tests for BinanceSpotAdapter.""" from __future__ import annotations import hashlib import hmac from datetime import UTC from decimal import Decimal from typing import Any from unittest.mock import AsyncMock, Mock from urllib.parse import urlencode import pytest from pydantic import SecretStr from tradefinder.adapters.base import AuthenticationError, ExchangeError from tradefinder.adapters.binance_spot import BinanceSpotAdapter, SpotBalance from tradefinder.core.config import BinanceSettings, Settings, TradingMode def _make_response(status_code: int, payload: Any) -> Mock: """Create a mock HTTP response.""" response = Mock() response.status_code = status_code response.json.return_value = payload return response @pytest.fixture def settings() -> Settings: """Create test settings without loading .env file.""" return Settings( trading_mode=TradingMode.TESTNET, binance=BinanceSettings( testnet_api_key=SecretStr("test-key"), testnet_secret=SecretStr("test-secret"), ), _env_file=None, ) @pytest.fixture def adapter(settings: Settings) -> BinanceSpotAdapter: """Create adapter with test settings.""" return BinanceSpotAdapter(settings) @pytest.fixture def mock_http_client() -> AsyncMock: """Create mock HTTP client.""" client = AsyncMock() client.get = AsyncMock() client.aclose = AsyncMock() return client def _sample_account_response() -> dict[str, Any]: """Sample Binance spot account response.""" return { "makerCommission": 10, "takerCommission": 10, "balances": [ {"asset": "BTC", "free": "0.50000000", "locked": "0.10000000"}, {"asset": "ETH", "free": "5.00000000", "locked": "0.00000000"}, {"asset": "USDT", "free": "1000.00000000", "locked": "500.00000000"}, {"asset": "BNB", "free": "0.00000000", "locked": "0.00000000"}, ], } class TestSpotBalanceDataclass: """Tests for SpotBalance dataclass.""" def test_total_property(self) -> None: """Total equals free plus locked.""" from datetime import datetime balance = SpotBalance( asset="BTC", free=Decimal("1.5"), locked=Decimal("0.5"), updated_at=datetime.now(UTC), ) assert balance.total == Decimal("2.0") def test_zero_balance_total(self) -> None: """Zero balance returns zero total.""" from datetime import datetime balance = SpotBalance( asset="XRP", free=Decimal("0"), locked=Decimal("0"), updated_at=datetime.now(UTC), ) assert balance.total == Decimal("0") class TestBinanceSpotAdapterInit: """Initialization and credential tests.""" def test_credentials_are_accessible(self, adapter: BinanceSpotAdapter) -> None: """API key and secret are exposed through properties.""" assert adapter._api_key == "test-key" assert adapter._secret == "test-secret" def test_base_url_uses_testnet(self, settings: Settings) -> None: """Testnet mode uses testnet URL.""" adapter = BinanceSpotAdapter(settings) assert "testnet" in adapter.base_url.lower() def test_sign_generates_valid_signature(self, adapter: BinanceSpotAdapter) -> None: """HMAC-SHA256 signature matches stdlib implementation.""" params = {"symbol": "BTCUSDT", "timestamp": "1234567890"} expected = hmac.new( adapter._secret.encode("utf-8"), urlencode(params).encode("utf-8"), hashlib.sha256, ).hexdigest() assert adapter._sign(params) == expected class TestBinanceSpotAdapterConnection: """Connection lifecycle tests.""" async def test_connect_creates_client(self, adapter: BinanceSpotAdapter) -> None: """Connect creates HTTP client.""" mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=_make_response(200, _sample_account_response())) mock_client.aclose = AsyncMock() import httpx original_client = httpx.AsyncClient try: # Patch httpx.AsyncClient httpx.AsyncClient = lambda **kwargs: mock_client await adapter.connect() assert adapter._client is mock_client finally: httpx.AsyncClient = original_client await adapter.disconnect() async def test_disconnect_closes_client( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """Disconnect closes HTTP client.""" adapter._client = mock_http_client await adapter.disconnect() mock_http_client.aclose.assert_awaited_once() assert adapter._client is None async def test_disconnect_when_not_connected(self, adapter: BinanceSpotAdapter) -> None: """Disconnect when not connected is safe.""" await adapter.disconnect() # Should not raise class TestBinanceSpotAdapterRequest: """Request handling tests.""" async def test_request_adds_auth_headers( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """Requests include X-MBX-APIKEY header.""" adapter._client = mock_http_client response = _make_response(200, {"ok": True}) mock_http_client.get.return_value = response result = await adapter._request("GET", "/api/v3/test") assert result == {"ok": True} mock_http_client.get.assert_awaited_once() called_headers = mock_http_client.get.call_args[1]["headers"] assert called_headers["X-MBX-APIKEY"] == "test-key" async def test_request_adds_signature_when_signed( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """Signed requests include timestamp and signature.""" adapter._client = mock_http_client response = _make_response(200, {"ok": True}) mock_http_client.get.return_value = response await adapter._request("GET", "/api/v3/test", params={}, signed=True) call_kwargs = mock_http_client.get.call_args[1] params = call_kwargs["params"] assert "timestamp" in params assert "signature" in params assert "recvWindow" in params async def test_request_raises_when_not_connected(self, adapter: BinanceSpotAdapter) -> None: """Request raises when not connected.""" with pytest.raises(ExchangeError, match="Not connected"): await adapter._request("GET", "/api/v3/test") async def test_request_handles_auth_error( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """Authentication errors raise AuthenticationError.""" adapter._client = mock_http_client response = _make_response(401, {"code": -1022, "msg": "Invalid signature"}) mock_http_client.get.return_value = response with pytest.raises(AuthenticationError): await adapter._request("GET", "/api/v3/test") async def test_request_handles_general_error( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """General API errors raise ExchangeError.""" adapter._client = mock_http_client response = _make_response(400, {"code": -1100, "msg": "Illegal characters"}) mock_http_client.get.return_value = response with pytest.raises(ExchangeError, match="Illegal characters"): await adapter._request("GET", "/api/v3/test") class TestBinanceSpotAdapterBalance: """Balance retrieval tests.""" async def test_get_balance_returns_asset( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """get_balance returns balance for specific asset.""" adapter._client = mock_http_client response = _make_response(200, _sample_account_response()) mock_http_client.get.return_value = response balance = await adapter.get_balance("BTC") assert balance.asset == "BTC" assert balance.free == Decimal("0.50000000") assert balance.locked == Decimal("0.10000000") assert balance.total == Decimal("0.60000000") async def test_get_balance_raises_when_not_found( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """get_balance raises when asset not found.""" adapter._client = mock_http_client response = _make_response(200, _sample_account_response()) mock_http_client.get.return_value = response with pytest.raises(ExchangeError, match="not found"): await adapter.get_balance("UNKNOWN") async def test_get_all_balances_returns_non_zero( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """get_all_balances returns only non-zero balances.""" adapter._client = mock_http_client response = _make_response(200, _sample_account_response()) mock_http_client.get.return_value = response balances = await adapter.get_all_balances() # BTC, ETH, USDT have non-zero balances; BNB is zero assets = {b.asset for b in balances} assert "BTC" in assets assert "ETH" in assets assert "USDT" in assets assert "BNB" not in assets async def test_get_all_balances_with_min_balance( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """get_all_balances filters by minimum balance.""" adapter._client = mock_http_client response = _make_response(200, _sample_account_response()) mock_http_client.get.return_value = response # min_balance of 100 should only include USDT (1500 total) balances = await adapter.get_all_balances(min_balance=Decimal("100")) assets = {b.asset for b in balances} assert "USDT" in assets assert "BTC" not in assets # Only 0.6 total assert "ETH" not in assets # Only 5.0 total async def test_get_all_balances_empty_response( self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock ) -> None: """get_all_balances handles empty balances.""" adapter._client = mock_http_client response = _make_response(200, {"balances": []}) mock_http_client.get.return_value = response balances = await adapter.get_all_balances() assert balances == []