Add support for extensions
Some checks failed
Test and build Docker image / docker (push) Failing after 3s
Some checks failed
Test and build Docker image / docker (push) Failing after 3s
This commit is contained in:
parent
64a116d947
commit
793f67db42
39 changed files with 3670 additions and 1309 deletions
1137
tests/test_extensions.py
Normal file
1137
tests/test_extensions.py
Normal file
|
|
@ -0,0 +1,1137 @@
|
|||
"""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="<p>Hello world</p>")],
|
||||
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 = """
|
||||
<div id="player_01"></div>
|
||||
<script type="text/javascript">
|
||||
jwplayer("player_01").setup({
|
||||
file: "https://example.com/video.mp4",
|
||||
image: "https://example.com/thumbnail.jpg",
|
||||
});
|
||||
</script>
|
||||
"""
|
||||
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 = "<p>No player here.</p>"
|
||||
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 = """
|
||||
<p style="text-align: center;"><strong>HENTAIGASM EXCLUSIVE!</strong></p>
|
||||
|
||||
<div style="width:620px; height:349px">
|
||||
<script src="https://content.jwplatform.com/libraries/SAHhwvZq.js"></script>
|
||||
<script>jwplayer.key="zTEbSn/eAplL0RLXT030FzOcek6qXmtrxju6Jg=="</script>
|
||||
|
||||
<div id="player_01"></div>
|
||||
<script type="text/javascript">
|
||||
.jwplayer("player_01").setup({
|
||||
file: "https://hgasm2.com/Test Video 1 Subbed.mp4",
|
||||
width: "620",
|
||||
height: "349",
|
||||
skin: "seven",
|
||||
preload: "none",
|
||||
autostart: "false",
|
||||
image: "https://hgasm1.com/thumbnail/Test Video 1 Subbed.jpg",
|
||||
advertising: {}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<a class="btn btn-pink" href="https://hgasm3.com/Test Video 1 Subbed.mp4" download>DOWNLOAD</a>
|
||||
"""
|
||||
entry: SimpleNamespace = SimpleNamespace(
|
||||
id="test-hentai",
|
||||
content=[SimpleNamespace(value=raw_html)],
|
||||
summary="",
|
||||
feed=SimpleNamespace(url="https://hentaigasm.com/feed/"),
|
||||
)
|
||||
result: dict[str, str] = ext.process_entry(entry, MagicMock()) # type: ignore[arg-type]
|
||||
assert result.get("jwplayer_thumbnail") == "https://hgasm1.com/thumbnail/Test%20Video%201%20Subbed.jpg"
|
||||
assert result.get("jwplayer_file") == "https://hgasm2.com/Test%20Video%201%20Subbed.mp4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: auto_enable_url_patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auto_enable_by_url_pattern_matches(
|
||||
mock_reader: MagicMock,
|
||||
temp_extensions_dir: str,
|
||||
) -> None:
|
||||
"""An extension with matching auto_enable_url_patterns should be auto-enabled.
|
||||
|
||||
Check that ``auto_enable_extensions_for_feed`` activates the plugin.
|
||||
"""
|
||||
# Register a test plugin with a URL pattern.
|
||||
plugin_code: str = """
|
||||
from discord_rss_bot.extensions.base import FeedExtension
|
||||
|
||||
class AutoPlugin(FeedExtension):
|
||||
name = "auto_test"
|
||||
auto_enable_url_patterns = [r"example\\.com/feeds"]
|
||||
|
||||
def process_entry(self, entry, reader):
|
||||
return {"auto_var": "enabled"}
|
||||
"""
|
||||
plugin_path: Path = Path(temp_extensions_dir) / "auto_plugin.py"
|
||||
plugin_path.write_text(plugin_code, encoding="utf-8")
|
||||
discover_plugins(force=True)
|
||||
|
||||
mock_feed_url: str = "https://example.com/feeds/test.xml"
|
||||
result: list[str] = auto_enable_extensions_for_feed(mock_reader, mock_feed_url)
|
||||
|
||||
assert "auto_test" in result
|
||||
|
||||
|
||||
def test_auto_enable_by_url_pattern_does_not_match(
|
||||
mock_reader: MagicMock,
|
||||
temp_extensions_dir: str,
|
||||
) -> None:
|
||||
"""An extension whose pattern does not match should NOT be auto-enabled."""
|
||||
# Register the same plugin as the matching test, so this test actually
|
||||
# validates that a non-matching URL does not trigger auto-enable.
|
||||
plugin_code: str = """
|
||||
from discord_rss_bot.extensions.base import FeedExtension
|
||||
|
||||
class AutoPlugin(FeedExtension):
|
||||
name = "auto_test"
|
||||
auto_enable_url_patterns = [r"example\\.com/feeds"]
|
||||
|
||||
def process_entry(self, entry, reader):
|
||||
return {"auto_var": "enabled"}
|
||||
"""
|
||||
plugin_path: Path = Path(temp_extensions_dir) / "auto_plugin.py"
|
||||
plugin_path.write_text(plugin_code, encoding="utf-8")
|
||||
discover_plugins(force=True)
|
||||
|
||||
mock_feed_url: str = "https://other-site.com/feed.xml"
|
||||
result: list[str] = auto_enable_extensions_for_feed(mock_reader, mock_feed_url)
|
||||
|
||||
assert "auto_test" not in result
|
||||
|
||||
|
||||
def test_auto_enable_does_not_duplicate_explicitly_enabled(
|
||||
mock_reader: MagicMock,
|
||||
) -> None:
|
||||
"""If an extension is already explicitly enabled, auto-enable should not add it again."""
|
||||
mock_feed_url: str = "https://example.com/feeds/test.xml"
|
||||
# Explicitly enable the extension first.
|
||||
set_enabled_extensions_for_feed(mock_reader, mock_feed_url, ["auto_test"])
|
||||
|
||||
result: list[str] = auto_enable_extensions_for_feed(mock_reader, mock_feed_url)
|
||||
# Should contain auto_test only once.
|
||||
assert result == ["auto_test"]
|
||||
|
||||
|
||||
def test_matches_feed_url_classmethod() -> None:
|
||||
"""The ``matches_feed_url`` classmethod should work correctly."""
|
||||
assert FeedExtension.matches_feed_url("http://example.com") is False, "Base class has no patterns"
|
||||
|
||||
|
||||
def test_steam_extension_has_auto_enable_patterns() -> None:
|
||||
"""The built-in Steam extension should declare URL patterns."""
|
||||
assert len(SteamExtension.auto_enable_url_patterns) > 0
|
||||
assert SteamExtension.matches_feed_url("https://store.steampowered.com/feeds/news/app/570/")
|
||||
assert SteamExtension.matches_feed_url("https://steamcommunity.com/games/570/")
|
||||
assert not SteamExtension.matches_feed_url("https://example.com/feed.xml")
|
||||
|
||||
|
||||
def test_youtube_extension_has_auto_enable_patterns() -> None:
|
||||
"""The built-in YouTube extension should declare URL patterns."""
|
||||
assert len(YouTubeExtension.auto_enable_url_patterns) > 0
|
||||
assert YouTubeExtension.matches_feed_url("https://www.youtube.com/feeds/videos.xml?channel_id=123")
|
||||
assert not YouTubeExtension.matches_feed_url("https://example.com/feed.xml")
|
||||
|
||||
|
||||
def test_hoyolab_extension_has_auto_enable_patterns() -> None:
|
||||
"""The built-in Hoyolab extension should declare URL patterns."""
|
||||
assert len(HoyolabExtension.auto_enable_url_patterns) > 0
|
||||
assert HoyolabExtension.matches_feed_url("https://feeds.c3kay.de/hoyolab.xml")
|
||||
assert not HoyolabExtension.matches_feed_url("https://example.com/feed.xml")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test with a real WordPress comment RSS feed (the original issue)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_COMMENTS_FEED_XML: str = r"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:sy="http://purl.org/rss/1.0/modules/syndication/">
|
||||
<channel>
|
||||
<title>Comments on: Test Article</title>
|
||||
<link>https://example.com/test-article/</link>
|
||||
<description></description>
|
||||
<lastBuildDate>Sat, 18 Jul 2026 05:11:01 +0000</lastBuildDate>
|
||||
<sy:updatePeriod>hourly</sy:updatePeriod>
|
||||
<sy:updateFrequency>1</sy:updateFrequency>
|
||||
<generator>https://wordpress.org/?v=5.8.2</generator>
|
||||
<item>
|
||||
<title>By: Anonymous</title>
|
||||
<link>https://example.com/test-article/comment-page-1/#comment-820928</link>
|
||||
<dc:creator><![CDATA[Anonymous]]></dc:creator>
|
||||
<pubDate>Sat, 18 Jul 2026 05:11:01 +0000</pubDate>
|
||||
<guid isPermaLink="false">https://example.com/?p=5633#comment-820928</guid>
|
||||
<description><![CDATA[Am I the only one that saw the "Bookmark us" message?]]></description>
|
||||
<content:encoded><![CDATA[<p>Am I the only one that saw “Bookmark us” message?</p>]]></content:encoded>
|
||||
</item>
|
||||
<item>
|
||||
<title>By: Unknown</title>
|
||||
<link>https://example.com/test-article/comment-page-1/#comment-820900</link>
|
||||
<dc:creator><![CDATA[Unknown]]></dc:creator>
|
||||
<pubDate>Fri, 17 Jul 2026 14:29:42 +0000</pubDate>
|
||||
<guid isPermaLink="false">https://example.com/?p=5633#comment-820900</guid>
|
||||
<description><![CDATA[Please uncensor the video]]></description>
|
||||
<content:encoded><![CDATA[<p>Please uncensor the video</p>]]></content:encoded>
|
||||
</item>
|
||||
<item>
|
||||
<title>By: mid</title>
|
||||
<link>https://example.com/test-article/comment-page-1/#comment-820887</link>
|
||||
<dc:creator><![CDATA[mid]]></dc:creator>
|
||||
<pubDate>Fri, 17 Jul 2026 06:20:56 +0000</pubDate>
|
||||
<guid isPermaLink="false">https://example.com/?p=5633#comment-820887</guid>
|
||||
<description><![CDATA[that's not uncensored]]></description>
|
||||
<content:encoded><![CDATA[<p>that’s not uncensored</p>]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
|
||||
class _CommentsFeedHandler(BaseHTTPRequestHandler):
|
||||
"""Serves the WordPress comment RSS XML on any request."""
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/xml")
|
||||
self.end_headers()
|
||||
self.wfile.write(_COMMENTS_FEED_XML.encode("utf-8"))
|
||||
|
||||
def log_message(self, _format: str, *args: str | int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _serve_feed() -> Iterator[str]:
|
||||
"""Start a local HTTP server serving the comments feed XML.
|
||||
|
||||
Yields:
|
||||
The URL of the feed.
|
||||
"""
|
||||
with ThreadingHTTPServer(("127.0.0.1", 0), _CommentsFeedHandler) as server:
|
||||
server_thread = Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}/feed.xml"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server_thread.join()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_wordpress_comments_feed_pipeline() -> None:
|
||||
"""End-to-end test using the WordPress comment RSS feed from the original issue.
|
||||
|
||||
Validates that:
|
||||
1. The feed XML parses correctly via the reader library
|
||||
2. Entry content and summary are populated as expected
|
||||
3. The jwplayer_thumbnail extension gracefully returns empty (no script tags)
|
||||
4. Tag replacement does not crash with comment feed data
|
||||
"""
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
_serve_feed() as feed_url,
|
||||
):
|
||||
db_path: Path = Path(tmpdir) / "test.sqlite"
|
||||
reader: ReaderType = make_reader(url=str(db_path))
|
||||
|
||||
try:
|
||||
# Add and update the feed.
|
||||
reader.add_feed(feed_url)
|
||||
reader.update_feed(feed_url)
|
||||
|
||||
feed = reader.get_feed(feed_url)
|
||||
entries: list[Entry] = list(reader.get_entries(feed=feed_url))
|
||||
|
||||
# The comments feed should have 3 entries.
|
||||
assert len(entries) == 3, f"Expected 3 comments, got {len(entries)}"
|
||||
|
||||
for entry in entries:
|
||||
# Each entry should have a title (comment author).
|
||||
assert entry.title is not None
|
||||
assert entry.title.startswith("By:")
|
||||
|
||||
# Each entry should have a link back to the comment.
|
||||
assert entry.link is not None
|
||||
assert "comment-page" in entry.link
|
||||
|
||||
# Summary should be plain text (from <description>),
|
||||
# content should be HTML (from <content:encoded>).
|
||||
assert entry.summary is not None
|
||||
assert "<p>" not in entry.summary
|
||||
if entry.content:
|
||||
assert "<p>" 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 "<p>" in raw_content
|
||||
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main feed XML (hentaigasm-style, with JWPlayer video entries)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAIN_FEED_XML: str = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:sy="http://purl.org/rss/1.0/modules/syndication/">
|
||||
<channel>
|
||||
<title>Hentaigasm - Test Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<description></description>
|
||||
<lastBuildDate>Sun, 19 Jul 2026 04:20:40 +0000</lastBuildDate>
|
||||
<item>
|
||||
<title>Test Video 1 Subbed</title>
|
||||
<link>https://example.com/test-video-1/</link>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<pubDate>Sun, 19 Jul 2026 04:00:49 +0000</pubDate>
|
||||
<guid isPermaLink="false">https://example.com/?p=5501</guid>
|
||||
<description><![CDATA[]]></description>
|
||||
<content:encoded><![CDATA[
|
||||
|
||||
<p style="text-align: center;"><strong>HENTAIGASM EXCLUSIVE!</strong></p>
|
||||
|
||||
<div style="width:620px; height:349px">
|
||||
<script src="https://content.jwplatform.com/libraries/SAHhwvZq.js"></script>
|
||||
<script>jwplayer.key="zTEbSn/eAplL0RLXT030FzOcek6qXmtrxju6Jg=="</script>
|
||||
|
||||
<div id="player_01"></div>
|
||||
<script type="text/javascript">
|
||||
jwplayer("player_01").setup({
|
||||
file: "https://cdn.example.com/Test Video 1 Subbed.mp4",
|
||||
width: "620",
|
||||
height: "349",
|
||||
skin: "seven",
|
||||
preload: "none",
|
||||
autostart: "false",
|
||||
image: "https://cdn.example.com/thumbnail/Test Video 1 Subbed.jpg",
|
||||
advertising: {}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
|
||||
class _MainFeedHandler(BaseHTTPRequestHandler):
|
||||
"""Serves the main feed XML with JWPlayer video entries."""
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/xml")
|
||||
self.end_headers()
|
||||
self.wfile.write(_MAIN_FEED_XML.encode("utf-8"))
|
||||
|
||||
def log_message(self, _format: str, *args: str | int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _serve_main_feed() -> Iterator[str]:
|
||||
"""Start a local HTTP server serving the main feed XML with JWPlayer content.
|
||||
|
||||
Yields:
|
||||
The URL of the feed.
|
||||
"""
|
||||
with ThreadingHTTPServer(("127.0.0.1", 0), _MainFeedHandler) as server:
|
||||
server_thread = Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}/feed.xml"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server_thread.join()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_hentaigasm_main_feed_jwplayer_extraction() -> None:
|
||||
"""End-to-end test using a hentaigasm-style main feed with JWPlayer content.
|
||||
|
||||
This test confirms that feedparser strips ``<script>`` tags from
|
||||
``<content:encoded>``, so the JWPlayer ``image:`` and ``file:``
|
||||
properties are NOT available in ``entry.content``.
|
||||
|
||||
The extension falls back to fetching the HTML page at ``entry.link``,
|
||||
but in the test environment no real server is available, so the
|
||||
extension returns empty. A separate test validates the HTTP fetch
|
||||
fallback with a local server.
|
||||
"""
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
_serve_main_feed() as feed_url,
|
||||
):
|
||||
db_path: Path = Path(tmpdir) / "test.sqlite"
|
||||
reader: ReaderType = make_reader(url=str(db_path))
|
||||
|
||||
try:
|
||||
reader.add_feed(feed_url)
|
||||
reader.update_feed(feed_url)
|
||||
|
||||
entries = list(reader.get_entries(feed=feed_url))
|
||||
|
||||
# Should have 1 video entry.
|
||||
assert len(entries) == 1, f"Expected 1 entry, got {len(entries)}"
|
||||
|
||||
entry = entries[0]
|
||||
assert entry.title is not None
|
||||
|
||||
# CONFIRMATION: feedparser strips <script> tags entirely.
|
||||
# The content <p> and <div> survive, but all <script> blocks
|
||||
# (including the jwplayer setup with image:/file:) are gone.
|
||||
assert entry.content is not None
|
||||
assert len(entry.content) > 0
|
||||
raw_content: str = entry.content[0].value
|
||||
assert "jwplayer" not in raw_content, (
|
||||
f"BUG: script content survived parsing! Content: {raw_content[:300]!r}"
|
||||
)
|
||||
assert "image:" not in raw_content
|
||||
assert "file:" not in raw_content
|
||||
assert "player_01" in raw_content # non-script div survives
|
||||
|
||||
# Auto-enable is URL-based — the test feed URL (127.0.0.1) doesn't
|
||||
# match "hentaigasm\.com", so we manually enable for this test.
|
||||
set_enabled_extensions_for_feed(reader, entry.feed.url, ["jwplayer_thumbnail"])
|
||||
enabled: list[str] = get_enabled_extensions_for_feed(reader, entry.feed.url)
|
||||
assert "jwplayer_thumbnail" in enabled
|
||||
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_jwplayer_thumbnail_wordpress_batch_fallback() -> None:
|
||||
"""Verify the batch WordPress API fallback works.
|
||||
|
||||
Serves a fake WordPress REST API response (a list of posts) so the
|
||||
extension extracts JWPlayer URLs from the cached batch data.
|
||||
"""
|
||||
_SLUG_CACHE.clear()
|
||||
|
||||
wp_json: str = json.dumps([
|
||||
{
|
||||
"slug": "test-slug",
|
||||
"content": {
|
||||
"rendered": (
|
||||
'<p>Test</p><div id="player_01"></div>'
|
||||
'<script>jwplayer("player_01").setup({'
|
||||
'file: "https://cdn.example.com/video.mp4", '
|
||||
'image: "https://cdn.example.com/thumb.jpg"'
|
||||
"});</script>"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"slug": "other-post",
|
||||
"content": {"rendered": "<p>No player here.</p>"},
|
||||
},
|
||||
])
|
||||
|
||||
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 = (
|
||||
"<p>Test</p>"
|
||||
"<script>jwplayer().setup({file: 'https://cdn.example.com/v.mp4', image: 'https://cdn.example.com/thumb.jpg'});</script>"
|
||||
)
|
||||
# Pre-populate the shared cache with the new richer format.
|
||||
_SLUG_CACHE["https://example.com"] = {
|
||||
"test-slug": {
|
||||
"content": content_html,
|
||||
"excerpt": "<p>Test excerpt</p>",
|
||||
"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") == "<p>Test excerpt</p>"
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue