"""Integration tests for the full candle-serving flow.""" import pytest import tempfile import os from unittest.mock import AsyncMock, MagicMock, patch from exchange_data_manager.cache.manager import CacheManager from exchange_data_manager.candles.models import Candle, CandleRequest, RequestMode from exchange_data_manager.config import CacheConfig, DatabaseConfig from exchange_data_manager.sessions import SessionManager, Session, ExchangeCredentials def make_candle(time: int, close: float = 50000.0) -> Candle: """Helper to create candles.""" return Candle( time=time, open=close - 50, high=close + 100, low=close - 100, close=close, volume=10.0, ) class MockConnector: """Mock exchange connector for integration tests.""" def __init__(self): self.fetch_candles = AsyncMock(return_value=[]) self.close = AsyncMock() def get_timeframes(self): return ["1m", "5m", "1h", "1d"] async def get_symbols(self): return ["BTC/USDT", "ETH/USDT"] class TestCacheManagerIntegration: """Integration tests for CacheManager with all components.""" @pytest.fixture def cache_manager(self): """Create a cache manager with temp database.""" with tempfile.TemporaryDirectory() as tmpdir: db_path = os.path.join(tmpdir, "test.db") cache_config = CacheConfig( fill_gaps=True, forward_fill_volume=True, time_tolerance_seconds=5, count_tolerance=1, ) db_config = DatabaseConfig(path=db_path) manager = CacheManager( cache_config=cache_config, database_config=db_config, use_async_db=False, # Sync for simpler testing ) yield manager @pytest.mark.asyncio async def test_cold_cache_fetches_from_exchange(self, cache_manager): """Test that cold cache fetches from exchange.""" mock_connector = MockConnector() mock_connector.fetch_candles.return_value = [ make_candle(1709337600), make_candle(1709337660), make_candle(1709337720), ] cache_manager.register_exchange("binance", mock_connector) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", limit=100, ) result = await cache_manager.get_candles(request) assert len(result) == 3 mock_connector.fetch_candles.assert_called_once() @pytest.mark.asyncio @patch("exchange_data_manager.cache.memory.time.time") async def test_warm_cache_uses_memory(self, mock_time, cache_manager): """Test that warm cache uses memory instead of exchange when limit is satisfied.""" # Mock time to be close to test candle timestamps (prevents freshness check) mock_time.return_value = 1709337720 # Just after the second candle mock_connector = MockConnector() mock_connector.fetch_candles.return_value = [ make_candle(1709337600), make_candle(1709337660), ] cache_manager.register_exchange("binance", mock_connector) # Request exactly 2 candles - matches what memory will have after first call # (If we requested more, the cache manager would refetch since memory has fewer) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", limit=2, ) # First call - cold cache await cache_manager.get_candles(request) assert mock_connector.fetch_candles.call_count == 1 # Second call - should use memory (has 2 candles, requested 2) result = await cache_manager.get_candles(request) assert len(result) == 2 assert mock_connector.fetch_candles.call_count == 1 # No additional call @pytest.mark.asyncio async def test_gap_filling_applied(self, cache_manager): """Test that gap filling is applied to results.""" mock_connector = MockConnector() # Data with a gap mock_connector.fetch_candles.return_value = [ make_candle(1709337600), # 0:00 # Gap: 0:01 make_candle(1709337720), # 0:02 ] cache_manager.register_exchange("binance", mock_connector) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", limit=100, ) result = await cache_manager.get_candles(request) # Should have 3 candles after gap filling assert len(result) == 3 assert result[1].time == 1709337660 # Filled gap @pytest.mark.asyncio async def test_range_mode_request(self, cache_manager): """Test RANGE mode request with start and end.""" mock_connector = MockConnector() mock_connector.fetch_candles.return_value = [ make_candle(1709337600), make_candle(1709337660), make_candle(1709337720), ] cache_manager.register_exchange("binance", mock_connector) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", start=1709337600, end=1709337720, ) assert request.mode == RequestMode.RANGE result = await cache_manager.get_candles(request) assert len(result) == 3 @pytest.mark.asyncio async def test_stats_includes_all_components(self, cache_manager): """Test that stats includes memory, database, and exchanges.""" mock_connector = MockConnector() cache_manager.register_exchange("binance", mock_connector) stats = await cache_manager.stats() assert "memory" in stats assert "database" in stats assert "registered_exchanges" in stats assert "binance" in stats["registered_exchanges"] class TestSessionLifecycleIntegration: """Integration tests for session lifecycle.""" @pytest.fixture def session_manager(self): """Create a session manager.""" return SessionManager( session_timeout_minutes=60, cleanup_interval_seconds=300, ) @pytest.mark.asyncio async def test_full_session_lifecycle(self, session_manager): """Test create → add creds → use → destroy → creds gone.""" # Create session session = session_manager.create_session() assert session is not None assert session.id in session_manager._sessions # Add credentials success = await session_manager.add_exchange_credentials( session_id=session.id, exchange="binance", api_key="test_key", api_secret="test_secret", ) assert success is True assert session.has_credentials("binance") # Refresh session success = session_manager.refresh_session(session.id) assert success is True # Destroy session destroyed = await session_manager.destroy_session(session.id) assert destroyed is True # Verify session is gone assert session.id not in session_manager._sessions assert len(session.credentials) == 0 @pytest.mark.asyncio async def test_session_cleanup_on_stop(self, session_manager): """Test that all sessions are cleaned up on stop.""" session1 = session_manager.create_session() session2 = session_manager.create_session() await session_manager.add_exchange_credentials( session1.id, "binance", "key1", "secret1" ) await session_manager.add_exchange_credentials( session2.id, "kucoin", "key2", "secret2" ) await session_manager.start() await session_manager.stop() assert len(session_manager._sessions) == 0 @pytest.mark.asyncio async def test_credential_replacement_closes_old_connector(self): """Test that replacing credentials closes the old connector.""" session = Session() # Add initial credentials await session.add_credentials( "binance", ExchangeCredentials(api_key="key1", api_secret="secret1"), ) # Create a mock connector mock_connector = MagicMock() mock_connector.close = AsyncMock() session._connectors["binance"] = mock_connector # Replace credentials await session.add_credentials( "binance", ExchangeCredentials(api_key="key2", api_secret="secret2"), ) # Old connector should have been closed mock_connector.close.assert_called_once() class TestRequestModeIntegration: """Integration tests for different request modes.""" @pytest.fixture def cache_manager(self): """Create a cache manager.""" return CacheManager(use_async_db=False) @pytest.mark.asyncio async def test_last_n_mode(self, cache_manager): """Test LAST_N mode fetches most recent candles.""" mock_connector = MockConnector() mock_connector.fetch_candles.return_value = [ make_candle(1709337600 + i * 60) for i in range(50) ] cache_manager.register_exchange("binance", mock_connector) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", limit=50, ) assert request.mode == RequestMode.LAST_N result = await cache_manager.get_candles(request) assert len(result) == 50 @pytest.mark.asyncio async def test_since_mode(self, cache_manager): """Test SINCE mode fetches from start to now.""" mock_connector = MockConnector() mock_connector.fetch_candles.return_value = [ make_candle(1709337600), make_candle(1709337660), ] cache_manager.register_exchange("binance", mock_connector) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", start=1709337600, ) assert request.mode == RequestMode.SINCE @pytest.mark.asyncio async def test_open_mode(self, cache_manager): """Test OPEN mode with no parameters.""" mock_connector = MockConnector() mock_connector.fetch_candles.return_value = [ make_candle(1709337600), ] cache_manager.register_exchange("binance", mock_connector) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", ) assert request.mode == RequestMode.OPEN class TestEpochTimestampHandling: """Integration tests for Unix epoch (0) timestamp handling.""" @pytest.fixture def cache_manager(self): """Create a cache manager.""" return CacheManager(use_async_db=False) @pytest.mark.asyncio async def test_epoch_start_timestamp(self, cache_manager): """Test that start=0 (Unix epoch) works correctly.""" mock_connector = MockConnector() mock_connector.fetch_candles.return_value = [ make_candle(0), # Epoch make_candle(60), ] cache_manager.register_exchange("binance", mock_connector) request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", start=0, end=60, ) # Should be RANGE mode (start=0 is not None) assert request.mode == RequestMode.RANGE assert request.start_ms == 0 result = await cache_manager.get_candles(request) # Should not crash due to truthiness issues assert len(result) >= 0 def test_ms_conversion_with_zero(self): """Test that zero converts correctly to ms.""" request = CandleRequest( exchange="binance", symbol="BTC/USDT", timeframe="1m", start=0, end=0, ) assert request.start_ms == 0 assert request.end_ms == 0