215 lines
7.2 KiB
Python
215 lines
7.2 KiB
Python
"""Tests for CacheManager."""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from exchange_data_manager.cache.manager import CacheManager
|
|
from exchange_data_manager.config import DatabaseConfig
|
|
from exchange_data_manager.candles.models import Candle, CandleRequest
|
|
|
|
|
|
class MockConnector:
|
|
"""Mock exchange connector for testing."""
|
|
|
|
def __init__(self):
|
|
self.fetch_candles = AsyncMock(return_value=[])
|
|
|
|
|
|
@pytest.fixture
|
|
def cache_manager():
|
|
"""Create a cache manager for testing (sync mode)."""
|
|
return CacheManager(use_async_db=False)
|
|
|
|
|
|
class TestCacheManagerColdCache:
|
|
"""Tests for cold-cache / limit-only requests."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_limit_only_request_fetches_from_exchange(self, cache_manager):
|
|
"""Test that limit-only requests (no start/end) fetch from exchange."""
|
|
# Setup mock connector
|
|
mock_connector = MockConnector()
|
|
expected_candles = [
|
|
Candle(time=1709337600, open=50000.0, high=50100.0, low=49900.0, close=50050.0, volume=10.0),
|
|
Candle(time=1709337660, open=50050.0, high=50200.0, low=50000.0, close=50150.0, volume=15.0),
|
|
]
|
|
mock_connector.fetch_candles.return_value = expected_candles
|
|
cache_manager.register_exchange("binance", mock_connector)
|
|
|
|
# Request with limit but no start/end (cold cache scenario)
|
|
request = CandleRequest(
|
|
exchange="binance",
|
|
symbol="BTC/USDT",
|
|
timeframe="1m",
|
|
start=None,
|
|
end=None,
|
|
limit=100,
|
|
)
|
|
|
|
result = await cache_manager.get_candles(request)
|
|
|
|
# Should have fetched from exchange
|
|
mock_connector.fetch_candles.assert_called_once_with(
|
|
symbol="BTC/USDT",
|
|
timeframe="1m",
|
|
limit=100,
|
|
)
|
|
assert len(result) == 2
|
|
assert result[0].time == 1709337600
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_limit_only_no_connector_returns_empty(self, cache_manager):
|
|
"""Test limit-only request with no connector returns empty list."""
|
|
request = CandleRequest(
|
|
exchange="unknown_exchange",
|
|
symbol="BTC/USDT",
|
|
timeframe="1m",
|
|
start=None,
|
|
end=None,
|
|
limit=100,
|
|
)
|
|
|
|
result = await cache_manager.get_candles(request)
|
|
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_limit_only_caches_result(self, cache_manager):
|
|
"""Test that limit-only results are cached in memory."""
|
|
mock_connector = MockConnector()
|
|
expected_candles = [
|
|
Candle(time=1709337600, open=50000.0, high=50100.0, low=49900.0, close=50050.0, volume=10.0),
|
|
]
|
|
mock_connector.fetch_candles.return_value = expected_candles
|
|
cache_manager.register_exchange("binance", mock_connector)
|
|
|
|
request = CandleRequest(
|
|
exchange="binance",
|
|
symbol="BTC/USDT",
|
|
timeframe="1m",
|
|
start=None,
|
|
end=None,
|
|
limit=100,
|
|
)
|
|
|
|
# First call - fetches from exchange
|
|
result1 = await cache_manager.get_candles(request)
|
|
assert mock_connector.fetch_candles.call_count == 1
|
|
assert len(result1) == 1
|
|
|
|
# Second call - memory cache has data, so doesn't need to fetch again
|
|
result2 = await cache_manager.get_candles(request)
|
|
assert mock_connector.fetch_candles.call_count == 1 # Still 1 - used cache
|
|
assert len(result2) == 1
|
|
|
|
|
|
class TestCacheManagerStats:
|
|
"""Tests for cache manager stats."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stats_returns_all_components(self, cache_manager):
|
|
"""Test that stats returns memory, database, and exchanges."""
|
|
stats = await cache_manager.stats()
|
|
|
|
assert "memory" in stats
|
|
assert "database" in stats
|
|
assert "registered_exchanges" in stats
|
|
assert isinstance(stats["registered_exchanges"], list)
|
|
|
|
|
|
class TestCacheManagerSources:
|
|
"""Tests for source reporting and per-request connector overrides."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_candles_with_source_reports_memory(self, cache_manager):
|
|
"""Memory-only responses should report memory source."""
|
|
candles = [
|
|
Candle(time=1709337600, open=50000.0, high=50100.0, low=49900.0, close=50050.0, volume=10.0),
|
|
Candle(time=1709337660, open=50050.0, high=50200.0, low=50000.0, close=50150.0, volume=15.0),
|
|
]
|
|
cache_manager.memory.put("binance:BTC/USDT:1m", candles)
|
|
|
|
request = CandleRequest(
|
|
exchange="binance",
|
|
symbol="BTC/USDT",
|
|
timeframe="1m",
|
|
limit=2,
|
|
)
|
|
|
|
result, source = await cache_manager.get_candles_with_source(request)
|
|
|
|
assert len(result) == 2
|
|
assert source == "memory"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_candles_with_source_uses_connector_override(self, cache_manager):
|
|
"""A session-scoped connector override should be used when provided."""
|
|
override_connector = MockConnector()
|
|
override_connector.fetch_candles.return_value = [
|
|
Candle(time=1709337600, open=50000.0, high=50100.0, low=49900.0, close=50050.0, volume=10.0),
|
|
]
|
|
|
|
request = CandleRequest(
|
|
exchange="binance",
|
|
symbol="BTC/USDT",
|
|
timeframe="1m",
|
|
limit=1,
|
|
)
|
|
|
|
result, source = await cache_manager.get_candles_with_source(
|
|
request,
|
|
connector_override=override_connector,
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert source == "exchange"
|
|
override_connector.fetch_candles.assert_called_once_with(
|
|
symbol="BTC/USDT",
|
|
timeframe="1m",
|
|
limit=1,
|
|
)
|
|
|
|
|
|
class TestCacheManagerDatabaseConfig:
|
|
"""Tests for database config wiring."""
|
|
|
|
def test_async_database_pool_config_is_wired(self):
|
|
"""CacheManager should pass pool config into AsyncDatabaseCache."""
|
|
manager = CacheManager(
|
|
database_config=DatabaseConfig(
|
|
path="./data/test-pool.db",
|
|
pool_size=7,
|
|
max_overflow=3,
|
|
),
|
|
use_async_db=True,
|
|
)
|
|
|
|
assert manager.database._pool_size == 7
|
|
assert manager.database._max_overflow == 3
|
|
|
|
|
|
class TestCacheManagerExchangeRegistration:
|
|
"""Tests for exchange connector registration."""
|
|
|
|
def test_register_exchange(self, cache_manager):
|
|
"""Test registering an exchange connector."""
|
|
mock_connector = MockConnector()
|
|
|
|
cache_manager.register_exchange("test_exchange", mock_connector)
|
|
|
|
assert cache_manager.get_exchange("test_exchange") is mock_connector
|
|
|
|
def test_get_exchange_case_insensitive(self, cache_manager):
|
|
"""Test that exchange lookup is case insensitive."""
|
|
mock_connector = MockConnector()
|
|
|
|
cache_manager.register_exchange("TestExchange", mock_connector)
|
|
|
|
assert cache_manager.get_exchange("testexchange") is mock_connector
|
|
assert cache_manager.get_exchange("TESTEXCHANGE") is mock_connector
|
|
|
|
def test_get_nonexistent_exchange_returns_none(self, cache_manager):
|
|
"""Test that getting a non-existent exchange returns None."""
|
|
result = cache_manager.get_exchange("nonexistent")
|
|
assert result is None
|