trading/tests/services/meet_kevin_watcher/test_rss_poller.py

144 lines
5.2 KiB
Python

"""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""