Files
openbb-invest-api/tests/test_routes_sentiment_social.py
Yaojia Wang 27b131492f test: add 159 tests for all new modules
New test files (171 tests):
- test_routes_shorts.py (16) - short volume, FTD, interest, darkpool
- test_routes_fixed_income.py (34) - treasury, yield curve, SOFR, etc.
- test_routes_economy.py (44) - CPI, GDP, FRED search, Fed holdings
- test_routes_surveys.py (17) - Michigan, SLOOS, NFP, Empire State
- test_routes_regulators.py (20) - COT, SEC litigation, institutions
- test_finnhub_service_social.py (20) - social/reddit sentiment unit tests
- test_routes_sentiment_social.py (20) - social endpoints + composite

Updated:
- test_routes_sentiment.py - match new composite sentiment response shape

Total: 261 tests passing (was 102)
2026-03-19 22:12:27 +01:00

359 lines
15 KiB
Python

"""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