exchange-data-manager/tests/test_integration.py

376 lines
12 KiB
Python

"""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."""
# 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 = CandleRequest(
exchange="binance",
symbol="BTC/USDT",
timeframe="1m",
limit=100,
)
# First call - cold cache
await cache_manager.get_candles(request)
assert mock_connector.fetch_candles.call_count == 1
# Second call - should use memory
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