summaryrefslogtreecommitdiff
path: root/backend/tests/test_news_service.py
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-06-22 15:11:45 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-06-22 15:11:45 -0700
commit911765d8d1094df1baed4c3a313988e18cc31755 (patch)
treee209289341237a9769e84158df36cc16937bdcae /backend/tests/test_news_service.py
parent570b2ec7c5890ef6f11eab5ac1c56eb3ec90911f (diff)
feat: add backend news service with Finnhub/FMP fallback and sentiment
Diffstat (limited to 'backend/tests/test_news_service.py')
-rw-r--r--backend/tests/test_news_service.py200
1 files changed, 200 insertions, 0 deletions
diff --git a/backend/tests/test_news_service.py b/backend/tests/test_news_service.py
new file mode 100644
index 0000000..297c2f6
--- /dev/null
+++ b/backend/tests/test_news_service.py
@@ -0,0 +1,200 @@
+"""Tests for backend news service."""
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from app.services import news_service
+
+
+def _clear_news_cache() -> None:
+ news_service.NEWS_CACHE.clear()
+
+
+def _finnhub_article(**kwargs) -> dict:
+ defaults = {
+ "datetime": int(datetime(2026, 6, 22, 18, 42, 0, tzinfo=timezone.utc).timestamp()),
+ "headline": "AMD surges on strong demand",
+ "summary": "Chipmaker gains as data-center revenue rises",
+ "source": "bloomberg.com",
+ "url": "https://www.bloomberg.com/news/article",
+ }
+ defaults.update(kwargs)
+ return defaults
+
+
+def _fmp_article(**kwargs) -> dict:
+ defaults = {
+ "publishedDate": "2026-06-22 18:42:00",
+ "title": "AMD misses on guidance",
+ "text": "Semiconductor firm issues weak outlook",
+ "site": "barrons.com",
+ "newsURL": "https://www.barrons.com/articles/amd",
+ }
+ defaults.update(kwargs)
+ return defaults
+
+
+def test_get_news_shape(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [_finnhub_article()])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: {
+ "buzz": {"articlesInLastWeek": 12},
+ "sentiment": {"bullishPercent": 0.38, "bearishPercent": 0.40, "neutralPercent": 0.22},
+ })
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert result["provider"] == "finnhub"
+ assert len(result["items"]) == 1
+ item = result["items"][0]
+ assert item["id"] == "6aa272d2b9f4f7f4"
+ assert item["title"] == "AMD surges on strong demand"
+ assert item["summary"] == "Chipmaker gains as data-center revenue rises"
+ assert item["source"] == "bloomberg"
+ assert item["url"] == "https://www.bloomberg.com/news/article"
+ assert item["sentiment"] == "bullish"
+ assert item["published_at"] == "2026-06-22T18:42:00Z"
+ assert result["aggregate"] == {
+ "buzz_articles_last_week": 12,
+ "bullish_pct": 0.38,
+ "bearish_pct": 0.40,
+ "neutral_pct": 0.22,
+ }
+ assert result["fetched_at"]
+
+
+def test_get_news_fmp_fallback_when_finnhub_empty(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [_fmp_article()])
+
+ result = news_service.get_news("AMD")
+
+ assert result["provider"] == "fmp"
+ assert len(result["items"]) == 1
+ item = result["items"][0]
+ assert item["title"] == "AMD misses on guidance"
+ assert item["summary"] == "Semiconductor firm issues weak outlook"
+ assert item["source"] == "barrons"
+ assert item["url"] == "https://www.barrons.com/articles/amd"
+ assert item["sentiment"] == "bearish"
+ assert item["published_at"] == "2026-06-22T18:42:00Z"
+ assert result["aggregate"] is None
+
+
+def test_get_news_drops_unsafe_urls(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [
+ _finnhub_article(url="javascript:alert(1)"),
+ _finnhub_article(url="data:text/html,<script>alert(1)</script>"),
+ _finnhub_article(url="https://safe.example.com/news"),
+ ])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert len(result["items"]) == 1
+ assert result["items"][0]["url"] == "https://safe.example.com/news"
+
+
+def test_get_news_both_upstream_empty(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert result["items"] == []
+ assert result["aggregate"] is None
+ assert result["provider"] is None
+ assert result["fetched_at"]
+
+
+def test_get_news_aggregate_failure_still_returns_items(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [_finnhub_article()])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert len(result["items"]) == 1
+ assert result["aggregate"] is None
+
+
+def test_get_news_sentiment_classifications(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [
+ _finnhub_article(headline="AMD surges on strong demand", summary="growth beats"),
+ _finnhub_article(headline="AMD misses and drops", summary="weak warning"),
+ _finnhub_article(headline="AMD reports quarterly results", summary="revenue flat"),
+ ])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+ sentiments = [i["sentiment"] for i in result["items"]]
+
+ assert "bullish" in sentiments
+ assert "bearish" in sentiments
+ assert "neutral" in sentiments
+
+
+def test_get_news_caches_result(monkeypatch) -> None:
+ _clear_news_cache()
+ call_count = {"finnhub": 0}
+
+ def _finnhub(symbol: str) -> list[dict]:
+ call_count["finnhub"] += 1
+ return [_finnhub_article()]
+
+ monkeypatch.setattr(news_service, "_finnhub_company_news", _finnhub)
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ news_service.get_news("AMD")
+ news_service.get_news("AMD")
+
+ assert call_count["finnhub"] == 1
+
+
+def test_get_news_date_parsing_variants(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [
+ _fmp_article(publishedDate="2026-06-21 09:15:30"),
+ ])
+
+ result = news_service.get_news("AMD")
+
+ assert result["items"][0]["published_at"] == "2026-06-21T09:15:30Z"
+
+
+def test_get_news_trims_and_cleans_source(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [
+ _finnhub_article(source=" WWW.REUTERS.COM ", url="https://www.reuters.com/x"),
+ ])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert result["items"][0]["source"] == "reuters"
+
+
+def test_get_news_capped_at_100(monkeypatch) -> None:
+ _clear_news_cache()
+ monkeypatch.setattr(news_service, "_finnhub_company_news", lambda symbol: [_finnhub_article(headline=f"Article {i}") for i in range(150)])
+ monkeypatch.setattr(news_service, "_finnhub_news_sentiment", lambda symbol: None)
+ monkeypatch.setattr(news_service, "_fmp_stock_news", lambda symbol: [])
+
+ result = news_service.get_news("AMD")
+
+ assert len(result["items"]) == 100