157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""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
|