"""Tests for the feed extension system. Tests cover the ABC base class, plugin discovery, per-feed storage, the ``run_extensions`` integration function, and the built-in example plugin. """ from __future__ import annotations import json import os import tempfile from contextlib import contextmanager from http.server import BaseHTTPRequestHandler from http.server import ThreadingHTTPServer from pathlib import Path from threading import Thread from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest from reader import Reader as ReaderType from reader import make_reader from discord_rss_bot.custom_message import CustomEmbed from discord_rss_bot.custom_message import get_embed from discord_rss_bot.custom_message import replace_tags_in_embed from discord_rss_bot.custom_message import replace_tags_in_text_message from discord_rss_bot.custom_message import save_embed from discord_rss_bot.extensions import FeedExtension from discord_rss_bot.extensions import auto_enable_extensions_for_feed from discord_rss_bot.extensions import discover_plugins from discord_rss_bot.extensions import registry_clear from discord_rss_bot.extensions import run_extensions from discord_rss_bot.extensions.base import FeedExtension as FeedExtensionABC from discord_rss_bot.extensions.hoyolab import HoyolabExtension from discord_rss_bot.extensions.jwplayer_thumbnail import _SLUG_CACHE from discord_rss_bot.extensions.jwplayer_thumbnail import JWPlayerThumbnailExtension from discord_rss_bot.extensions.steam import SteamExtension from discord_rss_bot.extensions.storage import get_enabled_extensions_for_feed from discord_rss_bot.extensions.storage import set_enabled_extensions_for_feed from discord_rss_bot.extensions.wordpress import WordPressExtension from discord_rss_bot.extensions.youtube import YouTubeExtension if TYPE_CHECKING: from collections.abc import Iterator from reader import Entry from reader import Reader from discord_rss_bot.feeds import JsonValue # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _clear_registry() -> Iterator[None]: """Clear the extension registry before and after each test. Yields: Control back to the test body, then cleans up. """ registry_clear() yield registry_clear() @pytest.fixture def temp_extensions_dir() -> Iterator[str]: """Create a temporary directory to act as the extensions directory. Yields: The path to the temporary extensions directory. """ tmpdir: str = tempfile.mkdtemp(prefix="ext-test-") old_env: str | None = os.environ.pop("EXTENSIONS_DIR", None) os.environ["EXTENSIONS_DIR"] = tmpdir yield tmpdir if old_env is not None: os.environ["EXTENSIONS_DIR"] = old_env else: os.environ.pop("EXTENSIONS_DIR", None) @pytest.fixture def mock_reader() -> MagicMock: """A mock Reader with tag storage support. Returns: A MagicMock configured with set_tag/get_tag side effects. """ reader: MagicMock = MagicMock() reader.tags = {} # type: ignore[valid-type] def _resolve_feed_key(feed_or_url: str | SimpleNamespace) -> str: """Extract the feed URL string from either a string or SimpleNamespace. Returns: The URL as a string. """ if isinstance(feed_or_url, str): return feed_or_url url = getattr(feed_or_url, "url", None) if url is not None: return str(url) return str(feed_or_url) def set_tag(feed_or_url: str | SimpleNamespace, key: str, value: JsonValue) -> None: feed_key: str = _resolve_feed_key(feed_or_url) if feed_key not in reader.tags: reader.tags[feed_key] = {} reader.tags[feed_key][key] = value def get_tag(feed_or_url: str | SimpleNamespace, key: str, default: JsonValue = None) -> JsonValue: feed_key: str = _resolve_feed_key(feed_or_url) feed_tags = reader.tags.get(feed_key, {}) return feed_tags.get(key, default) reader.set_tag.side_effect = set_tag reader.get_tag.side_effect = get_tag return reader @pytest.fixture def mock_feed() -> SimpleNamespace: """A feed object with all attributes required by tag replacement. Returns: A SimpleNamespace with feed-like attributes. """ return SimpleNamespace( added=None, author="Feed Author", authors_str="Feed Author", last_exception=None, last_updated=None, link="https://example.com/feed", subtitle="", title="Example Feed", updated=None, updates_enabled=True, url="https://example.com/feed.xml", user_title="", version="atom10", ) @pytest.fixture def mock_entry(mock_feed: SimpleNamespace) -> SimpleNamespace: """An entry object with all attributes required by tag replacement. Returns: A SimpleNamespace with entry-like attributes. """ return SimpleNamespace( added=None, author="Entry Author", authors_str="Entry Author", content=[SimpleNamespace(value="
Hello world
")], feed=mock_feed, feed_url=mock_feed.url, id="entry-1", important=False, link="https://example.com/entry-1", published=None, read=False, read_modified=None, summary="", title="Test Entry", updated=None, ) # --------------------------------------------------------------------------- # Tests: base.py # --------------------------------------------------------------------------- def test_feed_extension_abc_cannot_be_instantiated() -> None: """FeedExtension should be abstract and not instantiable directly.""" with pytest.raises(TypeError): FeedExtensionABC() # type: ignore[abstract] def test_feed_extension_subclass_can_be_instantiated() -> None: """A concrete subclass with ``process_entry`` should work.""" class ConcreteExtension(FeedExtensionABC): name = "test_concrete" def process_entry(self, entry: Entry, reader: Reader) -> dict[str, str]: # ruff:ignore[unused-method-argument] return {"hello": "world"} instance = ConcreteExtension() assert instance.name == "test_concrete" def test_feed_extension_name_defaults_to_empty_string() -> None: """The ``name`` class variable defaults to ``""``.""" class UnnamedExtension(FeedExtensionABC): def process_entry(self, entry: Entry, reader: Reader) -> dict[str, str]: # ruff:ignore[unused-method-argument] return {} assert not UnnamedExtension.name, "Expected name to be empty" # --------------------------------------------------------------------------- # Tests: discovery.py # --------------------------------------------------------------------------- BUILT_IN_EXTENSIONS: frozenset[str] = frozenset({"steam", "youtube", "hoyolab", "jwplayer_thumbnail", "wordpress"}) def _assert_only_built_in_extensions(registry: dict[str, type[FeedExtension]]) -> None: """Assert that *registry* contains exactly the built-in extensions.""" registered: set[str] = set(registry.keys()) unexpected: set[str] = registered - BUILT_IN_EXTENSIONS missing: frozenset[str] = BUILT_IN_EXTENSIONS - registered assert not unexpected, f"Registry contains unexpected extensions: {unexpected}" assert not missing, f"Registry missing built-in extensions: {missing}" def test_discover_plugins_empty_directory(temp_extensions_dir: str) -> None: """An empty external directory still has built-in extensions.""" registry: dict[str, type[FeedExtension]] = discover_plugins(force=True) _assert_only_built_in_extensions(registry) def test_discover_plugins_missing_directory() -> None: """If the external extensions directory doesn't exist, built-ins still load.""" old_env: str | None = os.environ.pop("EXTENSIONS_DIR", None) try: os.environ["EXTENSIONS_DIR"] = str(Path(tempfile.gettempdir()) / "nonexistent-extensions-dir-12345") registry: dict[str, type[FeedExtension]] = discover_plugins(force=True) _assert_only_built_in_extensions(registry) finally: if old_env is not None: os.environ["EXTENSIONS_DIR"] = old_env else: os.environ.pop("EXTENSIONS_DIR", None) def test_discover_plugins_imports_plugin(temp_extensions_dir: str) -> None: """A valid plugin file should be discovered and registered alongside built-ins.""" plugin_code: str = """ from discord_rss_bot.extensions.base import FeedExtension class TestPlugin(FeedExtension): name = "test_plugin" description = "A test plugin." def process_entry(self, entry, reader): return {"test_var": "hello"} """ plugin_path: Path = Path(temp_extensions_dir) / "test_plugin.py" plugin_path.write_text(plugin_code, encoding="utf-8") registry: dict[str, type[FeedExtension]] = discover_plugins(force=True) assert "test_plugin" in registry assert registry["test_plugin"].name == "test_plugin" assert registry["test_plugin"].description == "A test plugin." # Built-in extensions should also be present. for name in BUILT_IN_EXTENSIONS: assert name in registry, f"Missing built-in extension {name}" def test_discover_plugins_skips_broken_plugin(temp_extensions_dir: str) -> None: """A plugin that raises during import should be skipped, not crash.""" plugin_path: Path = Path(temp_extensions_dir) / "broken.py" plugin_path.write_text("raise SyntaxError('bad syntax'", encoding="utf-8") # Should not raise. registry: dict[str, type[FeedExtension]] = discover_plugins(force=True) assert "broken" not in registry # Built-in extensions should still load. _assert_only_built_in_extensions(registry) def test_discover_plugins_skips_init_py(temp_extensions_dir: str) -> None: """__init__.py files in the extensions directory should be ignored.""" init_path: Path = Path(temp_extensions_dir) / "__init__.py" init_path.write_text("# package init", encoding="utf-8") registry: dict[str, type[FeedExtension]] = discover_plugins(force=True) _assert_only_built_in_extensions(registry) def test_discover_plugins_deduplicates_by_name(temp_extensions_dir: str) -> None: """If two plugins define the same name, the last one wins.""" plugin_a: str = """ from discord_rss_bot.extensions.base import FeedExtension class PluginA(FeedExtension): name = "dup_name" def process_entry(self, entry, reader): return {"from": "a"} """ plugin_b: str = """ from discord_rss_bot.extensions.base import FeedExtension class PluginB(FeedExtension): name = "dup_name" def process_entry(self, entry, reader): return {"from": "b"} """ (Path(temp_extensions_dir) / "a.py").write_text(plugin_a) (Path(temp_extensions_dir) / "b.py").write_text(plugin_b) registry: dict[str, type[FeedExtension]] = discover_plugins(force=True) assert "dup_name" in registry # The last one alphabetically (b.py) should win. assert registry["dup_name"].__name__ == "PluginB" # --------------------------------------------------------------------------- # Tests: storage.py # --------------------------------------------------------------------------- def test_get_enabled_extensions_empty_when_no_tag(mock_reader: MagicMock, mock_feed: SimpleNamespace) -> None: """If no extensions tag is set, an empty list is returned.""" enabled: list[str] = get_enabled_extensions_for_feed(mock_reader, mock_feed.url) assert enabled == [] def test_set_and_get_enabled_extensions(mock_reader: MagicMock, mock_feed: SimpleNamespace) -> None: """Setting enabled extensions and retrieving them should round-trip.""" names: list[str] = ["jwplayer_thumbnail", "encode_links"] set_enabled_extensions_for_feed(mock_reader, mock_feed.url, names) enabled: list[str] = get_enabled_extensions_for_feed(mock_reader, mock_feed.url) assert enabled == names def test_set_enabled_extensions_clears_list(mock_reader: MagicMock, mock_feed: SimpleNamespace) -> None: """Setting an empty list should clear the enabled extensions.""" set_enabled_extensions_for_feed(mock_reader, mock_feed.url, ["some_plugin"]) set_enabled_extensions_for_feed(mock_reader, mock_feed.url, []) enabled: list[str] = get_enabled_extensions_for_feed(mock_reader, mock_feed.url) assert enabled == [] def test_get_enabled_extensions_handles_string_tag(mock_reader: MagicMock, mock_feed: SimpleNamespace) -> None: """If the tag is stored as a JSON string, it should still be parsed.""" def json_string_tag(feed_url: str, key: str, default: JsonValue = None) -> str: return json.dumps(["plugin_a", "plugin_b"]) mock_reader.get_tag.side_effect = json_string_tag enabled: list[str] = get_enabled_extensions_for_feed(mock_reader, mock_feed.url) assert enabled == ["plugin_a", "plugin_b"] def test_get_enabled_extensions_handles_empty_string_tag(mock_reader: MagicMock, mock_feed: SimpleNamespace) -> None: """An empty string tag should return an empty list.""" def empty_string_tag(feed_url: str, key: str, default: JsonValue = None) -> str: return "" mock_reader.get_tag.side_effect = empty_string_tag enabled: list[str] = get_enabled_extensions_for_feed(mock_reader, mock_feed.url) assert enabled == [] # --------------------------------------------------------------------------- # Tests: run_extensions (integration) # --------------------------------------------------------------------------- def test_run_extensions_empty_when_none_enabled( mock_reader: MagicMock, mock_entry: SimpleNamespace, mock_feed: SimpleNamespace, ) -> None: """If no extensions are enabled for the feed, the result is empty.""" set_enabled_extensions_for_feed(mock_reader, mock_feed.url, []) result: dict[str, str] = run_extensions(mock_entry, mock_reader) # type: ignore[arg-type] assert result == {} def test_run_extensions_with_missing_plugin( mock_reader: MagicMock, mock_entry: SimpleNamespace, mock_feed: SimpleNamespace, ) -> None: """Enabled extension that doesn't exist in the registry is skipped.""" set_enabled_extensions_for_feed(mock_reader, mock_feed.url, ["nonexistent_plugin"]) result: dict[str, str] = run_extensions(mock_entry, mock_reader) # type: ignore[arg-type] assert result == {} def test_run_extensions_with_registered_plugin( mock_reader: MagicMock, mock_entry: SimpleNamespace, mock_feed: SimpleNamespace, temp_extensions_dir: str, ) -> None: """A registered and enabled extension should produce variables.""" # Register a plugin via discovery. plugin_code: str = """ from discord_rss_bot.extensions.base import FeedExtension class TestPlugin(FeedExtension): name = "test_plugin" def process_entry(self, entry, reader): return {"custom_var": "hello_from_plugin"} """ (Path(temp_extensions_dir) / "my_plugin.py").write_text(plugin_code) discover_plugins(force=True) set_enabled_extensions_for_feed(mock_reader, mock_feed.url, ["test_plugin"]) result: dict[str, str] = run_extensions(mock_entry, mock_reader) # type: ignore[arg-type] assert result == {"custom_var": "hello_from_plugin"} def test_run_extensions_continues_after_plugin_error( mock_reader: MagicMock, mock_entry: SimpleNamespace, mock_feed: SimpleNamespace, temp_extensions_dir: str, ) -> None: """If one plugin raises, others should still run and produce results.""" # Register two plugins: one that raises and one that works. good_code: str = """ from discord_rss_bot.extensions.base import FeedExtension class GoodPlugin(FeedExtension): name = "good_plugin" def process_entry(self, entry, reader): return {"good_var": "ok"} """ bad_code: str = """ from discord_rss_bot.extensions.base import FeedExtension class BadPlugin(FeedExtension): name = "bad_plugin" def process_entry(self, entry, reader): raise RuntimeError("This plugin failed!") """ (Path(temp_extensions_dir) / "good.py").write_text(good_code) (Path(temp_extensions_dir) / "bad.py").write_text(bad_code) discover_plugins(force=True) set_enabled_extensions_for_feed(mock_reader, mock_feed.url, ["bad_plugin", "good_plugin"]) result: dict[str, str] = run_extensions(mock_entry, mock_reader) # type: ignore[arg-type] assert result == {"good_var": "ok"} # --------------------------------------------------------------------------- # Tests: tag replacement integration (custom_message.py) # --------------------------------------------------------------------------- def test_replace_tags_in_embed_uses_extension_variables( mock_reader: MagicMock, mock_feed: SimpleNamespace, mock_entry: SimpleNamespace, temp_extensions_dir: str, ) -> None: """Extension variables should be available in embed tag replacement.""" # Register a test plugin. plugin_code: str = """ from discord_rss_bot.extensions.base import FeedExtension class EmbedVarPlugin(FeedExtension): name = "embed_var" def process_entry(self, entry, reader): return {"custom_thumbnail": "https://example.com/thumb.jpg"} """ (Path(temp_extensions_dir) / "embed_var.py").write_text(plugin_code) discover_plugins(force=True) # Enable the plugin for this feed. set_enabled_extensions_for_feed(mock_reader, mock_feed.url, ["embed_var"]) # Create an embed that uses the extension variable. embed: CustomEmbed = get_embed(mock_reader, mock_feed) # type: ignore[arg-type] embed.image_url = "{{custom_thumbnail}}" save_embed(mock_reader, mock_feed, embed) # type: ignore[arg-type] # Run replacement. result: CustomEmbed = replace_tags_in_embed(mock_feed, mock_entry, mock_reader) # type: ignore[arg-type] assert "https://example.com/thumb.jpg" in result.image_url def test_replace_tags_in_text_message_uses_extension_variables( mock_reader: MagicMock, mock_feed: SimpleNamespace, mock_entry: SimpleNamespace, temp_extensions_dir: str, ) -> None: """Extension variables should be available in text message tag replacement.""" # Register a test plugin. plugin_code: str = """ from discord_rss_bot.extensions.base import FeedExtension class TextVarPlugin(FeedExtension): name = "text_var" def process_entry(self, entry, reader): return {"custom_text": "hello_from_extension"} """ (Path(temp_extensions_dir) / "text_var.py").write_text(plugin_code) discover_plugins(force=True) # Enable the plugin for this feed. set_enabled_extensions_for_feed(mock_reader, mock_feed.url, ["text_var"]) # Set a custom message that uses the extension variable. mock_reader.set_tag(mock_feed.url, "custom_message", "{{custom_text}}") # Run replacement. result: str = replace_tags_in_text_message(mock_entry, mock_reader) # type: ignore[arg-type] assert "hello_from_extension" in result # --------------------------------------------------------------------------- # Tests: JWPlayer example plugin # --------------------------------------------------------------------------- def test_jwplayer_thumbnail_extension_extracts_image() -> None: """The JWPlayer thumbnail extension should extract the image URL.""" ext = JWPlayerThumbnailExtension() raw_html: str = """ """ entry: SimpleNamespace = SimpleNamespace( id="test", content=[SimpleNamespace(value=raw_html)], summary="", feed=SimpleNamespace(url="https://example.com/feed.xml"), ) result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type] assert result.get("jwplayer_thumbnail") == "https://example.com/thumbnail.jpg" assert result.get("jwplayer_file") == "https://example.com/video.mp4" def test_jwplayer_thumbnail_extension_returns_empty_without_content() -> None: """If the entry has no content, the extension should return an empty dict.""" ext = JWPlayerThumbnailExtension() entry: SimpleNamespace = SimpleNamespace( id="test", content=[], summary="", link="https://example.com/video", feed=SimpleNamespace(url="https://example.com/feed.xml"), ) result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type] assert result == {} def test_jwplayer_thumbnail_extension_returns_empty_without_match() -> None: """If the HTML has no JWPlayer pattern, the extension should return empty.""" ext = JWPlayerThumbnailExtension() raw_html: str = "No player here.
" entry: SimpleNamespace = SimpleNamespace( id="test", content=[SimpleNamespace(value=raw_html)], summary="", link="https://example.com/video", feed=SimpleNamespace(url="https://example.com/feed.xml"), ) result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type] assert result == {} def test_jwplayer_thumbnail_extension_matches_hentaigasm_format() -> None: """The extension should extract URLs from the actual hentaigasm.com feed format.""" ext = JWPlayerThumbnailExtension() # This is the actual HTML structure from the main hentaigasm feed. raw_html: str = """HENTAIGASM EXCLUSIVE!
" not in entry.summary if entry.content: assert "
" in entry.content[0].value # The jwplayer_thumbnail extension should return empty # because this feed has no JWPlayer script blocks. ext = JWPlayerThumbnailExtension() result: dict[str, str] = ext.process_entry(entry, reader) # type: ignore[arg-type] assert result == {}, f"JWPlayer extension should return empty for comment feed, got {result}" # Tag replacement should not crash. replaced_text: str = replace_tags_in_text_message(entry, reader) assert isinstance(replaced_text, str) # Embed replacement should not crash. replaced_embed = replace_tags_in_embed(feed, entry, reader) assert replaced_embed is not None # Verify content:encoded was parsed into the content field. first_entry = entries[0] assert first_entry.content is not None raw_content: str = first_entry.content[0].value assert "Bookmark us" in raw_content or "bookmark" in raw_content.lower() assert "
" in raw_content
finally:
reader.close()
# ---------------------------------------------------------------------------
# Main feed XML (hentaigasm-style, with JWPlayer video entries)
# ---------------------------------------------------------------------------
_MAIN_FEED_XML: str = """
No player here.
"}, }, ]) class _APIHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(wp_json.encode("utf-8")) def log_message(self, _format: str, *args: str | int) -> None: pass with ThreadingHTTPServer(("127.0.0.1", 0), _APIHandler) as server: server_thread = Thread(target=server.serve_forever, daemon=True) server_thread.start() port: int = server.server_port try: entry = SimpleNamespace( id="test-wp-batch", content=[], summary="", link=f"http://127.0.0.1:{port}/test-slug/", feed=SimpleNamespace(url=f"http://127.0.0.1:{port}/feed/"), ) ext = JWPlayerThumbnailExtension() result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type] assert result.get("jwplayer_thumbnail") == "https://cdn.example.com/thumb.jpg", ( f"Batch WP fallback should extract thumbnail, got {result}" ) assert result.get("jwplayer_file") == "https://cdn.example.com/video.mp4", ( f"Batch WP fallback should extract file, got {result}" ) # Second entry from the same site uses cache — no API call. entry2 = SimpleNamespace( id="other-post", content=[], summary="", link=f"http://127.0.0.1:{port}/other-post/", feed=SimpleNamespace(url=f"http://127.0.0.1:{port}/feed/"), ) result2: dict[str, str] = ext.process_entry(entry2, MagicMock()) # type: ignore[arg-type] assert result2 == {}, "Entry without player should return empty" finally: server.shutdown() server_thread.join() _SLUG_CACHE.clear() # --------------------------------------------------------------------------- # Tests: WordPress extension # --------------------------------------------------------------------------- def test_wordpress_extension_uses_shared_batch_cache() -> None: """The WordPress extension should use the shared batch cache.""" _SLUG_CACHE.clear() content_html: str = ( "Test
" "" ) # Pre-populate the shared cache with the new richer format. _SLUG_CACHE["https://example.com"] = { "test-slug": { "content": content_html, "excerpt": "Test excerpt
", "title": "Test Post", }, } ext = WordPressExtension() entry = SimpleNamespace( id="test", link="https://example.com/test-slug/", feed=SimpleNamespace(url="https://example.com/feed/"), ) result = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type] assert result.get("wp_jwplayer_thumbnail") == "https://cdn.example.com/thumb.jpg" assert result.get("wp_jwplayer_file") == "https://cdn.example.com/v.mp4" assert result.get("wp_content_raw") == content_html assert result.get("wp_content") is not None assert "Test" in result.get("wp_content", "") assert result.get("wp_excerpt_raw") == "Test excerpt
" assert result.get("wp_excerpt") is not None assert "Test excerpt" in result.get("wp_excerpt", "") # No spaces in URL values. for key, val in result.items(): if key.startswith("wp_jwplayer"): assert " " not in val, f"URL should be encoded, got spaces: {val}" _SLUG_CACHE.clear() def test_wordpress_extension_provides_correct_variables() -> None: """The WordPress extension should declare the correct variables.""" expected: set[str] = { "wp_content", "wp_content_raw", "wp_excerpt", "wp_excerpt_raw", "wp_jwplayer_file", "wp_jwplayer_thumbnail", } assert set(WordPressExtension.provides_variables) == expected