"""Tests for fetcher.py — mocks YouTube Data API v3 + RSS.""" import pytest import os import json import aiosqlite import fetcher from unittest.mock import patch, AsyncMock, MagicMock def _clean_rotation_file(): """Remove rotation state file if it exists.""" path = getattr(fetcher, "_ROTATION_FILE", None) if path and os.path.exists(path): os.remove(path) @pytest.fixture(autouse=True) def auto_clean_rotation(): _clean_rotation_file() yield _clean_rotation_file() def _mock_youtube_build(sub_channels=None, video_data=None): """Build a mock YouTube API service that returns canned responses.""" sub_channels = sub_channels or [ {"snippet": {"resourceId": {"channelId": "chan001"}}}, {"snippet": {"resourceId": {"channelId": "chan002"}}}, ] video_data = video_data or {} def mock_subscriptions_list(**kwargs): return MagicMock(execute=MagicMock(return_value={"items": sub_channels})) def mock_videos_list(**kwargs): vid_str = kwargs.get("id", "") ids = [v for v in vid_str.split(",") if v] items = [] for vid in ids: data = video_data.get(vid, { "title": f"Video {vid}", "channelTitle": "Test Channel", "channelId": "chan001", "thumbnails": {"high": {"url": f"https://img.youtube.com/vi/{vid}/hqdefault.jpg"}}, "publishTime": "2026-06-01T00:00:00Z", }) items.append({"id": vid, "snippet": data}) return MagicMock(execute=MagicMock(return_value={"items": items})) svc = MagicMock() svc.subscriptions = MagicMock(return_value=MagicMock(list=mock_subscriptions_list, list_next=MagicMock(return_value=None))) svc.videos = MagicMock(return_value=MagicMock(list=mock_videos_list)) return svc def _mock_rss_response(videos): """Build RSS XML string for a list of (video_id, title) tuples.""" entries = [] for vid, title in videos: entries.append(f""" yt:video:{vid} {title} """) return f""" {''.join(entries)} """ @pytest.mark.asyncio async def test_fetch_zero_channels(test_db): """When no subscriptions exist, should return 0.""" mock_svc = _mock_youtube_build(sub_channels=[]) with patch("fetcher.build", return_value=mock_svc), \ patch("fetcher.get_credentials", return_value="mock_creds"): count = await fetcher.fetch_subscriptions() assert count == 0 @pytest.mark.asyncio async def test_fetch_rss_empty(test_db): """When RSS returns nothing, count should be 0.""" mock_svc = _mock_youtube_build() with patch("fetcher.build", return_value=mock_svc), \ patch("fetcher.get_credentials", return_value="mock_creds"), \ patch("httpx.AsyncClient.get", AsyncMock(return_value=MagicMock(status_code=404))): count = await fetcher.fetch_subscriptions() assert count == 0 @pytest.mark.asyncio async def test_fetch_new_videos(test_db): """New videos from RSS should be inserted into DB.""" mock_svc = _mock_youtube_build(video_data={ "vid001": {"title": "First Video", "channelTitle": "Chan One", "channelId": "chan001", "thumbnails": {"high": {"url": ""}}, "publishTime": "2026-06-01T00:00:00Z"}, "vid002": {"title": "Second Video", "channelTitle": "Chan Two", "channelId": "chan002", "thumbnails": {"high": {"url": ""}}, "publishTime": "2026-06-02T00:00:00Z"}, }) rss_xml = _mock_rss_response([("vid001", "First Video"), ("vid002", "Second Video")]) mock_response = MagicMock(status_code=200, text=rss_xml) with patch("fetcher.build", return_value=mock_svc), \ patch("fetcher.get_credentials", return_value="mock_creds"), \ patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)): count = await fetcher.fetch_subscriptions() assert count == 2 # Verify DB cursor = await test_db.execute("SELECT * FROM videos ORDER BY video_id") rows = await cursor.fetchall() assert len(rows) == 2 assert rows[0]["video_id"] == "vid001" assert rows[0]["title"] == "First Video" assert rows[1]["video_id"] == "vid002" assert rows[1]["title"] == "Second Video" @pytest.mark.asyncio async def test_fetch_skip_duplicates(test_db): """Existing videos should be skipped.""" # Pre-insert one video await test_db.execute( "INSERT INTO videos (video_id, title, url, status) VALUES ('vid001', 'Old', 'https://youtube.com/watch?v=vid001', 'pending')" ) await test_db.commit() mock_svc = _mock_youtube_build(video_data={ "vid001": {"title": "First Video (updated)", "channelTitle": "Chan One", "channelId": "chan001"}, "vid002": {"title": "Second Video", "channelTitle": "Chan Two", "channelId": "chan002"}, }) rss_xml = _mock_rss_response([("vid001", "First Video"), ("vid002", "Second Video")]) mock_response = MagicMock(status_code=200, text=rss_xml) with patch("fetcher.build", return_value=mock_svc), \ patch("fetcher.get_credentials", return_value="mock_creds"), \ patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)): count = await fetcher.fetch_subscriptions() assert count == 1 # Only vid002 is new # Verify DB — vid001 still has original title (not updated) cursor = await test_db.execute("SELECT title FROM videos WHERE video_id='vid001'") row = await cursor.fetchone() assert row["title"] == "Old" @pytest.mark.asyncio async def test_rotation_advances(test_db): """Rotation slice should advance after each sync.""" # Mock 150 channels sub_channels = [{"snippet": {"resourceId": {"channelId": f"chan{i:03d}"}}} for i in range(150)] mock_svc = _mock_youtube_build(sub_channels=sub_channels) rss_xml = _mock_rss_response([]) # No videos, just test rotation mock_response = MagicMock(status_code=200, text=rss_xml) with patch("fetcher.build", return_value=mock_svc), \ patch("fetcher.get_credentials", return_value="mock_creds"), \ patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)): # First sync — should process chan000-chan049 count = await fetcher.fetch_subscriptions() # Second sync — chan050-chan099 count2 = await fetcher.fetch_subscriptions() # Third sync — chan100-chan149 count3 = await fetcher.fetch_subscriptions() # Fourth sync — wrap around, chan000-chan049 again count4 = await fetcher.fetch_subscriptions() # Verify rotation file with open(fetcher._ROTATION_FILE) as f: state = json.load(f) assert state["offset"] == 50 # After 4th sync, offset = 50 (wrapped from 150) assert state["total"] == 150 @pytest.mark.asyncio async def test_fetch_quietly_handles_missing_credentials(test_db): """When OAuth is missing, should return 0 gracefully.""" with patch("fetcher.get_credentials", side_effect=FileNotFoundError("No client_secret")): count = await fetcher.fetch_subscriptions() assert count == 0