"""Unit tests for FMPFundamentalProvider 402 reason recording.""" from __future__ import annotations from unittest.mock import AsyncMock, patch import httpx import pytest from app.providers.fmp import FMPFundamentalProvider def _mock_response(status_code: int, json_data: object = None) -> httpx.Response: """Build a fake httpx.Response.""" resp = httpx.Response( status_code=status_code, json=json_data if json_data is not None else {}, request=httpx.Request("GET", "https://example.com"), ) return resp @pytest.fixture def provider() -> FMPFundamentalProvider: return FMPFundamentalProvider(api_key="test-key") class TestFetchJsonOptional402Tracking: """_fetch_json_optional returns (data, was_402) tuple.""" @pytest.mark.asyncio async def test_returns_empty_dict_and_true_on_402(self, provider): mock_client = AsyncMock() mock_client.get.return_value = _mock_response(402) data, was_402 = await provider._fetch_json_optional( mock_client, "ratios-ttm", {}, "AAPL" ) assert data == {} assert was_402 is True @pytest.mark.asyncio async def test_returns_data_and_false_on_200(self, provider): mock_client = AsyncMock() mock_client.get.return_value = _mock_response( 200, [{"priceToEarningsRatioTTM": 25.5}] ) data, was_402 = await provider._fetch_json_optional( mock_client, "ratios-ttm", {}, "AAPL" ) assert data == {"priceToEarningsRatioTTM": 25.5} assert was_402 is False class TestFetchFundamentals402Recording: """fetch_fundamentals records 402 endpoints in unavailable_fields.""" @pytest.mark.asyncio async def test_all_402_records_all_fields(self, provider): """When all supplementary endpoints return 402, all three fields are recorded.""" profile_resp = _mock_response(200, [{"marketCap": 1_000_000}]) ratios_resp = _mock_response(402) growth_resp = _mock_response(402) earnings_resp = _mock_response(402) async def mock_get(url, params=None): if "profile" in url: return profile_resp if "ratios-ttm" in url: return ratios_resp if "financial-growth" in url: return growth_resp if "earnings" in url: return earnings_resp return _mock_response(200, [{}]) with patch("app.providers.fmp.httpx.AsyncClient") as MockClient: instance = AsyncMock() instance.get.side_effect = mock_get instance.__aenter__ = AsyncMock(return_value=instance) instance.__aexit__ = AsyncMock(return_value=False) MockClient.return_value = instance result = await provider.fetch_fundamentals("AAPL") assert result.unavailable_fields == { "pe_ratio": "requires paid plan", "revenue_growth": "requires paid plan", "earnings_surprise": "requires paid plan", } @pytest.mark.asyncio async def test_mixed_200_402_records_only_402_fields(self, provider): """When only ratios-ttm returns 402, only pe_ratio is recorded.""" profile_resp = _mock_response(200, [{"marketCap": 2_000_000}]) ratios_resp = _mock_response(402) growth_resp = _mock_response(200, [{"revenueGrowth": 0.15}]) earnings_resp = _mock_response(200, [{"epsActual": 3.0, "epsEstimated": 2.5}]) async def mock_get(url, params=None): if "profile" in url: return profile_resp if "ratios-ttm" in url: return ratios_resp if "financial-growth" in url: return growth_resp if "earnings" in url: return earnings_resp return _mock_response(200, [{}]) with patch("app.providers.fmp.httpx.AsyncClient") as MockClient: instance = AsyncMock() instance.get.side_effect = mock_get instance.__aenter__ = AsyncMock(return_value=instance) instance.__aexit__ = AsyncMock(return_value=False) MockClient.return_value = instance result = await provider.fetch_fundamentals("AAPL") assert result.unavailable_fields == {"pe_ratio": "requires paid plan"} assert result.revenue_growth == 0.15 assert result.earnings_surprise is not None @pytest.mark.asyncio async def test_no_402_empty_unavailable_fields(self, provider): """When all endpoints succeed, unavailable_fields is empty.""" profile_resp = _mock_response(200, [{"marketCap": 3_000_000}]) ratios_resp = _mock_response(200, [{"priceToEarningsRatioTTM": 20.0}]) growth_resp = _mock_response(200, [{"revenueGrowth": 0.10}]) earnings_resp = _mock_response(200, [{"epsActual": 2.0, "epsEstimated": 1.8}]) async def mock_get(url, params=None): if "profile" in url: return profile_resp if "ratios-ttm" in url: return ratios_resp if "financial-growth" in url: return growth_resp if "earnings" in url: return earnings_resp return _mock_response(200, [{}]) with patch("app.providers.fmp.httpx.AsyncClient") as MockClient: instance = AsyncMock() instance.get.side_effect = mock_get instance.__aenter__ = AsyncMock(return_value=instance) instance.__aexit__ = AsyncMock(return_value=False) MockClient.return_value = instance result = await provider.fetch_fundamentals("AAPL") assert result.unavailable_fields == {} assert result.pe_ratio == 20.0