major update
This commit is contained in:
162
tests/unit/test_openai_sentiment_provider.py
Normal file
162
tests/unit/test_openai_sentiment_provider.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user