144 lines
5.2 KiB
Python
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""
|