376 lines
12 KiB
Python
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
|