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