"""Tests for the YouTube channel follow plugin.""" import asyncio import importlib.util import sys from pathlib import Path from unittest.mock import patch from derp.irc import Message # plugins/ is not a Python package -- load the module from file path _spec = importlib.util.spec_from_file_location( "plugins.youtube", Path(__file__).resolve().parent.parent / "plugins" / "youtube.py", ) _mod = importlib.util.module_from_spec(_spec) sys.modules[_spec.name] = _mod _spec.loader.exec_module(_mod) from plugins.youtube import ( # noqa: E402 _MAX_ANNOUNCE, _compact_num, _delete, _derive_name, _extract_channel_id, _format_duration, _is_youtube_url, _load, _parse_feed, _poll_once, _ps, _restore, _save, _start_poller, _state_key, _stop_poller, _truncate, _validate_name, cmd_yt, on_connect, ) # -- Fixtures ---------------------------------------------------------------- YT_ATOM_FEED = b"""\ 3Blue1Brown - Videos 3Blue1Brown yt:video:abc123 abc123 Linear Algebra 2026-01-15T12:00:00+00:00 yt:video:def456 def456 Calculus 2026-02-01T08:30:00+00:00 yt:video:ghi789 ghi789 Neural Networks 2026-02-10T14:00:00+00:00 """ YT_ATOM_NO_VIDEOID = b"""\ Test Channel Test Channel yt:video:xyz Fallback Link """ YT_ATOM_EMPTY = b"""\ Empty Channel Empty Channel """ FAKE_YT_PAGE_BROWSE = b"""\ """ FAKE_YT_PAGE_CHANNELID = b"""\ """ FAKE_YT_PAGE_NO_ID = b"""\ No channel here """ # -- Helpers ----------------------------------------------------------------- class _FakeState: """In-memory stand-in for bot.state.""" def __init__(self): self._store: dict[str, dict[str, str]] = {} def get(self, plugin: str, key: str, default: str | None = None) -> str | None: return self._store.get(plugin, {}).get(key, default) def set(self, plugin: str, key: str, value: str) -> None: self._store.setdefault(plugin, {})[key] = value def delete(self, plugin: str, key: str) -> bool: try: del self._store[plugin][key] return True except KeyError: return False def keys(self, plugin: str) -> list[str]: return sorted(self._store.get(plugin, {}).keys()) class _FakeBot: """Minimal bot stand-in that captures sent/replied messages.""" def __init__(self, *, admin: bool = False): self.sent: list[tuple[str, str]] = [] self.replied: list[str] = [] self.state = _FakeState() self._pstate: dict = {} self._admin = admin async def send(self, target: str, text: str) -> None: self.sent.append((target, text)) async def reply(self, message, text: str) -> None: self.replied.append(text) async def shorten_url(self, url: str) -> str: return url def _is_admin(self, message) -> bool: return self._admin def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message: """Create a channel PRIVMSG.""" return Message( raw="", prefix=f"{nick}!~{nick}@host", nick=nick, command="PRIVMSG", params=[target, text], tags={}, ) def _pm(text: str, nick: str = "alice") -> Message: """Create a private PRIVMSG.""" return Message( raw="", prefix=f"{nick}!~{nick}@host", nick=nick, command="PRIVMSG", params=["botname", text], tags={}, ) def _clear() -> None: """No-op -- state is per-bot now, each _FakeBot starts fresh.""" def _fake_fetch_ok(url, etag="", last_modified=""): """Fake fetch that returns YT_ATOM_FEED.""" return { "status": 200, "body": YT_ATOM_FEED, "etag": '"abc"', "last_modified": "Sat, 15 Feb 2026 12:00:00 GMT", "error": "", } def _fake_fetch_error(url, etag="", last_modified=""): """Fake fetch that returns an error.""" return { "status": 0, "body": b"", "etag": "", "last_modified": "", "error": "Connection refused", } def _fake_fetch_304(url, etag="", last_modified=""): """Fake fetch that returns 304 Not Modified.""" return { "status": 304, "body": b"", "etag": etag, "last_modified": last_modified, "error": "", } def _fake_resolve_ok(url): """Fake page scrape returning a channel ID.""" return "UCYO_jab_esuFRV4b17AJtAw" def _fake_resolve_fail(url): """Fake page scrape returning None.""" return None # --------------------------------------------------------------------------- # TestIsYoutubeUrl # --------------------------------------------------------------------------- class TestIsYoutubeUrl: def test_standard(self): assert _is_youtube_url("https://www.youtube.com/watch?v=abc") is True def test_short(self): assert _is_youtube_url("https://youtu.be/abc") is True def test_mobile(self): assert _is_youtube_url("https://m.youtube.com/watch?v=abc") is True def test_no_www(self): assert _is_youtube_url("https://youtube.com/@handle") is True def test_not_youtube(self): assert _is_youtube_url("https://example.com/video") is False def test_invalid(self): assert _is_youtube_url("not a url") is False def test_empty(self): assert _is_youtube_url("") is False # --------------------------------------------------------------------------- # TestExtractChannelId # --------------------------------------------------------------------------- class TestExtractChannelId: def test_direct_channel_url(self): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" assert _extract_channel_id(url) == "UCYO_jab_esuFRV4b17AJtAw" def test_channel_url_with_path(self): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw/videos" assert _extract_channel_id(url) == "UCYO_jab_esuFRV4b17AJtAw" def test_handle_url_no_match(self): url = "https://www.youtube.com/@3blue1brown" assert _extract_channel_id(url) is None def test_video_url_no_match(self): url = "https://www.youtube.com/watch?v=abc123" assert _extract_channel_id(url) is None def test_short_url_no_match(self): url = "https://youtu.be/abc123" assert _extract_channel_id(url) is None # --------------------------------------------------------------------------- # TestDeriveName # --------------------------------------------------------------------------- class TestDeriveName: def test_simple_title(self): assert _derive_name("3Blue1Brown") == "3blue1brown" def test_title_with_spaces(self): assert _derive_name("Linus Tech Tips") == "linus-tech-tips" def test_title_with_special(self): assert _derive_name("Tom Scott!") == "tom-scott" def test_empty_title(self): assert _derive_name("") == "yt" def test_long_title_truncated(self): result = _derive_name("A" * 50) assert len(result) <= 20 def test_only_special_chars(self): assert _derive_name("!!!") == "yt" def test_validates(self): result = _derive_name("Some Channel Name") assert _validate_name(result) # --------------------------------------------------------------------------- # TestTruncate # --------------------------------------------------------------------------- class TestTruncate: def test_short_text_unchanged(self): assert _truncate("hello", 80) == "hello" def test_exact_length_unchanged(self): text = "a" * 80 assert _truncate(text, 80) == text def test_long_text_truncated(self): text = "a" * 100 result = _truncate(text, 80) assert len(result) == 80 assert result.endswith("...") def test_default_max_length(self): text = "a" * 100 result = _truncate(text) assert len(result) == 80 def test_trailing_space_stripped(self): text = "word " * 20 result = _truncate(text, 20) assert not result.endswith(" ...") # --------------------------------------------------------------------------- # TestParseFeed # --------------------------------------------------------------------------- class TestParseFeed: def test_parses_entries(self): channel_name, items = _parse_feed(YT_ATOM_FEED) assert channel_name == "3Blue1Brown" assert len(items) == 3 assert items[0]["id"] == "yt:video:abc123" assert items[0]["title"] == "Linear Algebra" assert items[0]["link"] == "https://www.youtube.com/watch?v=abc123" def test_builds_link_from_videoid(self): _, items = _parse_feed(YT_ATOM_FEED) assert items[1]["link"] == "https://www.youtube.com/watch?v=def456" def test_fallback_link_no_videoid(self): _, items = _parse_feed(YT_ATOM_NO_VIDEOID) assert len(items) == 1 assert items[0]["link"] == "https://www.youtube.com/watch?v=xyz" def test_empty_feed(self): channel_name, items = _parse_feed(YT_ATOM_EMPTY) assert channel_name == "Empty Channel" assert items == [] def test_author_name_preferred(self): channel_name, _ = _parse_feed(YT_ATOM_FEED) assert channel_name == "3Blue1Brown" def test_parses_published_date(self): _, items = _parse_feed(YT_ATOM_FEED) assert items[0]["date"] == "2026-01-15" assert items[1]["date"] == "2026-02-01" assert items[2]["date"] == "2026-02-10" def test_parses_views(self): _, items = _parse_feed(YT_ATOM_FEED) assert items[0]["views"] == 1500000 assert items[1]["views"] == 820000 def test_parses_likes(self): _, items = _parse_feed(YT_ATOM_FEED) assert items[0]["likes"] == 45000 assert items[1]["likes"] == 32000 def test_no_media_defaults_zero(self): _, items = _parse_feed(YT_ATOM_NO_VIDEOID) assert items[0]["views"] == 0 assert items[0]["likes"] == 0 assert items[0]["date"] == "" # --------------------------------------------------------------------------- # TestResolveChannel # --------------------------------------------------------------------------- class TestResolveChannel: def test_browse_id_preferred(self): from plugins.youtube import _PAGE_BROWSE_RE m = _PAGE_BROWSE_RE.search(FAKE_YT_PAGE_BROWSE) assert m is not None assert m.group(1).decode() == "UCYO_jab_esuFRV4b17AJtAw" def test_channelid_fallback(self): from plugins.youtube import _PAGE_CHANNEL_RE m = _PAGE_CHANNEL_RE.search(FAKE_YT_PAGE_CHANNELID) assert m is not None assert m.group(1).decode() == "UCsXVk37bltHxD1rDPwtNM8Q" def test_no_match_in_page(self): from plugins.youtube import _PAGE_BROWSE_RE, _PAGE_CHANNEL_RE assert _PAGE_BROWSE_RE.search(FAKE_YT_PAGE_NO_ID) is None assert _PAGE_CHANNEL_RE.search(FAKE_YT_PAGE_NO_ID) is None # --------------------------------------------------------------------------- # TestStateHelpers # --------------------------------------------------------------------------- class TestStateHelpers: def test_save_and_load(self): bot = _FakeBot() data = {"feed_url": "https://example.com/feed", "name": "test"} _save(bot, "#ch:test", data) loaded = _load(bot, "#ch:test") assert loaded == data def test_load_missing(self): bot = _FakeBot() assert _load(bot, "nonexistent") is None def test_delete(self): bot = _FakeBot() _save(bot, "#ch:test", {"name": "test"}) _delete(bot, "#ch:test") assert _load(bot, "#ch:test") is None def test_state_key(self): assert _state_key("#ops", "3b1b") == "#ops:3b1b" def test_load_invalid_json(self): bot = _FakeBot() bot.state.set("yt", "bad", "not json{{{") assert _load(bot, "bad") is None # --------------------------------------------------------------------------- # TestCmdYtFollow # --------------------------------------------------------------------------- class TestCmdYtFollow: def test_follow_with_channel_url(self): _clear() bot = _FakeBot(admin=True) async def inner(): with ( patch.object(_mod, "_fetch_feed", _fake_fetch_ok), ): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url} 3b1b")) await asyncio.sleep(0) assert len(bot.replied) == 1 assert "Following '3b1b'" in bot.replied[0] assert "3 existing videos" in bot.replied[0] data = _load(bot, "#test:3b1b") assert data is not None assert data["channel_id"] == "UCYO_jab_esuFRV4b17AJtAw" assert data["name"] == "3b1b" assert data["channel"] == "#test" assert len(data["seen"]) == 3 assert "#test:3b1b" in _ps(bot)["pollers"] _stop_poller(bot, "#test:3b1b") await asyncio.sleep(0) asyncio.run(inner()) def test_follow_with_handle_url(self): _clear() bot = _FakeBot(admin=True) async def inner(): with ( patch.object(_mod, "_resolve_channel", _fake_resolve_ok), patch.object(_mod, "_fetch_feed", _fake_fetch_ok), ): await cmd_yt(bot, _msg("!yt follow https://www.youtube.com/@3blue1brown")) await asyncio.sleep(0) assert len(bot.replied) == 1 assert "Following" in bot.replied[0] _clear() asyncio.run(inner()) def test_follow_derives_name(self): _clear() bot = _FakeBot(admin=True) async def inner(): with ( patch.object(_mod, "_fetch_feed", _fake_fetch_ok), ): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url}")) await asyncio.sleep(0) # Name derived from channel title "3Blue1Brown" assert "Following '3blue1brown'" in bot.replied[0] _clear() asyncio.run(inner()) def test_follow_with_video_url_no_scheme(self): _clear() bot = _FakeBot(admin=True) async def inner(): with ( patch.object(_mod, "_resolve_channel", _fake_resolve_ok), patch.object(_mod, "_fetch_feed", _fake_fetch_ok), ): await cmd_yt(bot, _msg("!yt follow youtube.com/watch?v=abc123 test")) await asyncio.sleep(0) assert len(bot.replied) == 1 assert "Following 'test'" in bot.replied[0] data = _load(bot, "#test:test") assert data is not None assert data["channel_id"] == "UCYO_jab_esuFRV4b17AJtAw" _clear() asyncio.run(inner()) def test_follow_requires_admin(self): _clear() bot = _FakeBot(admin=False) asyncio.run(cmd_yt(bot, _msg("!yt follow https://www.youtube.com/@test"))) assert "Permission denied" in bot.replied[0] def test_follow_requires_channel(self): _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_yt(bot, _pm("!yt follow https://www.youtube.com/@test"))) assert "Use this command in a channel" in bot.replied[0] def test_follow_not_youtube_url(self): _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_yt(bot, _msg("!yt follow https://example.com/video"))) assert "Not a YouTube URL" in bot.replied[0] def test_follow_resolve_fails(self): _clear() bot = _FakeBot(admin=True) async def inner(): with patch.object(_mod, "_resolve_channel", _fake_resolve_fail): await cmd_yt(bot, _msg("!yt follow https://www.youtube.com/@nonexistent")) assert "Could not resolve" in bot.replied[0] asyncio.run(inner()) def test_follow_invalid_name(self): _clear() bot = _FakeBot(admin=True) async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url} BAD!")) assert "Invalid name" in bot.replied[0] asyncio.run(inner()) def test_follow_duplicate(self): _clear() bot = _FakeBot(admin=True) async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url} dupe")) await asyncio.sleep(0) bot.replied.clear() with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): url2 = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url2} dupe")) assert "already exists" in bot.replied[0] _clear() asyncio.run(inner()) def test_follow_fetch_error(self): _clear() bot = _FakeBot(admin=True) async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_error): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url}")) assert "Feed fetch failed" in bot.replied[0] asyncio.run(inner()) def test_follow_no_url(self): _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_yt(bot, _msg("!yt follow"))) assert "Usage:" in bot.replied[0] def test_follow_channel_limit(self): _clear() bot = _FakeBot(admin=True) for i in range(20): _save(bot, f"#test:ch{i}", {"name": f"ch{i}", "channel": "#test"}) async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url} overflow")) assert "limit reached" in bot.replied[0] asyncio.run(inner()) def test_follow_prepends_https(self): _clear() bot = _FakeBot(admin=True) async def inner(): with ( patch.object(_mod, "_resolve_channel", _fake_resolve_ok), patch.object(_mod, "_fetch_feed", _fake_fetch_ok), ): await cmd_yt(bot, _msg("!yt follow youtube.com/@test yttest")) await asyncio.sleep(0) data = _load(bot, "#test:yttest") assert data is not None assert data["channel_id"] == "UCYO_jab_esuFRV4b17AJtAw" _clear() asyncio.run(inner()) # --------------------------------------------------------------------------- # TestCmdYtUnfollow # --------------------------------------------------------------------------- class TestCmdYtUnfollow: def test_unfollow_success(self): _clear() bot = _FakeBot(admin=True) async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): url = "https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw" await cmd_yt(bot, _msg(f"!yt follow {url} delfeed")) await asyncio.sleep(0) bot.replied.clear() await cmd_yt(bot, _msg("!yt unfollow delfeed")) assert "Unfollowed 'delfeed'" in bot.replied[0] assert _load(bot, "#test:delfeed") is None assert "#test:delfeed" not in _ps(bot)["pollers"] await asyncio.sleep(0) asyncio.run(inner()) def test_unfollow_requires_admin(self): _clear() bot = _FakeBot(admin=False) asyncio.run(cmd_yt(bot, _msg("!yt unfollow somefeed"))) assert "Permission denied" in bot.replied[0] def test_unfollow_requires_channel(self): _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_yt(bot, _pm("!yt unfollow somefeed"))) assert "Use this command in a channel" in bot.replied[0] def test_unfollow_nonexistent(self): _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_yt(bot, _msg("!yt unfollow nosuchfeed"))) assert "No channel" in bot.replied[0] def test_unfollow_no_name(self): _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_yt(bot, _msg("!yt unfollow"))) assert "Usage:" in bot.replied[0] # --------------------------------------------------------------------------- # TestCmdYtList # --------------------------------------------------------------------------- class TestCmdYtList: def test_list_empty(self): _clear() bot = _FakeBot() asyncio.run(cmd_yt(bot, _msg("!yt list"))) assert "No YouTube channels" in bot.replied[0] def test_list_populated(self): _clear() bot = _FakeBot() _save(bot, "#test:3b1b", { "name": "3b1b", "channel": "#test", "feed_url": "https://yt.com/feed", "last_error": "", }) _save(bot, "#test:veritasium", { "name": "veritasium", "channel": "#test", "feed_url": "https://yt.com/feed2", "last_error": "", }) asyncio.run(cmd_yt(bot, _msg("!yt list"))) assert "YouTube:" in bot.replied[0] assert "3b1b" in bot.replied[0] assert "veritasium" in bot.replied[0] def test_list_shows_error(self): _clear() bot = _FakeBot() _save(bot, "#test:broken", { "name": "broken", "channel": "#test", "feed_url": "https://yt.com/feed", "last_error": "Connection refused", }) asyncio.run(cmd_yt(bot, _msg("!yt list"))) assert "broken (error)" in bot.replied[0] def test_list_requires_channel(self): _clear() bot = _FakeBot() asyncio.run(cmd_yt(bot, _pm("!yt list"))) assert "Use this command in a channel" in bot.replied[0] def test_list_only_this_channel(self): _clear() bot = _FakeBot() _save(bot, "#test:mine", { "name": "mine", "channel": "#test", "feed_url": "https://yt.com/feed", "last_error": "", }) _save(bot, "#other:theirs", { "name": "theirs", "channel": "#other", "feed_url": "https://yt.com/feed2", "last_error": "", }) asyncio.run(cmd_yt(bot, _msg("!yt list"))) assert "mine" in bot.replied[0] assert "theirs" not in bot.replied[0] # --------------------------------------------------------------------------- # TestCmdYtCheck # --------------------------------------------------------------------------- class TestCmdYtCheck: def test_check_success(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "chk", "channel": "#test", "interval": 600, "seen": ["yt:video:abc123", "yt:video:def456", "yt:video:ghi789"], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "Test", "channel_id": "UC123", } _save(bot, "#test:chk", data) async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): await cmd_yt(bot, _msg("!yt check chk")) assert "chk: checked" in bot.replied[0] asyncio.run(inner()) def test_check_nonexistent(self): _clear() bot = _FakeBot() asyncio.run(cmd_yt(bot, _msg("!yt check nope"))) assert "No channel" in bot.replied[0] def test_check_requires_channel(self): _clear() bot = _FakeBot() asyncio.run(cmd_yt(bot, _pm("!yt check something"))) assert "Use this command in a channel" in bot.replied[0] def test_check_shows_error(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "errfeed", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } _save(bot, "#test:errfeed", data) async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_error): await cmd_yt(bot, _msg("!yt check errfeed")) assert "error" in bot.replied[0].lower() asyncio.run(inner()) def test_check_announces_new_items(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "news", "channel": "#test", "interval": 600, "seen": ["yt:video:abc123"], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "Test", "channel_id": "UC123", } _save(bot, "#test:news", data) def fake_duration(video_id): return {"def456": 1105, "ghi789": 62}.get(video_id, 0) async def inner(): with ( patch.object(_mod, "_fetch_feed", _fake_fetch_ok), patch.object(_mod, "_fetch_duration", fake_duration), ): await cmd_yt(bot, _msg("!yt check news")) announcements = [s for t, s in bot.sent if t == "#test"] assert len(announcements) == 2 assert "[news]" in announcements[0] assert "Calculus" in announcements[0] # Verify metadata suffix (duration, views, likes, date) assert "18:25" in announcements[0] assert "820kv" in announcements[0] assert "32klk" in announcements[0] assert "2026-02-01" in announcements[0] # Second announcement has 1:02 duration assert "1:02" in announcements[1] asyncio.run(inner()) def test_check_no_name(self): _clear() bot = _FakeBot() asyncio.run(cmd_yt(bot, _msg("!yt check"))) assert "Usage:" in bot.replied[0] # --------------------------------------------------------------------------- # TestPollOnce # --------------------------------------------------------------------------- class TestPollOnce: def test_poll_304_clears_error(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "f304", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "old err", "etag": '"xyz"', "last_modified": "", "title": "", "channel_id": "UC123", } key = "#test:f304" _save(bot, key, data) _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_304): await _poll_once(bot, key) updated = _load(bot, key) assert updated["last_error"] == "" asyncio.run(inner()) def test_poll_error_increments(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "ferr", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } key = "#test:ferr" _save(bot, key, data) _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_error): await _poll_once(bot, key) await _poll_once(bot, key) assert _ps(bot)["errors"][key] == 2 updated = _load(bot, key) assert updated["last_error"] == "Connection refused" asyncio.run(inner()) def test_poll_max_announce(self): """Only MAX_ANNOUNCE items are individually announced.""" _clear() bot = _FakeBot() entries_xml = "" for i in range(8): entries_xml += f""" yt:video:vid{i} vid{i} Video {i} """ big_feed = f"""\ Big Channel Big Channel {entries_xml} """.encode() def fake_big(url, etag="", lm=""): return {"status": 200, "body": big_feed, "etag": "", "last_modified": "", "error": ""} data = { "feed_url": "https://yt.com/feed", "name": "big", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } key = "#test:big" _save(bot, key, data) _ps(bot)["channels"][key] = data async def inner(): with ( patch.object(_mod, "_fetch_feed", fake_big), patch.object(_mod, "_fetch_duration", lambda vid: 0), ): await _poll_once(bot, key, announce=True) messages = [s for t, s in bot.sent if t == "#test"] # 5 individual + 1 "... and N more" assert len(messages) == _MAX_ANNOUNCE + 1 assert "... and 3 more" in messages[-1] asyncio.run(inner()) def test_poll_no_announce_flag(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "quiet", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } key = "#test:quiet" _save(bot, key, data) _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): await _poll_once(bot, key, announce=False) assert len(bot.sent) == 0 updated = _load(bot, key) assert len(updated["seen"]) == 3 asyncio.run(inner()) def test_poll_updates_etag(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "etag", "channel": "#test", "interval": 600, "seen": ["yt:video:abc123", "yt:video:def456", "yt:video:ghi789"], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } key = "#test:etag" _save(bot, key, data) _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): await _poll_once(bot, key) updated = _load(bot, key) assert updated["etag"] == '"abc"' asyncio.run(inner()) # --------------------------------------------------------------------------- # TestRestore # --------------------------------------------------------------------------- class TestRestore: def test_restore_spawns_pollers(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "restored", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } _save(bot, "#test:restored", data) async def inner(): _restore(bot) assert "#test:restored" in _ps(bot)["pollers"] task = _ps(bot)["pollers"]["#test:restored"] assert not task.done() _stop_poller(bot, "#test:restored") await asyncio.sleep(0) asyncio.run(inner()) def test_restore_skips_active(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "active", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } _save(bot, "#test:active", data) async def inner(): dummy = asyncio.create_task(asyncio.sleep(9999)) _ps(bot)["pollers"]["#test:active"] = dummy _restore(bot) assert _ps(bot)["pollers"]["#test:active"] is dummy dummy.cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_restore_replaces_done_task(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "done", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } _save(bot, "#test:done", data) async def inner(): done_task = asyncio.create_task(asyncio.sleep(0)) await done_task _ps(bot)["pollers"]["#test:done"] = done_task _restore(bot) new_task = _ps(bot)["pollers"]["#test:done"] assert new_task is not done_task assert not new_task.done() _stop_poller(bot, "#test:done") await asyncio.sleep(0) asyncio.run(inner()) def test_restore_skips_bad_json(self): _clear() bot = _FakeBot() bot.state.set("yt", "#test:bad", "not json{{{") async def inner(): _restore(bot) assert "#test:bad" not in _ps(bot)["pollers"] asyncio.run(inner()) def test_on_connect_calls_restore(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "conn", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } _save(bot, "#test:conn", data) async def inner(): msg = _msg("", target="botname") await on_connect(bot, msg) assert "#test:conn" in _ps(bot)["pollers"] _stop_poller(bot, "#test:conn") await asyncio.sleep(0) asyncio.run(inner()) # --------------------------------------------------------------------------- # TestPollerManagement # --------------------------------------------------------------------------- class TestPollerManagement: def test_start_and_stop(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "mgmt", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } key = "#test:mgmt" _save(bot, key, data) _ps(bot)["channels"][key] = data async def inner(): _start_poller(bot, key) ps = _ps(bot) assert key in ps["pollers"] assert not ps["pollers"][key].done() _stop_poller(bot, key) await asyncio.sleep(0) assert key not in ps["pollers"] assert key not in ps["channels"] asyncio.run(inner()) def test_start_idempotent(self): _clear() bot = _FakeBot() data = { "feed_url": "https://yt.com/feed", "name": "idem", "channel": "#test", "interval": 600, "seen": [], "last_poll": "", "last_error": "", "etag": "", "last_modified": "", "title": "", "channel_id": "UC123", } key = "#test:idem" _save(bot, key, data) _ps(bot)["channels"][key] = data async def inner(): _start_poller(bot, key) ps = _ps(bot) first = ps["pollers"][key] _start_poller(bot, key) assert ps["pollers"][key] is first _stop_poller(bot, key) await asyncio.sleep(0) asyncio.run(inner()) def test_stop_nonexistent(self): _clear() bot = _FakeBot() _stop_poller(bot, "#test:nonexistent") # --------------------------------------------------------------------------- # TestCmdYtUsage # --------------------------------------------------------------------------- class TestCmdYtUsage: def test_no_args(self): _clear() bot = _FakeBot() asyncio.run(cmd_yt(bot, _msg("!yt"))) assert "Usage:" in bot.replied[0] def test_unknown_subcommand(self): _clear() bot = _FakeBot() asyncio.run(cmd_yt(bot, _msg("!yt foobar"))) assert "Usage:" in bot.replied[0] # --------------------------------------------------------------------------- # TestCompactNum # --------------------------------------------------------------------------- class TestCompactNum: def test_zero(self): assert _compact_num(0) == "0" def test_small(self): assert _compact_num(999) == "999" def test_one_k(self): assert _compact_num(1000) == "1k" def test_fractional_k(self): assert _compact_num(1500) == "1.5k" def test_one_m(self): assert _compact_num(1_000_000) == "1M" def test_fractional_m(self): assert _compact_num(2_500_000) == "2.5M" # --------------------------------------------------------------------------- # TestFormatDuration # --------------------------------------------------------------------------- class TestFormatDuration: def test_zero(self): assert _format_duration(0) == "" def test_negative(self): assert _format_duration(-1) == "" def test_seconds_only(self): assert _format_duration(45) == "0:45" def test_minutes_and_seconds(self): assert _format_duration(125) == "2:05" def test_exact_minutes(self): assert _format_duration(600) == "10:00" def test_hours(self): assert _format_duration(3661) == "1:01:01" def test_large_hours(self): assert _format_duration(36000) == "10:00:00" def test_one_second(self): assert _format_duration(1) == "0:01"