"""Unit tests for chained fundamentals provider fallback behavior.""" from __future__ import annotations from datetime import datetime, timezone import pytest from app.exceptions import ProviderError, RateLimitError from app.providers.fundamentals_chain import ChainedFundamentalProvider from app.providers.protocol import FundamentalData class _FailProvider: def __init__(self, message: str) -> None: self._message = message async def fetch_fundamentals(self, ticker: str) -> FundamentalData: raise ProviderError(f"{self._message} ({ticker})") class _RateLimitedProvider: async def fetch_fundamentals(self, ticker: str) -> FundamentalData: raise RateLimitError(f"rate limit hit for {ticker}") class _DataProvider: def __init__(self, data: FundamentalData) -> None: self._data = data async def fetch_fundamentals(self, ticker: str) -> FundamentalData: return FundamentalData( ticker=ticker, pe_ratio=self._data.pe_ratio, revenue_growth=self._data.revenue_growth, earnings_surprise=self._data.earnings_surprise, market_cap=self._data.market_cap, fetched_at=self._data.fetched_at, unavailable_fields=self._data.unavailable_fields, ) @pytest.mark.asyncio async def test_chained_provider_uses_fallback_provider_on_primary_failure(): fallback_data = FundamentalData( ticker="AAPL", pe_ratio=25.0, revenue_growth=None, earnings_surprise=None, market_cap=1_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={}, ) provider = ChainedFundamentalProvider([ ("primary", _FailProvider("primary down")), ("fallback", _DataProvider(fallback_data)), ]) result = await provider.fetch_fundamentals("AAPL") assert result.pe_ratio == 25.0 assert result.market_cap == 1_000_000.0 assert result.unavailable_fields.get("source_pe_ratio") == "fallback" @pytest.mark.asyncio async def test_chained_provider_merges_fields_across_providers(): """Primary supplies only market cap; fallback fills P/E and earnings.""" primary_data = FundamentalData( ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None, market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={}, ) fallback_data = FundamentalData( ticker="AAPL", pe_ratio=18.0, revenue_growth=12.0, earnings_surprise=4.0, market_cap=999.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={}, ) provider = ChainedFundamentalProvider([ ("fmp", _DataProvider(primary_data)), ("finnhub", _DataProvider(fallback_data)), ]) result = await provider.fetch_fundamentals("AAPL") # market cap from primary (first to supply it), the rest from fallback assert result.market_cap == 2_000_000.0 assert result.pe_ratio == 18.0 assert result.revenue_growth == 12.0 assert result.earnings_surprise == 4.0 assert result.unavailable_fields.get("source_market_cap") == "fmp" assert result.unavailable_fields.get("source_pe_ratio") == "finnhub" @pytest.mark.asyncio async def test_chained_provider_raises_when_all_providers_fail(): provider = ChainedFundamentalProvider([ ("p1", _FailProvider("p1 failed")), ("p2", _FailProvider("p2 failed")), ]) with pytest.raises(ProviderError) as exc: await provider.fetch_fundamentals("MSFT") assert "All fundamentals providers failed" in str(exc.value) @pytest.mark.asyncio async def test_rate_limited_fallback_raises_when_incomplete(): """FMP gives market cap; the fallback is rate-limited → chain signals it so the collector can back off instead of storing a degraded record.""" primary_data = FundamentalData( ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None, market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={}, ) provider = ChainedFundamentalProvider([ ("fmp", _DataProvider(primary_data)), ("finnhub", _RateLimitedProvider()), ]) with pytest.raises(RateLimitError): await provider.fetch_fundamentals("AAPL") @pytest.mark.asyncio async def test_rate_limited_fallback_allows_partial(): """With allow_partial=True the chain returns the market cap it did get.""" primary_data = FundamentalData( ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None, market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={}, ) provider = ChainedFundamentalProvider([ ("fmp", _DataProvider(primary_data)), ("finnhub", _RateLimitedProvider()), ]) result = await provider.fetch_fundamentals("AAPL", allow_partial=True) assert result.market_cap == 2_000_000.0 assert result.pe_ratio is None @pytest.mark.asyncio async def test_rate_limited_but_complete_does_not_raise(): """If every field is filled, a rate limit on a later (unused) provider is moot.""" full = FundamentalData( ticker="AAPL", pe_ratio=20.0, revenue_growth=10.0, earnings_surprise=2.0, market_cap=5.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={}, ) provider = ChainedFundamentalProvider([ ("fmp", _DataProvider(full)), ("finnhub", _RateLimitedProvider()), ]) result = await provider.fetch_fundamentals("AAPL") assert result.pe_ratio == 20.0 @pytest.mark.asyncio async def test_chain_merges_next_earnings_date(): """Earnings date is taken from the first provider that supplies it.""" from datetime import date as _date primary = FundamentalData( ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None, market_cap=100.0, fetched_at=datetime.now(timezone.utc), ) class _EarningsProvider: async def fetch_fundamentals(self, ticker: str) -> FundamentalData: return FundamentalData( ticker=ticker, pe_ratio=10.0, revenue_growth=5.0, earnings_surprise=1.0, market_cap=None, fetched_at=datetime.now(timezone.utc), next_earnings_date=_date(2026, 7, 1), ) provider = ChainedFundamentalProvider([ ("fmp", _DataProvider(primary)), ("finnhub", _EarningsProvider()), ]) result = await provider.fetch_fundamentals("AAPL") assert result.next_earnings_date == _date(2026, 7, 1)