Use Steam game names as feed display titles; custom.html are now partly rendered from Python
All checks were successful
Test and build Docker image / docker (push) Successful in 30s

This commit is contained in:
Joakim Hellsén 2026-08-12 21:55:08 +02:00
commit 6c0aea053b
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
15 changed files with 976 additions and 615 deletions

View file

@ -1,5 +1,7 @@
from __future__ import annotations
import inspect
import re
import typing
from types import SimpleNamespace
from unittest.mock import MagicMock
@ -16,6 +18,7 @@ from discord_rss_bot.custom_message import get_first_image
from discord_rss_bot.custom_message import get_image_urls
from discord_rss_bot.custom_message import normalize_message_avatar_url
from discord_rss_bot.custom_message import normalize_message_username
from discord_rss_bot.custom_message import render_tag_reference_html
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
@ -225,6 +228,42 @@ def test_replace_tags_in_embed_uses_authors_str(mock_get_embed: MagicMock) -> No
assert embed.description == "Feed Author One, Feed Author Two | Entry Author One, Entry Author Two"
@patch("discord_rss_bot.custom_message.get_custom_message")
def test_replace_tags_in_text_message_uses_steam_game_name_for_feed_title(
mock_get_custom_message: MagicMock,
) -> None:
"""{{feed_title}} should render the cached Steam game name for Steam feeds."""
mock_reader = MagicMock()
mock_reader.get_tag.return_value = "Dota"
mock_get_custom_message.return_value = "{{feed_title}}"
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
entry_ns.feed.url = "https://store.steampowered.com/feeds/news/app/570/?cc=US&l=english"
entry_ns.feed.title = "570 RSS Feed"
rendered: str = replace_tags_in_text_message(typing.cast("Entry", entry_ns), reader=mock_reader)
assert rendered == "Dota"
@patch("discord_rss_bot.custom_message.get_embed")
def test_replace_tags_in_embed_uses_steam_game_name_for_feed_title(mock_get_embed: MagicMock) -> None:
"""{{feed_title}} in an embed should render the cached Steam game name."""
mock_reader = MagicMock()
mock_reader.get_tag.return_value = "Dota"
mock_get_embed.return_value = CustomEmbed(description="{{feed_title}}")
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
entry_ns.feed.url = "https://store.steampowered.com/feeds/news/app/570/?cc=US&l=english"
entry_ns.feed.title = "570 RSS Feed"
embed: CustomEmbed = replace_tags_in_embed(
entry_ns.feed,
typing.cast("Entry", entry_ns),
reader=mock_reader,
)
assert embed.description == "Dota"
def test_get_first_image_prefers_content_image_over_summary_image() -> None:
summary = '<p><img src="https://example.com/from-summary.jpg" /></p>'
content = '<p><img src="https://example.com/from-content.jpg" /></p>'
@ -556,3 +595,123 @@ class TestNormalizeMessageAvatarUrl:
def test_invalid_url_returns_empty(self) -> None:
assert not normalize_message_avatar_url("not-a-url")
def test_render_tag_reference_html_renders_feed_tags() -> None:
"""The reference list should show every feed tag with its preview value."""
feed = typing.cast("Feed", make_feed())
output = render_tag_reference_html(
feed=feed,
entry=None,
feed_title="Example Feed",
first_image="",
)
assert '<ul class="list-inline">' in output
assert "{{feed_author}}</code> Entry Author" in output
assert "{{feed_title}}</code> Example Feed" in output
assert "{{feed_url}}</code> https://example.com/feed.xml" in output
assert "{{ feed_author }}" not in output
assert "{{entry_added}}" not in output
def test_render_tag_reference_html_renders_entry_tags() -> None:
"""The reference list should include entry tags, content, and image tags."""
feed = typing.cast("Feed", make_feed())
entry_ns = make_entry("<p>Summary text</p>")
entry_ns.content = [SimpleNamespace(value="<p>Content text</p>")]
entry = typing.cast("Entry", entry_ns)
output = render_tag_reference_html(
feed=feed,
entry=entry,
feed_title="Example Feed",
first_image="https://example.com/first.jpg",
)
assert "{{entry_title}}</code> Entry Title" in output
assert "{{entry_content}}</code> Content text" in output
assert "{{entry_content_raw}}" in output
assert "{{entry_summary}}</code> Summary text" in output
assert "{{entry_summary_raw}}" in output
assert "{{entry_text}}" in output
assert "{{image_1}}</code> https://example.com/first.jpg" in output
assert "{{ entry_title }}" not in output
def test_render_tag_reference_html_no_entry_shows_message() -> None:
"""Without an entry the list should show the fallback message instead."""
feed = typing.cast("Feed", make_feed())
output = render_tag_reference_html(
feed=feed,
entry=None,
feed_title="Example Feed",
first_image="",
)
assert "Something went wrong, there was no entry found" in output
assert "{{entry_title}}" not in output
def test_render_tag_reference_html_escapes_values() -> None:
"""Values containing HTML should be escaped so they render as text."""
feed_ns = make_feed()
feed_ns.subtitle = "<b>bold & loud</b>"
feed = typing.cast("Feed", feed_ns)
output = render_tag_reference_html(
feed=feed,
entry=None,
feed_title="Example Feed",
first_image="",
)
assert "<b>bold & loud</b>" not in output
assert "&lt;b&gt;bold &amp; loud&lt;/b&gt;" in output
def test_render_tag_reference_html_shows_extension_variables() -> None:
"""Enabled extension variables should be listed with their values."""
feed = typing.cast("Feed", make_feed())
entry = typing.cast("Entry", make_entry("<p>Summary text</p>"))
output = render_tag_reference_html(
feed=feed,
entry=entry,
feed_title="Example Feed",
first_image="",
extension_variables=["wp_content", "wp_excerpt"],
extension_values={"wp_content": "Full <p>post</p>", "wp_excerpt": ""},
)
assert "Extension variables (from enabled extensions):" in output
assert "{{wp_content}}</code> Full &lt;p&gt;post&lt;/p&gt;" in output
assert "{{wp_excerpt}}</code> (empty)" in output
assert "{{ wp_content }}" not in output
def test_render_tag_reference_html_lists_all_replaceable_tags() -> None:
"""Every replaceable tag should be listed on the reference preview."""
feed = typing.cast("Feed", make_feed())
entry_ns = make_entry("<p>Summary text</p>")
entry_ns.content = [SimpleNamespace(value="<p>Content text</p>")]
entry = typing.cast("Entry", entry_ns)
output = render_tag_reference_html(
feed=feed,
entry=entry,
feed_title="Example Feed",
first_image="https://example.com/first.jpg",
)
# Extract the literal {{...}} tags from both replacement tables so this
# test automatically catches any tag that is replaceable but not shown.
tag_pattern: re.Pattern[str] = re.compile(r'\{"\{\{([a-z_0-9]+)\}\}":')
replaceable_tags: set[str] = set(tag_pattern.findall(inspect.getsource(replace_tags_in_text_message)))
replaceable_tags.update(tag_pattern.findall(inspect.getsource(replace_tags_in_embed)))
assert replaceable_tags, "No replaceable tags were found to compare"
for tag in sorted(replaceable_tags):
assert f"{{{{{tag}}}}}" in output, f"Replaceable tag {{{{{tag}}}}} is missing from the reference list"

