"""Unit tests for OpenAISentimentProvider reasoning and citations extraction.""" from __future__ import annotations from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.providers.openai_sentiment import OpenAISentimentProvider def _make_annotation(ann_type: str, url: str = "", title: str = "") -> SimpleNamespace: return SimpleNamespace(type=ann_type, url=url, title=title) def _make_content_block(text: str, annotations: list | None = None) -> SimpleNamespace: block = SimpleNamespace(text=text, annotations=annotations or []) return block def _make_message_item(content_blocks: list) -> SimpleNamespace: return SimpleNamespace(type="message", content=content_blocks) def _make_web_search_item() -> SimpleNamespace: return SimpleNamespace(type="web_search_call") def _build_response(json_text: str, annotations: list | None = None) -> SimpleNamespace: """Build a mock OpenAI Responses API response object.""" content_block = _make_content_block(json_text, annotations or []) message_item = _make_message_item([content_block]) items = [message_item] return SimpleNamespace(output=items) def _build_response_with_search( json_text: str, annotations: list | None = None ) -> SimpleNamespace: """Build a response with a web_search_call item followed by a message with annotations.""" search_item = _make_web_search_item() content_block = _make_content_block(json_text, annotations or []) message_item = _make_message_item([content_block]) return SimpleNamespace(output=[search_item, message_item]) @pytest.fixture def provider(): """Create an OpenAISentimentProvider with a mocked client.""" with patch("app.providers.openai_sentiment.AsyncOpenAI"): p = OpenAISentimentProvider(api_key="test-key") return p class TestReasoningExtraction: """Tests for extracting reasoning from the parsed JSON response.""" @pytest.mark.asyncio async def test_reasoning_extracted_from_json(self, provider): json_text = '{"classification": "bullish", "confidence": 85, "reasoning": "Strong earnings report"}' mock_response = _build_response(json_text) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("AAPL") assert result.reasoning == "Strong earnings report" @pytest.mark.asyncio async def test_empty_reasoning_when_field_missing(self, provider): json_text = '{"classification": "neutral", "confidence": 50}' mock_response = _build_response(json_text) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("MSFT") assert result.reasoning == "" @pytest.mark.asyncio async def test_empty_reasoning_when_field_is_empty_string(self, provider): json_text = '{"classification": "bearish", "confidence": 70, "reasoning": ""}' mock_response = _build_response(json_text) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("TSLA") assert result.reasoning == "" class TestCitationsExtraction: """Tests for extracting url_citation annotations from the response.""" @pytest.mark.asyncio async def test_citations_extracted_from_annotations(self, provider): json_text = '{"classification": "bullish", "confidence": 90, "reasoning": "Good news"}' annotations = [ _make_annotation("url_citation", url="https://example.com/1", title="Article 1"), _make_annotation("url_citation", url="https://example.com/2", title="Article 2"), ] mock_response = _build_response(json_text, annotations) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("AAPL") assert len(result.citations) == 2 assert result.citations[0] == {"url": "https://example.com/1", "title": "Article 1"} assert result.citations[1] == {"url": "https://example.com/2", "title": "Article 2"} @pytest.mark.asyncio async def test_empty_citations_when_no_annotations(self, provider): json_text = '{"classification": "neutral", "confidence": 50, "reasoning": "No news"}' mock_response = _build_response(json_text) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("GOOG") assert result.citations == [] @pytest.mark.asyncio async def test_non_url_citation_annotations_ignored(self, provider): json_text = '{"classification": "bearish", "confidence": 60, "reasoning": "Mixed signals"}' annotations = [ _make_annotation("file_citation", url="https://file.com", title="File"), _make_annotation("url_citation", url="https://real.com", title="Real"), ] mock_response = _build_response(json_text, annotations) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("META") assert len(result.citations) == 1 assert result.citations[0] == {"url": "https://real.com", "title": "Real"} @pytest.mark.asyncio async def test_citations_from_response_with_web_search_call(self, provider): json_text = '{"classification": "bullish", "confidence": 80, "reasoning": "Positive outlook"}' annotations = [ _make_annotation("url_citation", url="https://news.com/a", title="News A"), ] mock_response = _build_response_with_search(json_text, annotations) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("NVDA") assert len(result.citations) == 1 assert result.citations[0] == {"url": "https://news.com/a", "title": "News A"} @pytest.mark.asyncio async def test_no_error_when_annotations_attr_missing(self, provider): """Content blocks without annotations attribute should not cause errors.""" json_text = '{"classification": "neutral", "confidence": 50, "reasoning": "Quiet day"}' # Block with no annotations attribute at all block = SimpleNamespace(text=json_text) message_item = SimpleNamespace(type="message", content=[block]) mock_response = SimpleNamespace(output=[message_item]) provider._client.responses.create = AsyncMock(return_value=mock_response) result = await provider.fetch_sentiment("AMD") assert result.citations == [] assert result.reasoning == "Quiet day"