""" Unit tests for Coinbase exchange connector. """ import asyncio import pytest from unittest.mock import Mock, AsyncMock, patch from datetime import datetime, timezone from ..connectors.coinbase_connector import CoinbaseConnector from ..models.core import OrderBookSnapshot, TradeEvent, PriceLevel class TestCoinbaseConnector: """Test suite for Coinbase connector.""" @pytest.fixture def connector(self): """Create connector instance for testing.""" return CoinbaseConnector(use_sandbox=True) def test_initialization(self, connector): """Test connector initializes correctly.""" assert connector.exchange_name == "coinbase" assert connector.use_sandbox is True assert connector.SANDBOX_URL in connector.websocket_url assert 'l2update' in connector.message_handlers assert 'match' in connector.message_handlers def test_symbol_normalization(self, connector): """Test symbol normalization to Coinbase format.""" # Test standard conversions assert connector.normalize_symbol('BTCUSDT') == 'BTC-USD' assert connector.normalize_symbol('ETHUSDT') == 'ETH-USD' assert connector.normalize_symbol('ADAUSDT') == 'ADA-USD' # Test generic conversion assert connector.normalize_symbol('LINKUSDT') == 'LINK-USD' # Test already correct format assert connector.normalize_symbol('BTC-USD') == 'BTC-USD' def test_symbol_denormalization(self, connector): """Test converting Coinbase format back to standard.""" assert connector._denormalize_symbol('BTC-USD') == 'BTCUSDT' assert connector._denormalize_symbol('ETH-USD') == 'ETHUSDT' assert connector._denormalize_symbol('ADA-USD') == 'ADAUSDT' # Test other quote currencies assert connector._denormalize_symbol('BTC-EUR') == 'BTCEUR' def test_message_type_detection(self, connector): """Test message type detection.""" # Test l2update message l2_message = {'type': 'l2update', 'product_id': 'BTC-USD'} assert connector._get_message_type(l2_message) == 'l2update' # Test match message match_message = {'type': 'match', 'product_id': 'BTC-USD'} assert connector._get_message_type(match_message) == 'match' # Test error message error_message = {'type': 'error', 'message': 'Invalid signature'} assert connector._get_message_type(error_message) == 'error' # Test unknown message unknown_message = {'data': 'something'} assert connector._get_message_type(unknown_message) == 'unknown' @pytest.mark.asyncio async def test_subscription_methods(self, connector): """Test subscription and unsubscription methods.""" # Mock the _send_message method connector._send_message = AsyncMock(return_value=True) # Test order book subscription await connector.subscribe_orderbook('BTCUSDT') # Verify subscription was tracked assert 'BTCUSDT' in connector.subscriptions assert 'orderbook' in connector.subscriptions['BTCUSDT'] assert 'level2' in connector.subscribed_channels assert 'BTC-USD' in connector.product_ids # Verify correct message was sent connector._send_message.assert_called() call_args = connector._send_message.call_args[0][0] assert call_args['type'] == 'subscribe' assert 'BTC-USD' in call_args['product_ids'] assert 'level2' in call_args['channels'] # Test trade subscription await connector.subscribe_trades('ETHUSDT') assert 'ETHUSDT' in connector.subscriptions assert 'trades' in connector.subscriptions['ETHUSDT'] assert 'matches' in connector.subscribed_channels assert 'ETH-USD' in connector.product_ids # Test unsubscription await connector.unsubscribe_orderbook('BTCUSDT') # Verify unsubscription if 'BTCUSDT' in connector.subscriptions: assert 'orderbook' not in connector.subscriptions['BTCUSDT'] @pytest.mark.asyncio async def test_orderbook_snapshot_parsing(self, connector): """Test parsing order book snapshot data.""" # Mock order book data from Coinbase mock_data = { 'sequence': 12345, 'bids': [ ['50000.00', '1.5', 1], ['49999.00', '2.0', 2] ], 'asks': [ ['50001.00', '1.2', 1], ['50002.00', '0.8', 1] ] } # Parse the data orderbook = connector._parse_orderbook_snapshot(mock_data, 'BTCUSDT') # Verify parsing assert isinstance(orderbook, OrderBookSnapshot) assert orderbook.symbol == 'BTCUSDT' assert orderbook.exchange == 'coinbase' assert orderbook.sequence_id == 12345 # Verify bids assert len(orderbook.bids) == 2 assert orderbook.bids[0].price == 50000.00 assert orderbook.bids[0].size == 1.5 assert orderbook.bids[1].price == 49999.00 assert orderbook.bids[1].size == 2.0 # Verify asks assert len(orderbook.asks) == 2 assert orderbook.asks[0].price == 50001.00 assert orderbook.asks[0].size == 1.2 assert orderbook.asks[1].price == 50002.00 assert orderbook.asks[1].size == 0.8 @pytest.mark.asyncio async def test_orderbook_update_handling(self, connector): """Test handling order book l2update messages.""" # Mock callback callback_called = False received_data = None def mock_callback(data): nonlocal callback_called, received_data callback_called = True received_data = data connector.add_data_callback(mock_callback) # Mock l2update message update_message = { 'type': 'l2update', 'product_id': 'BTC-USD', 'time': '2023-01-01T12:00:00.000000Z', 'changes': [ ['buy', '50000.00', '1.5'], ['sell', '50001.00', '1.2'] ] } # Handle the message await connector._handle_orderbook_update(update_message) # Verify callback was called assert callback_called assert isinstance(received_data, OrderBookSnapshot) assert received_data.symbol == 'BTCUSDT' assert received_data.exchange == 'coinbase' # Verify bids and asks assert len(received_data.bids) == 1 assert received_data.bids[0].price == 50000.00 assert received_data.bids[0].size == 1.5 assert len(received_data.asks) == 1 assert received_data.asks[0].price == 50001.00 assert received_data.asks[0].size == 1.2 @pytest.mark.asyncio async def test_trade_handling(self, connector): """Test handling trade (match) messages.""" # Mock callback callback_called = False received_data = None def mock_callback(data): nonlocal callback_called, received_data callback_called = True received_data = data connector.add_data_callback(mock_callback) # Mock match message trade_message = { 'type': 'match', 'product_id': 'BTC-USD', 'time': '2023-01-01T12:00:00.000000Z', 'price': '50000.50', 'size': '0.1', 'side': 'buy', 'trade_id': 12345 } # Handle the message await connector._handle_trade_update(trade_message) # Verify callback was called assert callback_called assert isinstance(received_data, TradeEvent) assert received_data.symbol == 'BTCUSDT' assert received_data.exchange == 'coinbase' assert received_data.price == 50000.50 assert received_data.size == 0.1 assert received_data.side == 'buy' assert received_data.trade_id == '12345' @pytest.mark.asyncio async def test_error_handling(self, connector): """Test error message handling.""" # Test error message error_message = { 'type': 'error', 'message': 'Invalid signature', 'reason': 'Authentication failed' } # Should not raise exception await connector._handle_error_message(error_message) @pytest.mark.asyncio async def test_get_symbols(self, connector): """Test getting available symbols.""" # Mock HTTP response mock_products = [ { 'id': 'BTC-USD', 'status': 'online', 'trading_disabled': False }, { 'id': 'ETH-USD', 'status': 'online', 'trading_disabled': False }, { 'id': 'DISABLED-USD', 'status': 'offline', 'trading_disabled': True } ] with patch('aiohttp.ClientSession.get') as mock_get: mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value=mock_products) mock_get.return_value.__aenter__.return_value = mock_response symbols = await connector.get_symbols() # Should only return online, enabled symbols assert 'BTCUSDT' in symbols assert 'ETHUSDT' in symbols assert 'DISABLEDUSDT' not in symbols @pytest.mark.asyncio async def test_get_orderbook_snapshot(self, connector): """Test getting order book snapshot from REST API.""" # Mock HTTP response mock_orderbook = { 'sequence': 12345, 'bids': [['50000.00', '1.5']], 'asks': [['50001.00', '1.2']] } with patch('aiohttp.ClientSession.get') as mock_get: mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value=mock_orderbook) mock_get.return_value.__aenter__.return_value = mock_response orderbook = await connector.get_orderbook_snapshot('BTCUSDT') assert isinstance(orderbook, OrderBookSnapshot) assert orderbook.symbol == 'BTCUSDT' assert orderbook.exchange == 'coinbase' assert len(orderbook.bids) == 1 assert len(orderbook.asks) == 1 def test_authentication_headers(self, connector): """Test authentication header generation.""" # Set up credentials connector.api_key = 'test_key' connector.api_secret = 'dGVzdF9zZWNyZXQ=' # base64 encoded 'test_secret' connector.passphrase = 'test_passphrase' # Test message test_message = {'type': 'subscribe', 'channels': ['level2']} # Generate headers headers = connector._get_auth_headers(test_message) # Verify headers are present assert 'CB-ACCESS-KEY' in headers assert 'CB-ACCESS-SIGN' in headers assert 'CB-ACCESS-TIMESTAMP' in headers assert 'CB-ACCESS-PASSPHRASE' in headers assert headers['CB-ACCESS-KEY'] == 'test_key' assert headers['CB-ACCESS-PASSPHRASE'] == 'test_passphrase' def test_statistics(self, connector): """Test getting connector statistics.""" # Add some test data connector.subscribed_channels.add('level2') connector.product_ids.add('BTC-USD') stats = connector.get_coinbase_stats() assert stats['exchange'] == 'coinbase' assert 'level2' in stats['subscribed_channels'] assert 'BTC-USD' in stats['product_ids'] assert stats['use_sandbox'] is True assert 'authenticated' in stats async def test_coinbase_integration(): """Integration test for Coinbase connector.""" connector = CoinbaseConnector(use_sandbox=True) try: # Test basic functionality assert connector.exchange_name == "coinbase" # Test symbol normalization assert connector.normalize_symbol('BTCUSDT') == 'BTC-USD' assert connector._denormalize_symbol('BTC-USD') == 'BTCUSDT' # Test message type detection test_message = {'type': 'l2update', 'product_id': 'BTC-USD'} assert connector._get_message_type(test_message) == 'l2update' print("✓ Coinbase connector integration test passed") return True except Exception as e: print(f"✗ Coinbase connector integration test failed: {e}") return False if __name__ == "__main__": # Run integration test success = asyncio.run(test_coinbase_integration()) if not success: exit(1)