View file

@ -17,8 +17,10 @@ from threading import Thread
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from httpx2 import HTTPError
from reader import Reader as ReaderType
from reader import make_reader
@ -39,6 +41,11 @@ from discord_rss_bot.extensions.jwplayer_thumbnail import JWPlayerThumbnailExten
from discord_rss_bot.extensions.steam import SteamExtension
from discord_rss_bot.extensions.steam import fix_steam_image_url
from discord_rss_bot.extensions.steam import fix_steam_image_urls_in_payload
from discord_rss_bot.extensions.steam import get_cached_feed_display_title
from discord_rss_bot.extensions.steam import get_cached_steam_game_name
from discord_rss_bot.extensions.steam import get_feed_display_title
from discord_rss_bot.extensions.steam import get_steam_game_name
from discord_rss_bot.extensions.steam import set_steam_game_name
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
@ -829,6 +836,175 @@ def test_steam_modify_webhook_skips_non_steam_feed() -> None:
assert result.json["content"] == "https://clan.akamai.steamstatic.com/images/123/abc/english.png"
def _steam_feed(url: str = "https://store.steampowered.com/feeds/news/app/570/") -> SimpleNamespace:
"""Build a minimal Steam feed object.
Returns:
A ``SimpleNamespace`` with a Steam feed URL and its raw title.
"""
return SimpleNamespace(url=url, title="570 RSS Feed")
def _appdetails_response(app_id: str, name: str) -> MagicMock:
"""Build a mock Steam Store API response carrying a game name.
Returns:
A mock response whose ``json()`` returns an app-details payload.
"""
response = MagicMock()
response.json.return_value = {app_id: {"success": True, "data": {"name": name}}}
return response
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_steam_game_name_fetches_from_api(mock_get: MagicMock) -> None:
"""get_steam_game_name should return the name from the Steam Store API."""
mock_get.return_value = _appdetails_response("570", "Dota")
result: str | None = get_steam_game_name("570")
assert result == "Dota"
mock_get.assert_called_once()
assert mock_get.call_args.kwargs["params"]["appids"] == "570"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_steam_game_name_returns_none_on_api_error(mock_get: MagicMock) -> None:
"""get_steam_game_name should return None when the API call fails."""
mock_get.side_effect = HTTPError("boom")
assert get_steam_game_name("570") is None
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_steam_game_name_returns_none_on_missing_details(mock_get: MagicMock) -> None:
"""get_steam_game_name should return None when the API lacks game details."""
mock_get.return_value = _appdetails_response("570", "")
mock_get.return_value.json.return_value = {"570": {"success": False, "data": {}}}
assert get_steam_game_name("570") is None
def test_get_cached_steam_game_name_reads_reader_tag(mock_reader: MagicMock) -> None:
"""get_cached_steam_game_name should read the cached name from the reader."""
feed: SimpleNamespace = _steam_feed()
assert get_cached_steam_game_name(mock_reader, feed) is None
set_steam_game_name(mock_reader, feed, "Dota")
assert get_cached_steam_game_name(mock_reader, feed) == "Dota"
def test_set_steam_game_name_stores_reader_tag(mock_reader: MagicMock) -> None:
"""set_steam_game_name should persist the name in the reader DB."""
feed: SimpleNamespace = _steam_feed()
set_steam_game_name(mock_reader, feed, "Dota 2")
assert mock_reader.tags[feed.url]["steam_game_name"] == "Dota 2"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_uses_cached_game_name(
mock_get: MagicMock,
mock_reader: MagicMock,
) -> None:
"""A cached game name should be used without any network request."""
feed: SimpleNamespace = _steam_feed()
set_steam_game_name(mock_reader, feed, "Dota")
result: str = get_feed_display_title(feed, mock_reader) # type: ignore[arg-type]
assert result == "Dota"
mock_get.assert_not_called()
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_fetches_and_caches_once(mock_get: MagicMock) -> None:
"""A missing game name should be fetched once, cached, then reused."""
mock_get.return_value = _appdetails_response("570", "Dota")
feed: SimpleNamespace = _steam_feed()
reader: MagicMock = MagicMock()
reader.tags = {} # type: ignore[valid-type]
def set_tag(feed_url: object, key: str, value: object) -> None:
reader.tags.setdefault(str(feed_url), {})[key] = value
def get_tag(feed_url: object, key: str, default: object = None) -> object:
return reader.tags.get(str(feed_url), {}).get(key, default)
reader.set_tag.side_effect = set_tag
reader.get_tag.side_effect = get_tag
first: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
second: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
assert first == "Dota"
assert second == "Dota"
mock_get.assert_called_once()
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_falls_back_to_feed_title_on_error(mock_get: MagicMock) -> None:
"""When the API fails, the feed's own title should be returned."""
mock_get.side_effect = HTTPError("boom")
feed: SimpleNamespace = _steam_feed(url="https://store.steampowered.com/feeds/news/app/111111/")
result: str = get_feed_display_title(feed, MagicMock()) # type: ignore[arg-type]
assert result == "570 RSS Feed"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_does_not_repeat_failed_fetch(mock_get: MagicMock) -> None:
"""A failed lookup should not trigger a network request on every call."""
mock_get.side_effect = HTTPError("boom")
feed: SimpleNamespace = _steam_feed(url="https://store.steampowered.com/feeds/news/app/999999/")
reader: MagicMock = MagicMock()
reader.tags = {} # type: ignore[valid-type]
def set_tag(feed_url: object, key: str, value: object) -> None:
reader.tags.setdefault(str(feed_url), {})[key] = value
def get_tag(feed_url: object, key: str, default: object = None) -> object:
return reader.tags.get(str(feed_url), {}).get(key, default)
reader.set_tag.side_effect = set_tag
reader.get_tag.side_effect = get_tag
first: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
second: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
assert first == "570 RSS Feed"
assert second == "570 RSS Feed"
mock_get.assert_called_once()
def test_get_feed_display_title_returns_title_for_non_steam_feed() -> None:
"""Non-Steam feeds should be returned with their title unchanged."""
feed: SimpleNamespace = SimpleNamespace(url="https://example.com/feed.xml", title="Example Feed")
result: str = get_feed_display_title(feed, MagicMock()) # type: ignore[arg-type]
assert result == "Example Feed"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_cached_feed_display_title_never_fetches(
mock_get: MagicMock,
mock_reader: MagicMock,
) -> None:
"""get_cached_feed_display_title should be read-only and never hit the network."""
feed: SimpleNamespace = _steam_feed()
uncached: str = get_cached_feed_display_title(feed, mock_reader) # type: ignore[arg-type]
assert uncached == "570 RSS Feed"
mock_get.assert_not_called()
set_steam_game_name(mock_reader, feed, "Dota")
cached: str = get_cached_feed_display_title(feed, mock_reader) # type: ignore[arg-type]
assert cached == "Dota"
mock_get.assert_not_called()
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

