feat(meet-kevin): RSS poller for YouTube uploads
This commit is contained in:
parent
8edcb070ed
commit
8ce3ede09c
4 changed files with 1237 additions and 0 deletions
0
tests/services/meet_kevin_watcher/__init__.py
Normal file
0
tests/services/meet_kevin_watcher/__init__.py
Normal file
144
tests/services/meet_kevin_watcher/test_rss_poller.py
Normal file
144
tests/services/meet_kevin_watcher/test_rss_poller.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""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""
|
||||
Loading…
Add table
Add a link
Reference in a new issue