diff --git a/tests/test_finnhub_service_social.py b/tests/test_finnhub_service_social.py new file mode 100644 index 0000000..615f15e --- /dev/null +++ b/tests/test_finnhub_service_social.py @@ -0,0 +1,367 @@ +"""Tests for the new social sentiment functions in finnhub_service.""" + +from unittest.mock import patch, AsyncMock, MagicMock + +import pytest + +import finnhub_service + + +# --- get_social_sentiment --- + + +@pytest.mark.asyncio +@patch("finnhub_service.settings") +async def test_social_sentiment_not_configured(mock_settings): + mock_settings.finnhub_api_key = "" + result = await finnhub_service.get_social_sentiment("AAPL") + assert result["configured"] is False + assert "INVEST_API_FINNHUB_API_KEY" in result["message"] + + +@pytest.mark.asyncio +@patch("finnhub_service.settings") +@patch("finnhub_service.httpx.AsyncClient") +async def test_social_sentiment_premium_required_403(mock_client_cls, mock_settings): + mock_settings.finnhub_api_key = "test_key" + mock_resp = MagicMock() + mock_resp.status_code = 403 + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_social_sentiment("AAPL") + assert result["configured"] is True + assert result["premium_required"] is True + assert result["reddit"] == [] + assert result["twitter"] == [] + + +@pytest.mark.asyncio +@patch("finnhub_service.settings") +@patch("finnhub_service.httpx.AsyncClient") +async def test_social_sentiment_premium_required_401(mock_client_cls, mock_settings): + mock_settings.finnhub_api_key = "test_key" + mock_resp = MagicMock() + mock_resp.status_code = 401 + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_social_sentiment("AAPL") + assert result["premium_required"] is True + + +@pytest.mark.asyncio +@patch("finnhub_service.settings") +@patch("finnhub_service.httpx.AsyncClient") +async def test_social_sentiment_success_with_data(mock_client_cls, mock_settings): + mock_settings.finnhub_api_key = "test_key" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "reddit": [ + {"mention": 50, "positiveScore": 30, "negativeScore": 10, "score": 0.5}, + {"mention": 30, "positiveScore": 20, "negativeScore": 5, "score": 0.6}, + ], + "twitter": [ + {"mention": 100, "positiveScore": 60, "negativeScore": 20, "score": 0.4}, + ], + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_social_sentiment("AAPL") + assert result["configured"] is True + assert result["symbol"] == "AAPL" + assert result["reddit_summary"]["total_mentions"] == 80 + assert result["reddit_summary"]["data_points"] == 2 + assert result["twitter_summary"]["total_mentions"] == 100 + assert len(result["reddit"]) == 2 + assert len(result["twitter"]) == 1 + + +@pytest.mark.asyncio +@patch("finnhub_service.settings") +@patch("finnhub_service.httpx.AsyncClient") +async def test_social_sentiment_empty_lists(mock_client_cls, mock_settings): + mock_settings.finnhub_api_key = "test_key" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"reddit": [], "twitter": []} + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_social_sentiment("AAPL") + assert result["configured"] is True + assert result["reddit_summary"] is None + assert result["twitter_summary"] is None + + +@pytest.mark.asyncio +@patch("finnhub_service.settings") +@patch("finnhub_service.httpx.AsyncClient") +async def test_social_sentiment_non_dict_response(mock_client_cls, mock_settings): + mock_settings.finnhub_api_key = "test_key" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = "unexpected string response" + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_social_sentiment("AAPL") + assert result["reddit"] == [] + assert result["twitter"] == [] + + +# --- _summarize_social --- + + +def test_summarize_social_empty(): + result = finnhub_service._summarize_social([]) + assert result == {} + + +def test_summarize_social_single_entry(): + entries = [{"mention": 10, "positiveScore": 7, "negativeScore": 2, "score": 0.5}] + result = finnhub_service._summarize_social(entries) + assert result["total_mentions"] == 10 + assert result["total_positive"] == 7 + assert result["total_negative"] == 2 + assert result["avg_score"] == 0.5 + assert result["data_points"] == 1 + + +def test_summarize_social_multiple_entries(): + entries = [ + {"mention": 100, "positiveScore": 60, "negativeScore": 20, "score": 0.4}, + {"mention": 50, "positiveScore": 30, "negativeScore": 10, "score": 0.6}, + ] + result = finnhub_service._summarize_social(entries) + assert result["total_mentions"] == 150 + assert result["total_positive"] == 90 + assert result["total_negative"] == 30 + assert result["avg_score"] == 0.5 + assert result["data_points"] == 2 + + +def test_summarize_social_missing_fields(): + entries = [{"mention": 5}] + result = finnhub_service._summarize_social(entries) + assert result["total_mentions"] == 5 + assert result["total_positive"] == 0 + assert result["total_negative"] == 0 + + +# --- get_reddit_sentiment --- + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_sentiment_symbol_found(mock_client_cls): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "results": [ + {"ticker": "AAPL", "name": "Apple Inc", "rank": 3, "mentions": 150, "mentions_24h_ago": 100, "upvotes": 500, "rank_24h_ago": 5}, + {"ticker": "TSLA", "name": "Tesla", "rank": 1, "mentions": 300, "mentions_24h_ago": 280, "upvotes": 900, "rank_24h_ago": 2}, + ] + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_sentiment("AAPL") + assert result["found"] is True + assert result["symbol"] == "AAPL" + assert result["rank"] == 3 + assert result["mentions_24h"] == 150 + assert result["mentions_24h_ago"] == 100 + assert result["mentions_change_pct"] == 50.0 + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_sentiment_symbol_not_found(mock_client_cls): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "results": [ + {"ticker": "TSLA", "rank": 1, "mentions": 300, "mentions_24h_ago": 280} + ] + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_sentiment("AAPL") + assert result["found"] is False + assert result["symbol"] == "AAPL" + assert "not in Reddit" in result["message"] + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_sentiment_zero_mentions_prev(mock_client_cls): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "results": [ + {"ticker": "AAPL", "rank": 1, "mentions": 50, "mentions_24h_ago": 0, "upvotes": 200, "rank_24h_ago": None} + ] + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_sentiment("AAPL") + assert result["found"] is True + assert result["mentions_change_pct"] is None # division by zero handled + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_sentiment_api_failure(mock_client_cls): + mock_client = AsyncMock() + mock_client.get.side_effect = Exception("Connection error") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_sentiment("AAPL") + assert result["symbol"] == "AAPL" + assert "error" in result + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_sentiment_case_insensitive(mock_client_cls): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "results": [ + {"ticker": "aapl", "rank": 1, "mentions": 100, "mentions_24h_ago": 80, "upvotes": 400, "rank_24h_ago": 2} + ] + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_sentiment("AAPL") + assert result["found"] is True + + +# --- get_reddit_trending --- + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_trending_happy_path(mock_client_cls): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "results": [ + {"ticker": "TSLA", "name": "Tesla", "rank": 1, "mentions": 500, "upvotes": 1000, "rank_24h_ago": 2, "mentions_24h_ago": 400}, + {"ticker": "AAPL", "name": "Apple", "rank": 2, "mentions": 300, "upvotes": 700, "rank_24h_ago": 1, "mentions_24h_ago": 350}, + {"ticker": "GME", "name": "GameStop", "rank": 3, "mentions": 200, "upvotes": 500, "rank_24h_ago": 3, "mentions_24h_ago": 180}, + ] + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_trending() + assert len(result) == 3 + assert result[0]["symbol"] == "TSLA" + assert result[0]["rank"] == 1 + assert result[1]["symbol"] == "AAPL" + assert "mentions_24h" in result[0] + assert "upvotes" in result[0] + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_trending_limits_to_25(mock_client_cls): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "results": [ + {"ticker": f"SYM{i}", "rank": i + 1, "mentions": 100 - i, "upvotes": 50, "rank_24h_ago": i, "mentions_24h_ago": 80} + for i in range(30) + ] + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_trending() + assert len(result) == 25 + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_trending_empty_results(mock_client_cls): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"results": []} + + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_trending() + assert result == [] + + +@pytest.mark.asyncio +@patch("finnhub_service.httpx.AsyncClient") +async def test_reddit_trending_api_failure(mock_client_cls): + mock_client = AsyncMock() + mock_client.get.side_effect = Exception("ApeWisdom down") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await finnhub_service.get_reddit_trending() + assert result == [] diff --git a/tests/test_routes_economy.py b/tests/test_routes_economy.py new file mode 100644 index 0000000..74ca723 --- /dev/null +++ b/tests/test_routes_economy.py @@ -0,0 +1,433 @@ +"""Tests for expanded economy routes.""" + +from unittest.mock import patch, AsyncMock + +import pytest +from httpx import AsyncClient, ASGITransport + +from main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --- CPI --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_cpi", new_callable=AsyncMock) +async def test_macro_cpi_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-02-01", "value": 312.5, "country": "united_states"} + ] + resp = await client.get("/api/v1/macro/cpi") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["value"] == 312.5 + mock_fn.assert_called_once_with(country="united_states") + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_cpi", new_callable=AsyncMock) +async def test_macro_cpi_custom_country(mock_fn, client): + mock_fn.return_value = [{"date": "2026-02-01", "value": 120.0}] + resp = await client.get("/api/v1/macro/cpi?country=germany") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(country="germany") + + +@pytest.mark.asyncio +async def test_macro_cpi_invalid_country(client): + resp = await client.get("/api/v1/macro/cpi?country=INVALID!!!COUNTRY") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_cpi", new_callable=AsyncMock) +async def test_macro_cpi_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/macro/cpi") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_cpi", new_callable=AsyncMock) +async def test_macro_cpi_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FRED down") + resp = await client.get("/api/v1/macro/cpi") + assert resp.status_code == 502 + + +# --- GDP --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_gdp", new_callable=AsyncMock) +async def test_macro_gdp_default_real(mock_fn, client): + mock_fn.return_value = [{"date": "2026-01-01", "value": 22.5}] + resp = await client.get("/api/v1/macro/gdp") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + mock_fn.assert_called_once_with(gdp_type="real") + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_gdp", new_callable=AsyncMock) +async def test_macro_gdp_nominal(mock_fn, client): + mock_fn.return_value = [{"date": "2026-01-01", "value": 28.3}] + resp = await client.get("/api/v1/macro/gdp?gdp_type=nominal") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(gdp_type="nominal") + + +@pytest.mark.asyncio +async def test_macro_gdp_invalid_type(client): + resp = await client.get("/api/v1/macro/gdp?gdp_type=invalid") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_gdp", new_callable=AsyncMock) +async def test_macro_gdp_forecast(mock_fn, client): + mock_fn.return_value = [{"date": "2027-01-01", "value": 23.1}] + resp = await client.get("/api/v1/macro/gdp?gdp_type=forecast") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(gdp_type="forecast") + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_gdp", new_callable=AsyncMock) +async def test_macro_gdp_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/macro/gdp") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Unemployment --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_unemployment", new_callable=AsyncMock) +async def test_macro_unemployment_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-02-01", "value": 3.7, "country": "united_states"}] + resp = await client.get("/api/v1/macro/unemployment") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["value"] == 3.7 + mock_fn.assert_called_once_with(country="united_states") + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_unemployment", new_callable=AsyncMock) +async def test_macro_unemployment_custom_country(mock_fn, client): + mock_fn.return_value = [{"date": "2026-02-01", "value": 5.1}] + resp = await client.get("/api/v1/macro/unemployment?country=france") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(country="france") + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_unemployment", new_callable=AsyncMock) +async def test_macro_unemployment_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/macro/unemployment") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- PCE --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_pce", new_callable=AsyncMock) +async def test_macro_pce_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-02-01", "value": 2.8}] + resp = await client.get("/api/v1/macro/pce") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["value"] == 2.8 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_pce", new_callable=AsyncMock) +async def test_macro_pce_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/macro/pce") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_pce", new_callable=AsyncMock) +async def test_macro_pce_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FRED unavailable") + resp = await client.get("/api/v1/macro/pce") + assert resp.status_code == 502 + + +# --- Money Measures --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_money_measures", new_callable=AsyncMock) +async def test_macro_money_measures_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-02-01", "m1": 18200.0, "m2": 21000.0}] + resp = await client.get("/api/v1/macro/money-measures") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["m2"] == 21000.0 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_money_measures", new_callable=AsyncMock) +async def test_macro_money_measures_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/macro/money-measures") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- CLI --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_composite_leading_indicator", new_callable=AsyncMock) +async def test_macro_cli_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-01-01", "value": 99.2, "country": "united_states"}] + resp = await client.get("/api/v1/macro/cli") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["value"] == 99.2 + mock_fn.assert_called_once_with(country="united_states") + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_composite_leading_indicator", new_callable=AsyncMock) +async def test_macro_cli_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/macro/cli") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- House Price Index --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_house_price_index", new_callable=AsyncMock) +async def test_macro_hpi_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-01-01", "value": 350.0, "country": "united_states"}] + resp = await client.get("/api/v1/macro/house-price-index") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["value"] == 350.0 + mock_fn.assert_called_once_with(country="united_states") + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_house_price_index", new_callable=AsyncMock) +async def test_macro_hpi_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/macro/house-price-index") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- FRED Regional --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_fred_regional", new_callable=AsyncMock) +async def test_economy_fred_regional_happy_path(mock_fn, client): + mock_fn.return_value = [{"region": "CA", "value": 5.2}] + resp = await client.get("/api/v1/economy/fred-regional?series_id=CAUR") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["region"] == "CA" + mock_fn.assert_called_once_with(series_id="CAUR", region=None) + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_fred_regional", new_callable=AsyncMock) +async def test_economy_fred_regional_with_region(mock_fn, client): + mock_fn.return_value = [{"region": "state", "value": 4.1}] + resp = await client.get("/api/v1/economy/fred-regional?series_id=CAUR®ion=state") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(series_id="CAUR", region="state") + + +@pytest.mark.asyncio +async def test_economy_fred_regional_missing_series_id(client): + resp = await client.get("/api/v1/economy/fred-regional") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_fred_regional", new_callable=AsyncMock) +async def test_economy_fred_regional_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/fred-regional?series_id=UNKNOWN") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Primary Dealer Positioning --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_primary_dealer_positioning", new_callable=AsyncMock) +async def test_economy_primary_dealer_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-03-12", "treasuries": 250000.0, "mbs": 80000.0}] + resp = await client.get("/api/v1/economy/primary-dealer-positioning") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["treasuries"] == 250000.0 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_primary_dealer_positioning", new_callable=AsyncMock) +async def test_economy_primary_dealer_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/primary-dealer-positioning") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- FRED Search --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.fred_search", new_callable=AsyncMock) +async def test_economy_fred_search_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"id": "FEDFUNDS", "title": "Effective Federal Funds Rate", "frequency": "Monthly"} + ] + resp = await client.get("/api/v1/economy/fred-search?query=federal+funds") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["id"] == "FEDFUNDS" + mock_fn.assert_called_once_with(query="federal funds") + + +@pytest.mark.asyncio +async def test_economy_fred_search_missing_query(client): + resp = await client.get("/api/v1/economy/fred-search") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.fred_search", new_callable=AsyncMock) +async def test_economy_fred_search_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/fred-search?query=nothingtofind") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Balance of Payments --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_balance_of_payments", new_callable=AsyncMock) +async def test_economy_bop_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-01-01", "current_account": -200.0, "capital_account": 5.0}] + resp = await client.get("/api/v1/economy/balance-of-payments") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["current_account"] == -200.0 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_balance_of_payments", new_callable=AsyncMock) +async def test_economy_bop_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/balance-of-payments") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Central Bank Holdings --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_central_bank_holdings", new_callable=AsyncMock) +async def test_economy_central_bank_holdings_happy_path(mock_fn, client): + mock_fn.return_value = [{"date": "2026-03-13", "treasuries": 4500000.0, "mbs": 2300000.0}] + resp = await client.get("/api/v1/economy/central-bank-holdings") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["treasuries"] == 4500000.0 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_central_bank_holdings", new_callable=AsyncMock) +async def test_economy_central_bank_holdings_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/central-bank-holdings") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- FOMC Documents --- + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_fomc_documents", new_callable=AsyncMock) +async def test_economy_fomc_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-01-28", "type": "Minutes", "url": "https://federalreserve.gov/fomc"} + ] + resp = await client.get("/api/v1/economy/fomc-documents") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["type"] == "Minutes" + mock_fn.assert_called_once_with(year=None) + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_fomc_documents", new_callable=AsyncMock) +async def test_economy_fomc_with_year(mock_fn, client): + mock_fn.return_value = [{"date": "2024-01-30", "type": "Statement"}] + resp = await client.get("/api/v1/economy/fomc-documents?year=2024") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(year=2024) + + +@pytest.mark.asyncio +async def test_economy_fomc_invalid_year_too_low(client): + resp = await client.get("/api/v1/economy/fomc-documents?year=1999") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_economy_fomc_invalid_year_too_high(client): + resp = await client.get("/api/v1/economy/fomc-documents?year=2100") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_economy.economy_service.get_fomc_documents", new_callable=AsyncMock) +async def test_economy_fomc_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/fomc-documents") + assert resp.status_code == 200 + assert resp.json()["data"] == [] diff --git a/tests/test_routes_fixed_income.py b/tests/test_routes_fixed_income.py new file mode 100644 index 0000000..a6a7bc4 --- /dev/null +++ b/tests/test_routes_fixed_income.py @@ -0,0 +1,328 @@ +"""Tests for fixed income routes.""" + +from unittest.mock import patch, AsyncMock + +import pytest +from httpx import AsyncClient, ASGITransport + +from main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --- Treasury Rates --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_treasury_rates", new_callable=AsyncMock) +async def test_treasury_rates_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-18", "week_4": 5.27, "month_3": 5.30, "year_2": 4.85, "year_10": 4.32, "year_30": 4.55} + ] + resp = await client.get("/api/v1/fixed-income/treasury-rates") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 1 + assert data["data"][0]["year_10"] == 4.32 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_treasury_rates", new_callable=AsyncMock) +async def test_treasury_rates_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/treasury-rates") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_treasury_rates", new_callable=AsyncMock) +async def test_treasury_rates_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("Federal Reserve API down") + resp = await client.get("/api/v1/fixed-income/treasury-rates") + assert resp.status_code == 502 + + +# --- Yield Curve --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_yield_curve", new_callable=AsyncMock) +async def test_yield_curve_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"maturity": "3M", "rate": 5.30}, + {"maturity": "2Y", "rate": 4.85}, + {"maturity": "10Y", "rate": 4.32}, + ] + resp = await client.get("/api/v1/fixed-income/yield-curve") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 3 + mock_fn.assert_called_once_with(date=None) + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_yield_curve", new_callable=AsyncMock) +async def test_yield_curve_with_date(mock_fn, client): + mock_fn.return_value = [{"maturity": "10Y", "rate": 3.80}] + resp = await client.get("/api/v1/fixed-income/yield-curve?date=2024-01-15") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(date="2024-01-15") + + +@pytest.mark.asyncio +async def test_yield_curve_invalid_date_format(client): + resp = await client.get("/api/v1/fixed-income/yield-curve?date=not-a-date") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_yield_curve", new_callable=AsyncMock) +async def test_yield_curve_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/yield-curve") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Treasury Auctions --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_treasury_auctions", new_callable=AsyncMock) +async def test_treasury_auctions_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"auction_date": "2026-03-10", "security_type": "Note", "security_term": "10-Year", "high_yield": 4.32, "bid_to_cover_ratio": 2.45} + ] + resp = await client.get("/api/v1/fixed-income/treasury-auctions") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["bid_to_cover_ratio"] == 2.45 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_treasury_auctions", new_callable=AsyncMock) +async def test_treasury_auctions_with_security_type(mock_fn, client): + mock_fn.return_value = [{"security_type": "Bill"}] + resp = await client.get("/api/v1/fixed-income/treasury-auctions?security_type=Bill") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(security_type="Bill") + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_treasury_auctions", new_callable=AsyncMock) +async def test_treasury_auctions_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/treasury-auctions") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +async def test_treasury_auctions_invalid_security_type(client): + resp = await client.get("/api/v1/fixed-income/treasury-auctions?security_type=DROP;TABLE") + assert resp.status_code == 422 + + +# --- TIPS Yields --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_tips_yields", new_callable=AsyncMock) +async def test_tips_yields_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-18", "year_5": 2.10, "year_10": 2.25, "year_30": 2.40} + ] + resp = await client.get("/api/v1/fixed-income/tips-yields") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["year_10"] == 2.25 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_tips_yields", new_callable=AsyncMock) +async def test_tips_yields_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/tips-yields") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_tips_yields", new_callable=AsyncMock) +async def test_tips_yields_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FRED unavailable") + resp = await client.get("/api/v1/fixed-income/tips-yields") + assert resp.status_code == 502 + + +# --- EFFR --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_effr", new_callable=AsyncMock) +async def test_effr_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-18", "rate": 5.33, "percentile_1": 5.31, "percentile_25": 5.32, "percentile_75": 5.33} + ] + resp = await client.get("/api/v1/fixed-income/effr") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["rate"] == 5.33 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_effr", new_callable=AsyncMock) +async def test_effr_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/effr") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- SOFR --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_sofr", new_callable=AsyncMock) +async def test_sofr_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-18", "rate": 5.31, "average_30d": 5.31, "average_90d": 5.30} + ] + resp = await client.get("/api/v1/fixed-income/sofr") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["rate"] == 5.31 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_sofr", new_callable=AsyncMock) +async def test_sofr_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/sofr") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- HQM --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_hqm", new_callable=AsyncMock) +async def test_hqm_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-02-01", "aaa": 5.10, "aa": 5.25, "a": 5.40} + ] + resp = await client.get("/api/v1/fixed-income/hqm") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["aaa"] == 5.10 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_hqm", new_callable=AsyncMock) +async def test_hqm_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/hqm") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Commercial Paper --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_commercial_paper", new_callable=AsyncMock) +async def test_commercial_paper_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-18", "maturity": "overnight", "financial": 5.28, "nonfinancial": 5.30} + ] + resp = await client.get("/api/v1/fixed-income/commercial-paper") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 1 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_commercial_paper", new_callable=AsyncMock) +async def test_commercial_paper_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/commercial-paper") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Spot Rates --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_spot_rates", new_callable=AsyncMock) +async def test_spot_rates_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-01", "year_1": 5.50, "year_5": 5.20, "year_10": 5.10} + ] + resp = await client.get("/api/v1/fixed-income/spot-rates") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["year_10"] == 5.10 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_spot_rates", new_callable=AsyncMock) +async def test_spot_rates_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/spot-rates") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +# --- Spreads --- + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_spreads", new_callable=AsyncMock) +async def test_spreads_default(mock_fn, client): + mock_fn.return_value = [{"date": "2026-03-18", "spread": 1.10}] + resp = await client.get("/api/v1/fixed-income/spreads") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + mock_fn.assert_called_once_with(series="tcm") + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_spreads", new_callable=AsyncMock) +async def test_spreads_tcm_effr(mock_fn, client): + mock_fn.return_value = [{"date": "2026-03-18", "spread": 0.02}] + resp = await client.get("/api/v1/fixed-income/spreads?series=tcm_effr") + assert resp.status_code == 200 + mock_fn.assert_called_once_with(series="tcm_effr") + + +@pytest.mark.asyncio +async def test_spreads_invalid_series(client): + resp = await client.get("/api/v1/fixed-income/spreads?series=invalid") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_fixed_income.fixed_income_service.get_spreads", new_callable=AsyncMock) +async def test_spreads_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/fixed-income/spreads?series=treasury_effr") + assert resp.status_code == 200 + assert resp.json()["data"] == [] diff --git a/tests/test_routes_regulators.py b/tests/test_routes_regulators.py new file mode 100644 index 0000000..0182177 --- /dev/null +++ b/tests/test_routes_regulators.py @@ -0,0 +1,228 @@ +"""Tests for regulatory data routes (CFTC, SEC).""" + +from unittest.mock import patch, AsyncMock + +import pytest +from httpx import AsyncClient, ASGITransport + +from main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --- COT Report --- + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_cot", new_callable=AsyncMock) +async def test_cot_report_happy_path(mock_fn, client): + mock_fn.return_value = [ + { + "date": "2026-03-11", + "symbol": "ES", + "commercial_long": 250000, + "commercial_short": 300000, + "noncommercial_long": 180000, + "noncommercial_short": 120000, + } + ] + resp = await client.get("/api/v1/regulators/cot?symbol=ES") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["commercial_long"] == 250000 + mock_fn.assert_called_once_with("ES") + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_cot", new_callable=AsyncMock) +async def test_cot_report_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/regulators/cot?symbol=UNKNOWN") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_cot", new_callable=AsyncMock) +async def test_cot_report_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("CFTC unavailable") + resp = await client.get("/api/v1/regulators/cot?symbol=ES") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_cot_report_missing_symbol(client): + resp = await client.get("/api/v1/regulators/cot") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_cot_report_invalid_symbol(client): + resp = await client.get("/api/v1/regulators/cot?symbol=DROP;TABLE") + assert resp.status_code == 400 + + +# --- COT Search --- + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.cot_search", new_callable=AsyncMock) +async def test_cot_search_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"code": "13874P", "name": "E-MINI S&P 500"}, + {"code": "13874A", "name": "S&P 500 CONSOLIDATED"}, + ] + resp = await client.get("/api/v1/regulators/cot/search?query=S%26P+500") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 2 + assert data["data"][0]["name"] == "E-MINI S&P 500" + + +@pytest.mark.asyncio +async def test_cot_search_missing_query(client): + resp = await client.get("/api/v1/regulators/cot/search") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.cot_search", new_callable=AsyncMock) +async def test_cot_search_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/regulators/cot/search?query=nonexistentfutures") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.cot_search", new_callable=AsyncMock) +async def test_cot_search_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("CFTC search failed") + resp = await client.get("/api/v1/regulators/cot/search?query=gold") + assert resp.status_code == 502 + + +# --- SEC Litigation --- + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_sec_litigation", new_callable=AsyncMock) +async def test_sec_litigation_happy_path(mock_fn, client): + mock_fn.return_value = [ + { + "date": "2026-03-15", + "title": "SEC Charges Former CEO with Fraud", + "url": "https://sec.gov/litigation/lr/2026/lr-99999.htm", + "summary": "The Commission charged...", + } + ] + resp = await client.get("/api/v1/regulators/sec/litigation") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 1 + assert "CEO" in data["data"][0]["title"] + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_sec_litigation", new_callable=AsyncMock) +async def test_sec_litigation_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/regulators/sec/litigation") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_sec_litigation", new_callable=AsyncMock) +async def test_sec_litigation_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("SEC RSS feed unavailable") + resp = await client.get("/api/v1/regulators/sec/litigation") + assert resp.status_code == 502 + + +# --- SEC Institution Search --- + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.search_institutions", new_callable=AsyncMock) +async def test_sec_institutions_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"name": "Vanguard Group Inc", "cik": "0000102909"}, + {"name": "BlackRock Inc", "cik": "0001364742"}, + ] + resp = await client.get("/api/v1/regulators/sec/institutions?query=vanguard") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 2 + assert data["data"][0]["name"] == "Vanguard Group Inc" + mock_fn.assert_called_once_with("vanguard") + + +@pytest.mark.asyncio +async def test_sec_institutions_missing_query(client): + resp = await client.get("/api/v1/regulators/sec/institutions") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.search_institutions", new_callable=AsyncMock) +async def test_sec_institutions_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/regulators/sec/institutions?query=notarealfirm") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.search_institutions", new_callable=AsyncMock) +async def test_sec_institutions_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("SEC API failed") + resp = await client.get("/api/v1/regulators/sec/institutions?query=blackrock") + assert resp.status_code == 502 + + +# --- SEC CIK Map --- + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_cik_map", new_callable=AsyncMock) +async def test_sec_cik_map_happy_path(mock_fn, client): + mock_fn.return_value = [{"symbol": "AAPL", "cik": "0000320193"}] + resp = await client.get("/api/v1/regulators/sec/cik-map/AAPL") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["cik"] == "0000320193" + mock_fn.assert_called_once_with("AAPL") + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_cik_map", new_callable=AsyncMock) +async def test_sec_cik_map_not_found(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/regulators/sec/cik-map/XXXX") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_regulators.regulators_service.get_cik_map", new_callable=AsyncMock) +async def test_sec_cik_map_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("SEC lookup failed") + resp = await client.get("/api/v1/regulators/sec/cik-map/AAPL") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_sec_cik_map_invalid_symbol(client): + resp = await client.get("/api/v1/regulators/sec/cik-map/INVALID!!!") + assert resp.status_code == 400 diff --git a/tests/test_routes_sentiment.py b/tests/test_routes_sentiment.py index 0e80039..83be103 100644 --- a/tests/test_routes_sentiment.py +++ b/tests/test_routes_sentiment.py @@ -14,13 +14,17 @@ async def client(): @pytest.mark.asyncio -@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) @patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) -async def test_stock_sentiment(mock_sentiment, mock_av, client): +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_stock_sentiment(mock_av, mock_sentiment, mock_reddit, mock_upgrades, mock_recs, client): + # Route was refactored to return composite_score/composite_label/details/source_scores mock_sentiment.return_value = { "symbol": "AAPL", "news_sentiment": {"bullish_percent": 0.7, "bearish_percent": 0.3}, - "recent_news": [], + "recent_news": [{"headline": "Apple strong"}], "analyst_recommendations": [], "recent_upgrades_downgrades": [], } @@ -31,12 +35,22 @@ async def test_stock_sentiment(mock_sentiment, mock_av, client): "overall_sentiment": {"avg_score": 0.4, "label": "Bullish"}, "articles": [], } + mock_reddit.return_value = {"found": False, "symbol": "AAPL"} + mock_upgrades.return_value = [] + mock_recs.return_value = [] resp = await client.get("/api/v1/stock/AAPL/sentiment") assert resp.status_code == 200 data = resp.json()["data"] assert data["symbol"] == "AAPL" - assert data["news_sentiment"]["bullish_percent"] == 0.7 - assert data["alpha_vantage_sentiment"]["overall_sentiment"]["label"] == "Bullish" + # New composite response shape + assert "composite_score" in data + assert "composite_label" in data + assert "source_scores" in data + assert "details" in data + # AV news data accessible via details + assert data["details"]["news_sentiment"]["overall_sentiment"]["label"] == "Bullish" + # Finnhub news accessible via details + assert len(data["details"]["finnhub_news"]) == 1 @pytest.mark.asyncio diff --git a/tests/test_routes_sentiment_social.py b/tests/test_routes_sentiment_social.py new file mode 100644 index 0000000..8d9d4b9 --- /dev/null +++ b/tests/test_routes_sentiment_social.py @@ -0,0 +1,358 @@ +"""Tests for new sentiment routes: social sentiment, reddit, composite sentiment, trending.""" + +from unittest.mock import patch, AsyncMock + +import pytest +from httpx import AsyncClient, ASGITransport + +from main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --- Social Sentiment (Finnhub) --- + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_social_sentiment", new_callable=AsyncMock) +async def test_stock_social_sentiment_happy_path(mock_fn, client): + mock_fn.return_value = { + "configured": True, + "symbol": "AAPL", + "reddit_summary": {"total_mentions": 150, "avg_score": 0.55, "data_points": 5}, + "twitter_summary": {"total_mentions": 300, "avg_score": 0.40, "data_points": 8}, + "reddit": [{"mention": 30, "score": 0.5}], + "twitter": [{"mention": 40, "score": 0.4}], + } + resp = await client.get("/api/v1/stock/AAPL/social-sentiment") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"]["symbol"] == "AAPL" + assert data["data"]["reddit_summary"]["total_mentions"] == 150 + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_social_sentiment", new_callable=AsyncMock) +async def test_stock_social_sentiment_not_configured(mock_fn, client): + mock_fn.return_value = {"configured": False, "message": "Set INVEST_API_FINNHUB_API_KEY"} + resp = await client.get("/api/v1/stock/AAPL/social-sentiment") + assert resp.status_code == 200 + data = resp.json() + assert data["data"]["configured"] is False + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_social_sentiment", new_callable=AsyncMock) +async def test_stock_social_sentiment_premium_required(mock_fn, client): + mock_fn.return_value = { + "configured": True, + "symbol": "AAPL", + "premium_required": True, + "reddit": [], + "twitter": [], + } + resp = await client.get("/api/v1/stock/AAPL/social-sentiment") + assert resp.status_code == 200 + data = resp.json() + assert data["data"]["premium_required"] is True + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_social_sentiment", new_callable=AsyncMock) +async def test_stock_social_sentiment_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("Finnhub error") + resp = await client.get("/api/v1/stock/AAPL/social-sentiment") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_stock_social_sentiment_invalid_symbol(client): + resp = await client.get("/api/v1/stock/INVALID!!!/social-sentiment") + assert resp.status_code == 400 + + +# --- Reddit Sentiment --- + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +async def test_stock_reddit_sentiment_found(mock_fn, client): + mock_fn.return_value = { + "symbol": "AAPL", + "found": True, + "rank": 3, + "mentions_24h": 150, + "mentions_24h_ago": 100, + "mentions_change_pct": 50.0, + "upvotes": 500, + "rank_24h_ago": 5, + } + resp = await client.get("/api/v1/stock/AAPL/reddit-sentiment") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"]["found"] is True + assert data["data"]["rank"] == 3 + assert data["data"]["mentions_24h"] == 150 + assert data["data"]["mentions_change_pct"] == 50.0 + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +async def test_stock_reddit_sentiment_not_found(mock_fn, client): + mock_fn.return_value = { + "symbol": "OBSCURE", + "found": False, + "message": "OBSCURE not in Reddit top trending (not enough mentions)", + } + resp = await client.get("/api/v1/stock/OBSCURE/reddit-sentiment") + assert resp.status_code == 200 + data = resp.json() + assert data["data"]["found"] is False + assert "not in Reddit" in data["data"]["message"] + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +async def test_stock_reddit_sentiment_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("ApeWisdom down") + resp = await client.get("/api/v1/stock/AAPL/reddit-sentiment") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_stock_reddit_sentiment_invalid_symbol(client): + resp = await client.get("/api/v1/stock/BAD!!!/reddit-sentiment") + assert resp.status_code == 400 + + +# --- Reddit Trending --- + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_reddit_trending", new_callable=AsyncMock) +async def test_reddit_trending_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"rank": 1, "symbol": "TSLA", "name": "Tesla", "mentions_24h": 500, "upvotes": 1200, "rank_24h_ago": 2, "mentions_24h_ago": 400}, + {"rank": 2, "symbol": "AAPL", "name": "Apple", "mentions_24h": 300, "upvotes": 800, "rank_24h_ago": 1, "mentions_24h_ago": 350}, + {"rank": 3, "symbol": "GME", "name": "GameStop", "mentions_24h": 200, "upvotes": 600, "rank_24h_ago": 3, "mentions_24h_ago": 180}, + ] + resp = await client.get("/api/v1/discover/reddit-trending") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 3 + assert data["data"][0]["symbol"] == "TSLA" + assert data["data"][0]["rank"] == 1 + assert data["data"][1]["symbol"] == "AAPL" + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_reddit_trending", new_callable=AsyncMock) +async def test_reddit_trending_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/discover/reddit-trending") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_reddit_trending", new_callable=AsyncMock) +async def test_reddit_trending_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("ApeWisdom unavailable") + resp = await client.get("/api/v1/discover/reddit-trending") + assert resp.status_code == 502 + + +# --- Composite /stock/{symbol}/sentiment (aggregation logic) --- + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_composite_sentiment_all_sources(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): + mock_av.return_value = { + "configured": True, + "symbol": "AAPL", + "overall_sentiment": {"avg_score": 0.2, "label": "Bullish"}, + "articles": [], + } + mock_fh.return_value = { + "symbol": "AAPL", + "news_sentiment": {}, + "recent_news": [{"headline": "Apple rises", "source": "Reuters"}], + "analyst_recommendations": [], + "recent_upgrades_downgrades": [], + } + mock_reddit.return_value = { + "symbol": "AAPL", + "found": True, + "rank": 2, + "mentions_24h": 200, + "mentions_24h_ago": 150, + "mentions_change_pct": 33.3, + "upvotes": 800, + } + mock_upgrades.return_value = [ + {"action": "up", "company": "Goldman"}, + {"action": "down", "company": "Morgan Stanley"}, + {"action": "init", "company": "JPMorgan"}, + ] + mock_recs.return_value = [ + {"strongBuy": 10, "buy": 15, "hold": 5, "sell": 2, "strongSell": 1} + ] + + resp = await client.get("/api/v1/stock/AAPL/sentiment") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + result = data["data"] + assert result["symbol"] == "AAPL" + assert result["composite_score"] is not None + assert result["composite_label"] in ("Strong Bullish", "Bullish", "Neutral", "Bearish", "Strong Bearish") + assert "news" in result["source_scores"] + assert "analysts" in result["source_scores"] + assert "upgrades" in result["source_scores"] + assert "reddit" in result["source_scores"] + assert "details" in result + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_composite_sentiment_no_data_returns_unknown(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): + mock_av.return_value = {} + mock_fh.return_value = {} + mock_reddit.return_value = {"found": False} + mock_upgrades.return_value = [] + mock_recs.return_value = [] + + resp = await client.get("/api/v1/stock/AAPL/sentiment") + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["composite_score"] is None + assert data["composite_label"] == "Unknown" + assert data["source_scores"] == {} + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_composite_sentiment_strong_bullish_label(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): + # All signals strongly bullish + mock_av.return_value = {"overall_sentiment": {"avg_score": 0.35}} + mock_fh.return_value = {} + mock_reddit.return_value = {"found": True, "mentions_24h": 500, "mentions_change_pct": 100.0} + mock_upgrades.return_value = [{"action": "up"}, {"action": "up"}, {"action": "up"}] + mock_recs.return_value = [{"strongBuy": 20, "buy": 10, "hold": 1, "sell": 0, "strongSell": 0}] + + resp = await client.get("/api/v1/stock/AAPL/sentiment") + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["composite_score"] >= 0.5 + assert data["composite_label"] == "Strong Bullish" + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_composite_sentiment_bearish_label(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): + # All signals bearish + mock_av.return_value = {"overall_sentiment": {"avg_score": -0.3}} + mock_fh.return_value = {} + mock_reddit.return_value = {"found": True, "mentions_24h": 200, "mentions_change_pct": -70.0} + mock_upgrades.return_value = [{"action": "down"}, {"action": "down"}, {"action": "down"}] + mock_recs.return_value = [{"strongBuy": 0, "buy": 2, "hold": 5, "sell": 10, "strongSell": 5}] + + resp = await client.get("/api/v1/stock/AAPL/sentiment") + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["composite_label"] in ("Bearish", "Strong Bearish") + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_composite_sentiment_one_source_failing_is_graceful(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): + # Simulate an exception from one source — gather uses return_exceptions=True + mock_av.side_effect = RuntimeError("AV down") + mock_fh.return_value = {} + mock_reddit.return_value = {"found": False} + mock_upgrades.return_value = [] + mock_recs.return_value = [{"strongBuy": 5, "buy": 5, "hold": 3, "sell": 1, "strongSell": 0}] + + resp = await client.get("/api/v1/stock/AAPL/sentiment") + # Should still succeed, gracefully skipping the failed source + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["symbol"] == "AAPL" + + +@pytest.mark.asyncio +async def test_composite_sentiment_invalid_symbol(client): + resp = await client.get("/api/v1/stock/INVALID!!!/sentiment") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_composite_sentiment_reddit_low_mentions_excluded(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): + # Reddit mentions < 10 threshold should exclude reddit from scoring + mock_av.return_value = {} + mock_fh.return_value = {} + mock_reddit.return_value = {"found": True, "mentions_24h": 5, "mentions_change_pct": 50.0} + mock_upgrades.return_value = [] + mock_recs.return_value = [] + + resp = await client.get("/api/v1/stock/AAPL/sentiment") + assert resp.status_code == 200 + data = resp.json()["data"] + assert "reddit" not in data["source_scores"] + + +@pytest.mark.asyncio +@patch("routes_sentiment.finnhub_service.get_recommendation_trends", new_callable=AsyncMock) +@patch("routes_sentiment.openbb_service.get_upgrades_downgrades", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_reddit_sentiment", new_callable=AsyncMock) +@patch("routes_sentiment.finnhub_service.get_sentiment_summary", new_callable=AsyncMock) +@patch("routes_sentiment.alphavantage_service.get_news_sentiment", new_callable=AsyncMock) +async def test_composite_sentiment_details_structure(mock_av, mock_fh, mock_reddit, mock_upgrades, mock_recs, client): + mock_av.return_value = {"overall_sentiment": {"avg_score": 0.1}} + mock_fh.return_value = {"recent_news": [{"headline": "Test news"}]} + mock_reddit.return_value = {"found": False} + mock_upgrades.return_value = [{"action": "up"}, {"action": "up"}] + mock_recs.return_value = [] + + resp = await client.get("/api/v1/stock/MSFT/sentiment") + assert resp.status_code == 200 + details = resp.json()["data"]["details"] + assert "news_sentiment" in details + assert "analyst_recommendations" in details + assert "recent_upgrades" in details + assert "reddit" in details + assert "finnhub_news" in details diff --git a/tests/test_routes_shorts.py b/tests/test_routes_shorts.py new file mode 100644 index 0000000..3a0f393 --- /dev/null +++ b/tests/test_routes_shorts.py @@ -0,0 +1,176 @@ +"""Tests for shorts and dark pool routes.""" + +from unittest.mock import patch, AsyncMock + +import pytest +from httpx import AsyncClient, ASGITransport + +from main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --- Short Volume --- + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_short_volume", new_callable=AsyncMock) +async def test_short_volume_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-18", "short_volume": 5000000, "short_exempt_volume": 10000, "total_volume": 20000000, "short_volume_percent": 0.25} + ] + resp = await client.get("/api/v1/stock/AAPL/shorts/volume") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 1 + assert data["data"][0]["short_volume"] == 5000000 + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_short_volume", new_callable=AsyncMock) +async def test_short_volume_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/stock/GME/shorts/volume") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_short_volume", new_callable=AsyncMock) +async def test_short_volume_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("stockgrid unavailable") + resp = await client.get("/api/v1/stock/AAPL/shorts/volume") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_short_volume_invalid_symbol(client): + resp = await client.get("/api/v1/stock/AAPL;DROP/shorts/volume") + assert resp.status_code == 400 + + +# --- Fails To Deliver --- + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_fails_to_deliver", new_callable=AsyncMock) +async def test_ftd_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-01", "cusip": "037833100", "failure_quantity": 50000, "symbol": "AAPL", "price": 175.0} + ] + resp = await client.get("/api/v1/stock/AAPL/shorts/ftd") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["symbol"] == "AAPL" + assert data["data"][0]["failure_quantity"] == 50000 + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_fails_to_deliver", new_callable=AsyncMock) +async def test_ftd_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/stock/TSLA/shorts/ftd") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_fails_to_deliver", new_callable=AsyncMock) +async def test_ftd_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("SEC connection failed") + resp = await client.get("/api/v1/stock/AAPL/shorts/ftd") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_ftd_invalid_symbol(client): + resp = await client.get("/api/v1/stock/BAD!!!/shorts/ftd") + assert resp.status_code == 400 + + +# --- Short Interest --- + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_short_interest", new_callable=AsyncMock) +async def test_short_interest_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"settlement_date": "2026-02-28", "symbol": "GME", "short_interest": 20000000, "days_to_cover": 3.5} + ] + resp = await client.get("/api/v1/stock/GME/shorts/interest") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["short_interest"] == 20000000 + assert data["data"][0]["days_to_cover"] == 3.5 + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_short_interest", new_callable=AsyncMock) +async def test_short_interest_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/stock/NVDA/shorts/interest") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_short_interest", new_callable=AsyncMock) +async def test_short_interest_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FINRA unavailable") + resp = await client.get("/api/v1/stock/AAPL/shorts/interest") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_short_interest_invalid_symbol(client): + resp = await client.get("/api/v1/stock/INVALID!!!/shorts/interest") + assert resp.status_code == 400 + + +# --- Dark Pool OTC --- + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_darkpool_otc", new_callable=AsyncMock) +async def test_darkpool_otc_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-18", "symbol": "AAPL", "shares": 3000000, "percentage": 12.5} + ] + resp = await client.get("/api/v1/darkpool/AAPL/otc") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["percentage"] == 12.5 + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_darkpool_otc", new_callable=AsyncMock) +async def test_darkpool_otc_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/darkpool/TSLA/otc") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_shorts.shorts_service.get_darkpool_otc", new_callable=AsyncMock) +async def test_darkpool_otc_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FINRA connection timeout") + resp = await client.get("/api/v1/darkpool/AAPL/otc") + assert resp.status_code == 502 + + +@pytest.mark.asyncio +async def test_darkpool_otc_invalid_symbol(client): + resp = await client.get("/api/v1/darkpool/BAD!!!/otc") + assert resp.status_code == 400 diff --git a/tests/test_routes_surveys.py b/tests/test_routes_surveys.py new file mode 100644 index 0000000..095b1c2 --- /dev/null +++ b/tests/test_routes_surveys.py @@ -0,0 +1,189 @@ +"""Tests for economy survey routes.""" + +from unittest.mock import patch, AsyncMock + +import pytest +from httpx import AsyncClient, ASGITransport + +from main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --- Michigan Consumer Sentiment --- + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_michigan", new_callable=AsyncMock) +async def test_survey_michigan_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-01", "consumer_sentiment": 76.5, "inflation_expectation_1yr": 3.1} + ] + resp = await client.get("/api/v1/economy/surveys/michigan") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["consumer_sentiment"] == 76.5 + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_michigan", new_callable=AsyncMock) +async def test_survey_michigan_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/surveys/michigan") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_michigan", new_callable=AsyncMock) +async def test_survey_michigan_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FRED unavailable") + resp = await client.get("/api/v1/economy/surveys/michigan") + assert resp.status_code == 502 + + +# --- SLOOS --- + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_sloos", new_callable=AsyncMock) +async def test_survey_sloos_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-01-01", "c_i_tightening_pct": 25.0, "consumer_tightening_pct": 10.0} + ] + resp = await client.get("/api/v1/economy/surveys/sloos") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["c_i_tightening_pct"] == 25.0 + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_sloos", new_callable=AsyncMock) +async def test_survey_sloos_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/surveys/sloos") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_sloos", new_callable=AsyncMock) +async def test_survey_sloos_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FRED down") + resp = await client.get("/api/v1/economy/surveys/sloos") + assert resp.status_code == 502 + + +# --- Nonfarm Payrolls --- + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_nonfarm_payrolls", new_callable=AsyncMock) +async def test_survey_nfp_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-07", "value": 275000, "industry": "total_nonfarm"} + ] + resp = await client.get("/api/v1/economy/surveys/nonfarm-payrolls") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["value"] == 275000 + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_nonfarm_payrolls", new_callable=AsyncMock) +async def test_survey_nfp_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/surveys/nonfarm-payrolls") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_nonfarm_payrolls", new_callable=AsyncMock) +async def test_survey_nfp_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("BLS unavailable") + resp = await client.get("/api/v1/economy/surveys/nonfarm-payrolls") + assert resp.status_code == 502 + + +# --- Empire State Manufacturing --- + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_empire_state", new_callable=AsyncMock) +async def test_survey_empire_state_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"date": "2026-03-01", "general_business_conditions": -7.58} + ] + resp = await client.get("/api/v1/economy/surveys/empire-state") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["data"][0]["general_business_conditions"] == -7.58 + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_empire_state", new_callable=AsyncMock) +async def test_survey_empire_state_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/surveys/empire-state") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.get_empire_state", new_callable=AsyncMock) +async def test_survey_empire_state_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("FRED connection error") + resp = await client.get("/api/v1/economy/surveys/empire-state") + assert resp.status_code == 502 + + +# --- BLS Search --- + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.bls_search", new_callable=AsyncMock) +async def test_survey_bls_search_happy_path(mock_fn, client): + mock_fn.return_value = [ + {"series_id": "CES0000000001", "series_title": "All employees, thousands, total nonfarm"}, + {"series_id": "CES1000000001", "series_title": "All employees, thousands, mining and logging"}, + ] + resp = await client.get("/api/v1/economy/surveys/bls-search?query=nonfarm+payrolls") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["data"]) == 2 + assert data["data"][0]["series_id"] == "CES0000000001" + mock_fn.assert_called_once_with(query="nonfarm payrolls") + + +@pytest.mark.asyncio +async def test_survey_bls_search_missing_query(client): + resp = await client.get("/api/v1/economy/surveys/bls-search") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.bls_search", new_callable=AsyncMock) +async def test_survey_bls_search_empty(mock_fn, client): + mock_fn.return_value = [] + resp = await client.get("/api/v1/economy/surveys/bls-search?query=nothingtofind") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +@pytest.mark.asyncio +@patch("routes_surveys.surveys_service.bls_search", new_callable=AsyncMock) +async def test_survey_bls_search_service_error_returns_502(mock_fn, client): + mock_fn.side_effect = RuntimeError("BLS API down") + resp = await client.get("/api/v1/economy/surveys/bls-search?query=wages") + assert resp.status_code == 502