TradingAgents/tests/domains/news/test_news_service.py

337 lines
13 KiB
Python

"""
Test suite for NewsService following pragmatic outside-in TDD methodology.
This test suite follows the CLAUDE.md testing principles:
- Mock I/O boundaries (Repository calls, HTTP clients, external systems)
- Real objects for logic (Data transformations, validation, business logic)
- Outside-in but practical - Start with service tests, work inward
"""
from datetime import date
from unittest.mock import Mock
import pytest
# Import mock ScrapeResult from conftest to avoid newspaper3k import issues
from conftest import ScrapeResult
from tradingagents.domains.news.news_repository import (
NewsData,
)
from tradingagents.domains.news.news_service import (
ArticleData,
NewsContext,
NewsService,
NewsUpdateResult,
SentimentScore,
)
class TestNewsServiceCollaboratorInteractions:
"""Test NewsService interactions with its collaborators (I/O boundaries)."""
def test_get_company_news_context_calls_repository_with_correct_params(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test that get_company_news_context calls repository with correct parameters."""
# Arrange - Mock the I/O boundary
mock_repository.get_news_data.return_value = {}
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act - Call the service method
result = service.get_company_news_context("AAPL", "2024-01-01", "2024-01-31")
# Assert - Repository should be called with converted date objects
mock_repository.get_news_data.assert_called_once_with(
query="AAPL",
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
sources=["finnhub", "google_news"],
)
# Assert - Result should have correct structure (real object logic)
assert isinstance(result, NewsContext)
assert result.query == "AAPL"
assert result.symbol == "AAPL"
assert result.period == {"start": "2024-01-01", "end": "2024-01-31"}
def test_get_global_news_context_calls_repository_for_each_category(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test that get_global_news_context calls repository for each category."""
# Arrange - Mock the I/O boundary
mock_repository.get_news_data.return_value = {}
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
categories = ["business", "politics", "technology"]
# Act
service.get_global_news_context(
"2024-01-01", "2024-01-31", categories=categories
)
# Assert - Repository should be called once for each category
assert mock_repository.get_news_data.call_count == 3
for call_args in mock_repository.get_news_data.call_args_list:
args, kwargs = call_args
assert args[0] in categories # query should be one of the categories
assert args[1] == date(2024, 1, 1) # start_date
assert args[2] == date(2024, 1, 31) # end_date
assert kwargs["sources"] == ["google_news"]
def test_update_company_news_calls_google_client(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test that update_company_news calls GoogleNewsClient correctly."""
# Arrange - Mock the I/O boundary
mock_google_client.get_company_news.return_value = []
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act
result = service.update_company_news("AAPL")
# Assert - Google client should be called
mock_google_client.get_company_news.assert_called_once_with("AAPL")
assert isinstance(result, NewsUpdateResult)
assert result.symbol == "AAPL"
assert result.articles_found == 0
def test_update_company_news_scrapes_each_article_url(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
sample_google_articles,
):
"""Test that update_company_news calls scraper for each article URL."""
# Arrange - Mock I/O boundaries with real data objects
mock_google_client.get_company_news.return_value = sample_google_articles
mock_article_scraper.scrape_article.return_value = ScrapeResult(
status="SUCCESS",
content="Full article content",
author="Test Author",
title="Test Title",
publish_date="2024-01-15",
)
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act
result = service.update_company_news("AAPL")
# Assert - Scraper should be called for each article
assert mock_article_scraper.scrape_article.call_count == 2
mock_article_scraper.scrape_article.assert_any_call(
"https://example.com/apple-soars"
)
mock_article_scraper.scrape_article.assert_any_call(
"https://example.com/apple-products"
)
# Assert - Real object logic for result
assert result.articles_found == 2
assert result.articles_scraped == 2
assert result.articles_failed == 0
def test_repository_failure_returns_empty_context_with_error_metadata(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test that repository failure is handled gracefully."""
# Arrange - Mock repository failure (I/O boundary)
mock_repository.get_news_data.side_effect = Exception(
"Database connection failed"
)
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act
result = service.get_company_news_context("AAPL", "2024-01-01", "2024-01-31")
# Assert - Should return empty context with error metadata (real object logic)
assert isinstance(result, NewsContext)
assert result.articles == []
assert result.article_count == 0
assert "error" in result.metadata
assert "Database connection failed" in result.metadata["error"]
class TestNewsServiceDataTransformations:
"""Test data transformations using real objects (no mocking)."""
def test_converts_repository_articles_to_article_data(
self, mock_google_client, mock_article_scraper, sample_news_articles
):
"""Test conversion of NewsRepository.NewsArticle to ArticleData."""
# Arrange - Create real repository with sample data
mock_repo = Mock()
news_data = NewsData(
query="AAPL",
date=date(2024, 1, 15),
source="finnhub",
articles=sample_news_articles,
)
mock_repo.get_news_data.return_value = {date(2024, 1, 15): [news_data]}
service = NewsService(mock_google_client, mock_repo, mock_article_scraper)
# Act - Test real data transformation logic
result = service.get_company_news_context("AAPL", "2024-01-01", "2024-01-31")
# Assert - Real object data transformation
assert len(result.articles) == 2
assert result.articles[0].title == "Apple Stock Rises 5% on Strong Earnings"
assert (
result.articles[0].content
== "Apple reports strong quarterly earnings beating expectations"
)
assert result.articles[0].date == "2024-01-15"
assert result.articles[0].source == "CNBC"
assert result.articles[0].url == "https://example.com/apple-earnings"
def test_calculates_sentiment_summary_from_articles(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test sentiment summary calculation from article list."""
# Arrange - Create articles with sentiment-bearing content (real objects)
articles = [
ArticleData(
title="Great News for Apple",
content="Apple stock is performing excellent with strong growth and positive outlook",
author="Analyst",
source="CNBC",
date="2024-01-15",
url="https://example.com/positive",
),
ArticleData(
title="Apple Faces Challenges",
content="Apple stock is declining due to bad earnings and negative market sentiment",
author="Reporter",
source="Reuters",
date="2024-01-16",
url="https://example.com/negative",
),
]
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act - Test real sentiment calculation logic (private method)
sentiment = service._calculate_sentiment_summary(articles)
# Assert - Real sentiment calculation
assert isinstance(sentiment, SentimentScore)
assert -1.0 <= sentiment.score <= 1.0
assert 0.0 <= sentiment.confidence <= 1.0
assert sentiment.label in ["positive", "negative", "neutral"]
def test_extracts_trending_topics_from_articles(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test trending topic extraction."""
# Arrange - Create articles with repeated keywords (real objects)
articles = [
ArticleData(
title="Apple iPhone Sales Surge",
content="Content about iPhone",
author="Reporter",
source="TechNews",
date="2024-01-15",
url="https://example.com/iphone1",
),
ArticleData(
title="iPhone Market Share Growth",
content="More iPhone content",
author="Analyst",
source="MarketWatch",
date="2024-01-16",
url="https://example.com/iphone2",
),
ArticleData(
title="Apple Revenue from Services",
content="Services revenue content",
author="Finance Writer",
source="Bloomberg",
date="2024-01-17",
url="https://example.com/services",
),
]
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act - Test real trending topic extraction logic
topics = service._extract_trending_topics(articles)
# Assert - Should identify repeated keywords
assert isinstance(topics, list)
assert "iphone" in topics # Should appear twice
assert "apple" in topics # Should appear multiple times
class TestNewsServiceErrorScenarios:
"""Test various error scenarios and edge cases."""
def test_handles_google_client_failure(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test handling of GoogleNewsClient failure."""
# Arrange - Mock client failure (I/O boundary)
mock_google_client.get_company_news.side_effect = Exception(
"API rate limit exceeded"
)
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act & Assert - Should raise the exception
with pytest.raises(Exception, match="API rate limit exceeded"):
service.update_company_news("AAPL")
def test_handles_article_scraper_failure(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
sample_google_articles,
):
"""Test handling of article scraper failure."""
# Arrange - Mock scraper returning failure status
mock_google_client.get_company_news.return_value = sample_google_articles
mock_article_scraper.scrape_article.return_value = ScrapeResult(
status="SCRAPE_FAILED", content="", author="", title="", publish_date=""
)
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act
result = service.update_company_news("AAPL")
# Assert - Should handle scraper failures gracefully
assert result.articles_found == 2
assert result.articles_scraped == 0
assert result.articles_failed == 2
def test_handles_invalid_date_formats(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test validation of date formats."""
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act & Assert - Should raise ValueError for invalid date format
with pytest.raises(ValueError):
service.get_company_news_context("AAPL", "invalid-date", "2024-01-31")
def test_handles_empty_articles_gracefully(
self, mock_repository, mock_google_client, mock_article_scraper
):
"""Test handling of empty article list."""
service = NewsService(mock_google_client, mock_repository, mock_article_scraper)
# Act - Test sentiment calculation with empty list
sentiment = service._calculate_sentiment_summary([])
# Assert - Should return neutral sentiment
assert sentiment.score == 0.0
assert sentiment.confidence == 0.0
assert sentiment.label == "neutral"