TradingAgents/tests/domains/news/test_news_service.py

453 lines
15 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 AsyncMock
import pytest
from tradingagents.domains.news.article_scraper_client import ScrapeResult
from tradingagents.domains.news.news_service import (
ArticleData,
NewsContext,
NewsService,
NewsUpdateResult,
SentimentScore,
)
class TestNewsServiceCollaboratorInteractions:
"""Test NewsService interactions with its collaborators (I/O boundaries)."""
@pytest.mark.asyncio
async def test_get_company_news_context_calls_repository_with_correct_params(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
):
"""Test that get_company_news_context calls repository with correct parameters."""
# Arrange - Mock the I/O boundary
mock_repository.list_by_date_range.return_value = []
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
# Act - Call the service method
result = await service.get_company_news_context(
"AAPL", "2024-01-01", "2024-01-31"
)
# Assert - Repository should be called with converted date objects
mock_repository.list_by_date_range.assert_called_once_with(
symbol="AAPL",
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
)
# 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"}
@pytest.mark.asyncio
async def test_get_global_news_context_calls_repository_for_each_category(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
):
"""Test that get_global_news_context calls repository for each category."""
# Arrange - Mock the I/O boundary
mock_repository.list_by_date_range.return_value = []
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
categories = ["business", "politics", "technology"]
# Act
await 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.list_by_date_range.call_count == 3
for call_args in mock_repository.list_by_date_range.call_args_list:
args, kwargs = call_args
assert (
kwargs["symbol"] in categories
) # symbol should be one of the categories
assert kwargs["start_date"] == date(2024, 1, 1) # start_date
assert kwargs["end_date"] == date(2024, 1, 31) # end_date
@pytest.mark.asyncio
async def test_update_company_news_calls_google_client(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
):
"""Test that update_company_news calls GoogleNewsClient correctly."""
# Arrange - Mock the I/O boundary
mock_google_client.get_company_news.return_value = []
mock_repository.upsert_batch.return_value = []
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
# Act
result = await 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
@pytest.mark.asyncio
async def test_update_company_news_scrapes_each_article_url(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
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",
)
mock_repository.upsert_batch.return_value = []
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
# Act
result = await 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
@pytest.mark.asyncio
async def test_repository_failure_returns_empty_context_gracefully(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
):
"""Test that repository failure is handled gracefully."""
# Arrange - Mock repository failure (I/O boundary)
mock_repository.list_by_date_range.side_effect = Exception(
"Database connection failed"
)
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
# Act
result = await service.get_company_news_context(
"AAPL", "2024-01-01", "2024-01-31"
)
# Assert - Should return empty context gracefully (real object logic)
assert isinstance(result, NewsContext)
assert result.articles == []
assert result.article_count == 0
assert result.metadata["data_source"] == "repository"
# Service gracefully handles repository errors by returning empty results
class TestNewsServiceDataTransformations:
"""Test data transformations using real objects (no mocking)."""
@pytest.mark.asyncio
async def test_converts_repository_articles_to_article_data(
self,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
sample_news_articles,
):
"""Test conversion of NewsRepository.NewsArticle to ArticleData."""
# Arrange - Create real repository with sample data
mock_repo = AsyncMock()
mock_repo.list_by_date_range.return_value = sample_news_articles
service = NewsService(
mock_google_client, mock_repo, mock_article_scraper, mock_openrouter_client
)
# Act - Test real data transformation logic
result = await 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,
mock_openrouter_client,
):
"""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,
mock_openrouter_client,
)
# Act - Test real sentiment calculation logic (private method)
import asyncio
sentiment = asyncio.run(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,
mock_openrouter_client,
):
"""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,
mock_openrouter_client,
)
# 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."""
@pytest.mark.asyncio
async def test_handles_google_client_failure(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
):
"""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,
mock_openrouter_client,
)
# Act & Assert - Should raise the exception
with pytest.raises(Exception, match="API rate limit exceeded"):
await service.update_company_news("AAPL")
@pytest.mark.asyncio
async def test_handles_article_scraper_failure(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
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=""
)
mock_repository.upsert_batch.return_value = []
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
# Act
result = await 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
@pytest.mark.asyncio
async def test_handles_invalid_date_formats(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
):
"""Test validation of date formats."""
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
# Act - Invalid date format should be handled gracefully
result = await service.get_company_news_context(
"AAPL", "invalid-date", "2024-01-31"
)
# Assert - Should return empty context due to date parsing error
assert isinstance(result, NewsContext)
assert result.articles == []
assert result.article_count == 0
def test_handles_empty_articles_gracefully(
self,
mock_repository,
mock_google_client,
mock_article_scraper,
mock_openrouter_client,
):
"""Test handling of empty article list."""
service = NewsService(
mock_google_client,
mock_repository,
mock_article_scraper,
mock_openrouter_client,
)
# Act - Test sentiment calculation with empty list
import asyncio
sentiment = asyncio.run(service._calculate_sentiment_summary([]))
# Assert - Should return neutral sentiment
assert sentiment.score == 0.0
assert sentiment.confidence == 0.0
assert sentiment.label == "neutral"