# app/tests/unit/integrations/exchange/test_service_quote.py from __future__ import annotations from datetime import datetime, timedelta, timezone from decimal import Decimal from types import SimpleNamespace from typing import cast import pytest import src.integrations.exchange.service as service_module from src.core.config import Settings from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.service import ExchangeService from src.market_data.acquisition.models.quote import Quote import src.storage.quote_store as quote_store_module from src.storage.quote_store import ( InMemoryQuoteStore, QUOTE_RUNTIME_SOURCE_NAME, get_quote_store, ) def _service(*, exchange_enabled: bool = True) -> ExchangeService: service = object.__new__(ExchangeService) service.settings = cast( Settings, SimpleNamespace( exchange_enabled=exchange_enabled, default_symbol="BTC/USD_LEVERAGE", ), ) return service def _quote(*, age_seconds: float = 0.0) -> Quote: return Quote( symbol="BTC/USD_LEVERAGE", last_price=Decimal("100"), bid_price=Decimal("99"), ask_price=Decimal("101"), exchange_timestamp=None, received_at=( datetime.now(timezone.utc) - timedelta(seconds=age_seconds) ), source="test", ) @pytest.fixture(autouse=True) def reset_store(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( quote_store_module, "_SHARED_QUOTE_STORE", InMemoryQuoteStore(), ) yield def test_get_quote_returns_fresh_cached_identity( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() quote = _quote() get_quote_store().set( QUOTE_RUNTIME_SOURCE_NAME,quote, runtime_key="auto") monkeypatch.setattr( service, "validate_symbol", lambda _: SimpleNamespace( is_valid=True, normalized_symbol=quote.symbol, message="", ), ) monkeypatch.setattr( service, "_get_fresh_quote", lambda _: (_ for _ in ()).throw(AssertionError("REST not expected")), ) assert service.get_quote(quote.symbol, runtime_key="auto") is quote def test_get_quote_refreshes_stale_quote( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() stale = _quote(age_seconds=10) fresh = _quote() get_quote_store().set( QUOTE_RUNTIME_SOURCE_NAME,stale, runtime_key="auto") monkeypatch.setattr( service, "validate_symbol", lambda _: SimpleNamespace( is_valid=True, normalized_symbol=fresh.symbol, message="", ), ) monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh) result = service.get_quote(fresh.symbol, runtime_key="auto") assert result is fresh assert get_quote_store().get( QUOTE_RUNTIME_SOURCE_NAME,fresh.symbol, runtime_key="auto") is fresh def test_get_quote_wraps_acquisition_error( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() original = RuntimeError("unavailable") monkeypatch.setattr( service, "_load_quote_via_acquisition", lambda _: (_ for _ in ()).throw(original), ) monkeypatch.setattr(service, "_log_exchange_error", lambda **_: None) with pytest.raises(ExchangeError) as exc_info: service._get_fresh_quote("BTC/USD_LEVERAGE") assert exc_info.value.__cause__ is original def test_refresh_quote_cache_forces_fresh_quote_and_preserves_identity( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() cached = _quote() fresh = _quote() get_quote_store().set( QUOTE_RUNTIME_SOURCE_NAME,cached, runtime_key="auto") requested_symbols: list[str] = [] monkeypatch.setattr( service, "validate_symbol", lambda symbol: SimpleNamespace( is_valid=True, normalized_symbol=(requested_symbols.append(symbol) or fresh.symbol), message="", ), ) monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh) result = service.refresh_quote_cache( " btc/usd_leverage ", runtime_key="auto", ) assert requested_symbols == [" btc/usd_leverage "] assert result is fresh assert result is not cached assert get_quote_store().get( QUOTE_RUNTIME_SOURCE_NAME,fresh.symbol, runtime_key="auto") is fresh def test_refresh_quote_cache_uses_default_runtime_key( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() fresh = _quote() monkeypatch.setattr( service, "validate_symbol", lambda _: SimpleNamespace( is_valid=True, normalized_symbol=fresh.symbol, message="", ), ) monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh) result = service.refresh_quote_cache(fresh.symbol) assert result is fresh assert get_quote_store().get( QUOTE_RUNTIME_SOURCE_NAME,fresh.symbol, runtime_key="auto") is fresh def test_refresh_quote_cache_normalizes_runtime_key( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() fresh = _quote() monkeypatch.setattr( service, "validate_symbol", lambda _: SimpleNamespace( is_valid=True, normalized_symbol=fresh.symbol, message="", ), ) monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh) result = service.refresh_quote_cache( fresh.symbol, runtime_key=" Debug_Auto ", ) assert result is fresh assert ( get_quote_store().get( QUOTE_RUNTIME_SOURCE_NAME, fresh.symbol, runtime_key="debug_auto", ) is fresh ) def test_refresh_quote_cache_rejects_invalid_symbol( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() monkeypatch.setattr( service, "validate_symbol", lambda _: SimpleNamespace( is_valid=False, normalized_symbol="UNKNOWN/USD", message="Символ не найден.", ), ) monkeypatch.setattr( service, "_get_fresh_quote", lambda _: (_ for _ in ()).throw( AssertionError("Fresh Quote must not be requested.") ), ) with pytest.raises(ExchangeError, match="Символ не найден"): service.refresh_quote_cache("UNKNOWN/USD", runtime_key="auto") def test_refresh_quote_cache_does_not_update_cache_on_acquisition_error( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() cached = _quote() original = RuntimeError("unavailable") get_quote_store().set( QUOTE_RUNTIME_SOURCE_NAME,cached, runtime_key="auto") monkeypatch.setattr( service, "validate_symbol", lambda _: SimpleNamespace( is_valid=True, normalized_symbol=cached.symbol, message="", ), ) monkeypatch.setattr( service, "_load_quote_via_acquisition", lambda _: (_ for _ in ()).throw(original), ) monkeypatch.setattr(service, "_log_exchange_error", lambda **_: None) with pytest.raises(ExchangeError) as exc_info: service.refresh_quote_cache(cached.symbol, runtime_key="auto") assert exc_info.value.__cause__ is original assert get_quote_store().get( QUOTE_RUNTIME_SOURCE_NAME,cached.symbol, runtime_key="auto") is cached def test_refresh_quote_cache_uses_and_stores_mock_quote( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service(exchange_enabled=False) mock = _quote() monkeypatch.setattr(service_module, "mock_quote", lambda _: mock) monkeypatch.setattr( service, "validate_symbol", lambda _: (_ for _ in ()).throw( AssertionError("Validation is not expected in mock mode.") ), ) monkeypatch.setattr( service, "_get_fresh_quote", lambda _: (_ for _ in ()).throw( AssertionError("REST is not expected in mock mode.") ), ) result = service.refresh_quote_cache( "BTC/USD_LEVERAGE", runtime_key="debug_auto", ) assert result is mock assert ( get_quote_store().get( QUOTE_RUNTIME_SOURCE_NAME, mock.symbol, runtime_key="debug_auto", ) is mock )