View file

@ -330,6 +330,49 @@ def test_extract_steam_app_id_from_url(url: str, expected_app_id: str | None) ->
assert extract_app_id(url) == expected_app_id
def test_get_feed_display_name_uses_cached_steam_game_name() -> None:
"""Steam feeds should display the cached game name when a reader is provided."""
reader = MagicMock()
reader.get_tag.return_value = "Dota"
feed = MagicMock()
feed.url = "https://store.steampowered.com/feeds/news/app/570/"
feed.title = "570 RSS Feed"
feed.authors_str = ""
result: str = feeds.get_feed_display_name(feed, reader)
assert result == "Dota"
def test_get_feed_display_name_without_reader_returns_feed_title() -> None:
"""Without a reader, Steam feeds should keep their raw title."""
feed = MagicMock()
feed.url = "https://store.steampowered.com/feeds/news/app/570/"
feed.title = "570 RSS Feed"
feed.authors_str = ""
assert feeds.get_feed_display_name(feed) == "570 RSS Feed"
def test_apply_feed_webhook_identity_uses_steam_game_name_for_username_fallback() -> None:
"""Webhook username fallback should use the Steam game name, not the raw title."""
feed = MagicMock()
feed.url = "https://store.steampowered.com/feeds/news/app/570/"
feed.title = "570 RSS Feed"
feed.authors_str = ""
entry = MagicMock()
entry.feed = feed
reader = MagicMock()
reader.get_tag.side_effect = lambda resource, key, default=None: ( # ruff:ignore[unused-lambda-argument]
"Dota 2" if key == "steam_game_name" else default
)
webhook = feeds.DiscordWebhook(url="https://discord.com/api/webhooks/123/abc")
result: feeds.DiscordWebhook = feeds.apply_feed_webhook_identity(webhook, entry, reader)
assert result.username == "Dota 2"
@pytest.mark.parametrize(
("tag_value", "expected_limit"),
[

View file

@ -325,6 +325,7 @@ def test_feed_page_shows_steam_thumbnail_hint_for_steam_feeds() -> None:
"media_gallery_image_limit": 0,
"webhook_text_length_limit": 4000,
"save_sent_webhooks": True,
"steam_game_name": "Dota 2",
}.get(key, default)
def get_entry_counts(self, **_kwargs: TestKwargValue) -> SimpleNamespace:
@ -344,6 +345,47 @@ def test_feed_page_shows_steam_thumbnail_hint_for_steam_feeds() -> None:
response: Response = client.get(url="/feed", params={"feed_url": stub.feed.url})
assert response.status_code == 200, f"/feed failed: {response.text}"
assert "Dota 2" in response.text
finally:
app.dependency_overrides = {}
def test_embed_page_shows_steam_game_name_for_feed_title() -> None:
"""The /embed page's {{feed_title}} preview should show the cached game name."""
@dataclass(slots=True)
class DummyFeed:
url: str
title: str
class StubReader:
def __init__(self) -> None:
self.feed = DummyFeed(
url="https://store.steampowered.com/feeds/news/app/570/?cc=US&l=english",
title="570 RSS Feed",
)
def get_feed(self, feed_url: str) -> DummyFeed:
assert feed_url == self.feed.url
return self.feed
def get_tag(self, _resource: object, key: str, default: TestTagValue = None) -> TestTagValue:
return {
"embed": "",
"steam_game_name": "Dota 2",
}.get(key, default)
def get_entries(self, **_kwargs: TestKwargValue) -> list[Entry]:
return []
stub = StubReader()
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
response: Response = client.get(url="/embed", params={"feed_url": stub.feed.url})
assert response.status_code == 200, f"/embed failed: {response.text}"
assert "Dota 2" in response.text
finally:
app.dependency_overrides = {}