trading/tests/services/meet_kevin_watcher/test_rss_poller.py

145 lines
5.2 KiB
Python
Raw Normal View History

"""Tests for RSS feed polling."""
from datetime import datetime, timezone
from pathlib import Path
import httpx
import pytest
from unittest.mock import AsyncMock
from services.meet_kevin_watcher.rss_poller import DiscoveredVideo, parse_feed, fetch_feed
@pytest.fixture
def fixture_xml() -> bytes:
"""Load real YouTube RSS feed fixture."""
fixture_path = Path(__file__).parent.parent.parent / "fixtures" / "meet_kevin_rss.xml"
return fixture_path.read_bytes()
class TestParseFeed:
"""Test parse_feed with various inputs."""
def test_parse_feed_real_fixture(self, fixture_xml: bytes):
"""parse_feed returns videos from real YouTube RSS feed."""
videos = parse_feed(fixture_xml)
# Should return multiple videos (fixture has 15 entries)
assert len(videos) >= 1
assert isinstance(videos, list)
# Each video should have correct structure
video = videos[0]
assert isinstance(video, DiscoveredVideo)
assert len(video.youtube_video_id) == 11
assert isinstance(video.title, str) and len(video.title) > 0
assert isinstance(video.description, str)
assert isinstance(video.published_at, datetime)
assert video.published_at.tzinfo is not None
assert video.thumbnail_url.startswith("https://")
def test_parse_feed_empty_input(self):
"""parse_feed returns empty list on empty input."""
result = parse_feed(b"")
assert result == []
def test_parse_feed_invalid_xml(self):
"""parse_feed returns empty list on malformed XML."""
result = parse_feed(b"<?xml version='1.0'?><not-a-feed/>")
assert result == []
def test_parse_feed_missing_fields(self):
"""parse_feed skips entries with missing required fields."""
# Valid feed header but entry missing video_id
xml = b"""<?xml version="1.0"?>
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Video without ID</title>
<published>2026-05-21T15:22:13+00:00</published>
<media:group>
<media:description>Test</media:description>
<media:thumbnail url="https://example.com/img.jpg"/>
</media:group>
</entry>
</feed>"""
result = parse_feed(xml)
assert result == []
def test_discovered_video_is_frozen(self):
"""DiscoveredVideo is frozen (immutable)."""
video = DiscoveredVideo(
youtube_video_id="abc123abc12",
title="Test",
description="Test desc",
published_at=datetime(2026, 5, 21, tzinfo=timezone.utc),
thumbnail_url="https://example.com/img.jpg",
)
# Should be hashable (frozen=True)
assert hash(video) is not None
# Should not be mutable
with pytest.raises(AttributeError):
video.title = "Changed" # type: ignore
class TestFetchFeed:
"""Test fetch_feed with HTTP client."""
@pytest.mark.asyncio
async def test_fetch_feed_success(self):
"""fetch_feed returns bytes on successful HTTP GET."""
mock_response = AsyncMock()
mock_response.content = b"<xml>data</xml>"
# raise_for_status is synchronous, so don't mock it as async
mock_response.raise_for_status = lambda: None
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = mock_response
result = await fetch_feed("UCUvvj5lwue7PspotMDjk5UA", mock_client)
assert result == b"<xml>data</xml>"
mock_client.get.assert_called_once()
# Check timeout is set to 15.0
call_kwargs = mock_client.get.call_args[1]
assert call_kwargs["timeout"] == 15.0
@pytest.mark.asyncio
async def test_fetch_feed_http_error(self):
"""fetch_feed returns empty bytes on HTTP error."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.side_effect = httpx.HTTPError("Connection failed")
result = await fetch_feed("UCUvvj5lwue7PspotMDjk5UA", mock_client)
assert result == b""
@pytest.mark.asyncio
async def test_fetch_feed_uses_correct_url(self):
"""fetch_feed constructs correct YouTube RSS feed URL."""
mock_response = AsyncMock()
mock_response.content = b"<xml/>"
# raise_for_status is synchronous, so don't mock it as async
mock_response.raise_for_status = lambda: None
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = mock_response
await fetch_feed("UCUvvj5lwue7PspotMDjk5UA", mock_client)
# Verify URL was constructed correctly
call_args = mock_client.get.call_args[0]
assert "https://www.youtube.com/feeds/videos.xml" in call_args[0]
assert "channel_id=UCUvvj5lwue7PspotMDjk5UA" in call_args[0]
@pytest.mark.asyncio
async def test_fetch_feed_timeout_error(self):
"""fetch_feed returns empty bytes on timeout."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.side_effect = httpx.TimeoutException("Timeout")
result = await fetch_feed("UCUvvj5lwue7PspotMDjk5UA", mock_client)
assert result == b""