All checks were successful
Test and build Docker image / docker (push) Successful in 1m40s
361 lines
12 KiB
Python
361 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import typing
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from discord_rss_bot.custom_message import CustomEmbed
|
|
from discord_rss_bot.custom_message import format_entry_html_for_discord
|
|
from discord_rss_bot.custom_message import get_custom_message
|
|
from discord_rss_bot.custom_message import get_embed
|
|
from discord_rss_bot.custom_message import get_embed_data
|
|
from discord_rss_bot.custom_message import get_first_image
|
|
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.custom_message import try_to_replace
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from reader import Entry
|
|
from reader.types import Feed
|
|
|
|
# https://docs.discord.com/developers/reference#message-formatting
|
|
TIMESTAMP_FORMATS: tuple[str, ...] = (
|
|
"<t:1773461490>",
|
|
"<t:1773461490:F>",
|
|
"<t:1773461490:f>",
|
|
"<t:1773461490:D>",
|
|
"<t:1773461490:d>",
|
|
"<t:1773461490:t>",
|
|
"<t:1773461490:T>",
|
|
"<t:1773461490:R>",
|
|
"<t:1773461490:s>",
|
|
"<t:1773461490:S>",
|
|
)
|
|
|
|
|
|
def make_feed() -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
added=None,
|
|
author="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",
|
|
)
|
|
|
|
|
|
def make_entry(summary: str) -> SimpleNamespace:
|
|
feed: SimpleNamespace = make_feed()
|
|
return SimpleNamespace(
|
|
added=None,
|
|
author="Entry Author",
|
|
content=[],
|
|
feed=feed,
|
|
feed_url=feed.url,
|
|
id="entry-1",
|
|
important=False,
|
|
link="https://example.com/entry-1",
|
|
published=None,
|
|
read=False,
|
|
read_modified=None,
|
|
summary=summary,
|
|
title="Entry Title",
|
|
updated=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("timestamp_tag", TIMESTAMP_FORMATS)
|
|
def test_format_entry_html_for_discord_preserves_timestamp_tags(timestamp_tag: str) -> None:
|
|
escaped_timestamp_tag: str = timestamp_tag.replace("<", "<").replace(">", ">")
|
|
html_summary: str = f"<p>Starts: 2026-03-13 23:30 UTC ({escaped_timestamp_tag})</p>"
|
|
|
|
rendered: str = format_entry_html_for_discord(html_summary)
|
|
|
|
assert timestamp_tag in rendered
|
|
assert "DISCORDTIMESTAMPPLACEHOLDER" not in rendered
|
|
|
|
|
|
def test_format_entry_html_for_discord_empty_text_returns_empty_string() -> None:
|
|
rendered: str = format_entry_html_for_discord("")
|
|
assert not rendered
|
|
|
|
|
|
def test_format_entry_html_for_discord_cleans_markdownified_https_link_text() -> None:
|
|
html_summary: str = "[https://example.com](https://example.com)"
|
|
|
|
rendered: str = format_entry_html_for_discord(html_summary)
|
|
|
|
assert "[example.com](https://example.com)" in rendered
|
|
assert "[https://example.com]" not in rendered
|
|
|
|
|
|
def test_format_entry_html_for_discord_does_not_preserve_invalid_timestamp_style() -> None:
|
|
invalid_timestamp: str = "<t:1773461490:Z>"
|
|
html_summary: str = f"<p>Invalid style ({invalid_timestamp.replace('<', '<').replace('>', '>')})</p>"
|
|
|
|
rendered: str = format_entry_html_for_discord(html_summary)
|
|
|
|
assert invalid_timestamp not in rendered
|
|
|
|
|
|
@patch("discord_rss_bot.custom_message.get_custom_message")
|
|
def test_replace_tags_in_text_message_preserves_timestamp_tags(
|
|
mock_get_custom_message: MagicMock,
|
|
) -> None:
|
|
mock_reader = MagicMock()
|
|
mock_get_custom_message.return_value = "{{entry_summary}}"
|
|
summary_parts: list[str] = [
|
|
f"<p>Format {index}: ({timestamp_tag.replace('<', '<').replace('>', '>')})</p>"
|
|
for index, timestamp_tag in enumerate(TIMESTAMP_FORMATS, start=1)
|
|
]
|
|
entry_ns: SimpleNamespace = make_entry("".join(summary_parts))
|
|
|
|
entry: Entry = typing.cast("Entry", entry_ns)
|
|
rendered: str = replace_tags_in_text_message(entry, reader=mock_reader)
|
|
|
|
for timestamp_tag in TIMESTAMP_FORMATS:
|
|
assert timestamp_tag in rendered
|
|
|
|
|
|
@patch("discord_rss_bot.custom_message.get_embed")
|
|
def test_replace_tags_in_embed_preserves_timestamp_tags(
|
|
mock_get_embed: MagicMock,
|
|
) -> None:
|
|
mock_reader = MagicMock()
|
|
mock_get_embed.return_value = CustomEmbed(description="{{entry_summary}}")
|
|
summary_parts: list[str] = [
|
|
f"<p>Format {index}: ({timestamp_tag.replace('<', '<').replace('>', '>')})</p>"
|
|
for index, timestamp_tag in enumerate(TIMESTAMP_FORMATS, start=1)
|
|
]
|
|
entry_ns: SimpleNamespace = make_entry("".join(summary_parts))
|
|
|
|
entry: Entry = typing.cast("Entry", entry_ns)
|
|
|
|
embed: CustomEmbed = replace_tags_in_embed(entry_ns.feed, entry, reader=mock_reader)
|
|
|
|
for timestamp_tag in TIMESTAMP_FORMATS:
|
|
assert timestamp_tag in embed.description
|
|
|
|
|
|
def test_try_to_replace_returns_original_message_when_replace_fails() -> None:
|
|
rendered = try_to_replace(typing.cast("str", None), "{{tag}}", "value")
|
|
assert rendered is None
|
|
|
|
|
|
@patch("discord_rss_bot.custom_message.get_custom_message")
|
|
def test_replace_tags_in_text_message_uses_last_content_item_and_unescapes_newline(
|
|
mock_get_custom_message: MagicMock,
|
|
) -> None:
|
|
mock_reader = MagicMock()
|
|
mock_get_custom_message.return_value = "{{entry_content}}\\n{{entry_content_raw}}"
|
|
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
|
|
entry_ns.content = [SimpleNamespace(value="<p>First content</p>"), SimpleNamespace(value="<p>Last content</p>")]
|
|
|
|
entry: Entry = typing.cast("Entry", entry_ns)
|
|
rendered: str = replace_tags_in_text_message(entry, reader=mock_reader)
|
|
|
|
assert "Last content" in rendered
|
|
assert "<p>First content</p>" in rendered
|
|
assert "\\n" not in rendered
|
|
assert "\n" in rendered
|
|
|
|
|
|
@patch("discord_rss_bot.custom_message.get_custom_message")
|
|
def test_replace_tags_in_text_message_skips_non_string_replacement_values(
|
|
mock_get_custom_message: MagicMock,
|
|
) -> None:
|
|
mock_reader = MagicMock()
|
|
mock_get_custom_message.return_value = "{{entry_id}}"
|
|
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
|
|
entry_ns.id = 123
|
|
|
|
entry: Entry = typing.cast("Entry", entry_ns)
|
|
rendered: str = replace_tags_in_text_message(entry, reader=mock_reader)
|
|
|
|
assert rendered == "{{entry_id}}"
|
|
|
|
|
|
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>'
|
|
|
|
image = get_first_image(summary, content)
|
|
|
|
assert image == "https://example.com/from-content.jpg"
|
|
|
|
|
|
def test_get_first_image_uses_summary_when_content_image_is_invalid() -> None:
|
|
summary = '<p><img src="https://example.com/from-summary.jpg" /></p>'
|
|
content = '<p><img src="javascript:alert(1)" /></p>'
|
|
|
|
image = get_first_image(summary, content)
|
|
|
|
assert image == "https://example.com/from-summary.jpg"
|
|
|
|
|
|
def test_get_first_image_returns_empty_when_images_have_no_src() -> None:
|
|
summary = "<p></p>"
|
|
content = '<p><img alt="missing source" /></p>'
|
|
|
|
image = get_first_image(summary, content)
|
|
|
|
assert not image
|
|
|
|
|
|
def test_get_first_image_returns_empty_when_summary_image_url_is_invalid() -> None:
|
|
summary = '<p><img src="javascript:alert(1)" /></p>'
|
|
|
|
image = get_first_image(summary, content=None)
|
|
|
|
assert not image
|
|
|
|
|
|
def test_get_first_image_returns_empty_when_summary_image_has_no_src() -> None:
|
|
summary = '<p><img alt="missing source" /></p>'
|
|
|
|
image = get_first_image(summary, content=None)
|
|
|
|
assert not image
|
|
|
|
|
|
@patch("discord_rss_bot.custom_message.get_embed")
|
|
def test_replace_tags_in_embed_moves_title_to_author_name_when_required(
|
|
mock_get_embed: MagicMock,
|
|
) -> None:
|
|
mock_reader = MagicMock()
|
|
mock_get_embed.return_value = CustomEmbed(
|
|
title="{{entry_title}}",
|
|
author_name="",
|
|
author_url="https://example.com/author",
|
|
)
|
|
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
|
|
|
|
entry: Entry = typing.cast("Entry", entry_ns)
|
|
embed: CustomEmbed = replace_tags_in_embed(entry_ns.feed, entry, reader=mock_reader)
|
|
|
|
assert not embed.title
|
|
assert embed.author_name == "Entry Title"
|
|
|
|
|
|
@patch("discord_rss_bot.custom_message.get_embed")
|
|
def test_replace_tags_in_embed_uses_last_content_item(
|
|
mock_get_embed: MagicMock,
|
|
) -> None:
|
|
mock_reader = MagicMock()
|
|
mock_get_embed.return_value = CustomEmbed(description="{{entry_content}}")
|
|
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
|
|
entry_ns.content = [SimpleNamespace(value="<p>Old content</p>"), SimpleNamespace(value="<p>New content</p>")]
|
|
|
|
entry: Entry = typing.cast("Entry", entry_ns)
|
|
embed: CustomEmbed = replace_tags_in_embed(entry_ns.feed, entry, reader=mock_reader)
|
|
|
|
assert "New content" in embed.description
|
|
|
|
|
|
def test_get_custom_message_returns_empty_string_on_value_error() -> None:
|
|
reader = MagicMock()
|
|
feed = make_feed()
|
|
reader.get_tag.side_effect = ValueError
|
|
|
|
feed = typing.cast("Feed", feed)
|
|
|
|
custom_message = get_custom_message(reader=reader, feed=feed)
|
|
|
|
assert not custom_message
|
|
|
|
|
|
def test_save_embed_serializes_embed_and_writes_feed_tag() -> None:
|
|
reader = MagicMock()
|
|
feed = make_feed()
|
|
feed = typing.cast("Feed", feed)
|
|
embed = CustomEmbed(
|
|
title="Title",
|
|
description="Description",
|
|
color="#123456",
|
|
author_name="Author",
|
|
author_url="https://example.com/author",
|
|
author_icon_url="https://example.com/author.png",
|
|
image_url="https://example.com/image.png",
|
|
thumbnail_url="https://example.com/thumb.png",
|
|
footer_text="Footer",
|
|
footer_icon_url="https://example.com/footer.png",
|
|
)
|
|
|
|
save_embed(reader=reader, feed=feed, embed=embed)
|
|
|
|
reader.set_tag.assert_called_once()
|
|
call_args = reader.set_tag.call_args.args
|
|
assert call_args[1] == "embed"
|
|
parsed = typing.cast("dict[str, str]", __import__("json").loads(call_args[2]))
|
|
assert parsed["title"] == "Title"
|
|
assert parsed["footer_icon_url"] == "https://example.com/footer.png"
|
|
|
|
|
|
def test_get_embed_returns_default_embed_when_tag_is_empty() -> None:
|
|
reader = MagicMock()
|
|
feed = make_feed()
|
|
feed = typing.cast("Feed", feed)
|
|
reader.get_tag.return_value = ""
|
|
|
|
embed = get_embed(reader=reader, feed=feed)
|
|
|
|
assert embed.color == "#469ad9"
|
|
|
|
|
|
def test_get_embed_reads_embed_from_dict_tag() -> None:
|
|
reader = MagicMock()
|
|
feed = make_feed()
|
|
feed = typing.cast("Feed", feed)
|
|
reader.get_tag.return_value = {
|
|
"title": "Dict title",
|
|
"description": "Dict description",
|
|
"color": 123,
|
|
}
|
|
|
|
embed = get_embed(reader=reader, feed=feed)
|
|
|
|
assert embed.title == "Dict title"
|
|
assert embed.description == "Dict description"
|
|
assert embed.color == "123"
|
|
|
|
|
|
def test_get_embed_reads_embed_from_json_string() -> None:
|
|
reader = MagicMock()
|
|
feed = make_feed()
|
|
feed = typing.cast("Feed", feed)
|
|
reader.get_tag.return_value = '{"title": "Json title", "footer_text": "Json footer"}'
|
|
|
|
embed = get_embed(reader=reader, feed=feed)
|
|
|
|
assert embed.title == "Json title"
|
|
assert embed.footer_text == "Json footer"
|
|
|
|
|
|
def test_get_embed_data_coerces_values_to_strings() -> None:
|
|
embed = get_embed_data(
|
|
{
|
|
"title": 1,
|
|
"description": 2,
|
|
"color": 3,
|
|
"author_name": 4,
|
|
"author_url": 5,
|
|
"author_icon_url": 6,
|
|
"image_url": 7,
|
|
"thumbnail_url": 8,
|
|
"footer_text": 9,
|
|
"footer_icon_url": 10,
|
|
},
|
|
)
|
|
|
|
assert embed.title == "1"
|
|
assert embed.footer_icon_url == "10"
|