From 46d84ed3086e4daebf9040bac7cfa9cfc5c01dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Sat, 18 Jul 2026 04:40:13 +0200 Subject: [PATCH] Add more tests --- tests/test_custom_message.py | 68 +++++++++++++++ tests/test_main.py | 164 +++++++++++++++++++++++++++++++---- 2 files changed, 214 insertions(+), 18 deletions(-) diff --git a/tests/test_custom_message.py b/tests/test_custom_message.py index ecd744a..b3b8ce5 100644 --- a/tests/test_custom_message.py +++ b/tests/test_custom_message.py @@ -14,6 +14,8 @@ 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 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 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 @@ -429,3 +431,69 @@ def test_get_embed_data_coerces_values_to_strings() -> None: assert embed.title == "1" assert embed.footer_icon_url == "10" assert embed.show_steam_game_icon_in_thumbnail is True + + +class TestNormalizeMessageUsername: + """Tests for normalize_message_username.""" + + def test_empty_returns_empty(self) -> None: + assert not normalize_message_username(None) + assert not normalize_message_username("") + + def test_whitespace_only_returns_empty(self) -> None: + assert not normalize_message_username(" ") + assert not normalize_message_username("\t\n") + + def test_valid_username_returns_stripped(self) -> None: + assert normalize_message_username(" Power Of The Shell ") == "Power Of The Shell" + + def test_too_long_returns_empty(self) -> None: + long_name: str = "a" * 81 + assert not normalize_message_username(long_name) + + def test_max_length_allowed(self) -> None: + name: str = "a" * 80 + assert normalize_message_username(name) == name + + @pytest.mark.parametrize("char", ["@", "#", ":", "`"]) + def test_forbidden_characters_returns_empty(self, char: str) -> None: + assert not normalize_message_username(f"valid{char}name") + + @pytest.mark.parametrize("substring", ["clyde", "Clyde", "CLYDE", "discord", "Discord", "DISCOrd"]) + def test_forbidden_substrings_returns_empty(self, substring: str) -> None: + assert not normalize_message_username(f"my_{substring}_name") + + +class TestNormalizeMessageAvatarUrl: + """Tests for normalize_message_avatar_url.""" + + def test_empty_returns_empty(self) -> None: + assert not normalize_message_avatar_url(None) + assert not normalize_message_avatar_url("") + + def test_whitespace_only_returns_empty(self) -> None: + assert not normalize_message_avatar_url(" ") + + def test_valid_http_url_returns_stripped(self) -> None: + url: str = "http://example.com/icon.png" + assert normalize_message_avatar_url(url) == url + + def test_valid_https_url_returns_stripped(self) -> None: + url: str = "https://cdn.example.com/images/avatar.jpg" + assert normalize_message_avatar_url(url) == url + + def test_whitespace_around_url_is_stripped(self) -> None: + url: str = "https://example.com/icon.png" + assert normalize_message_avatar_url(f" {url} ") == url + + def test_ftp_scheme_returns_empty(self) -> None: + assert not normalize_message_avatar_url("ftp://example.com/icon.png") + + def test_no_scheme_returns_empty(self) -> None: + assert not normalize_message_avatar_url("example.com/icon.png") + + def test_javascript_url_returns_empty(self) -> None: + assert not normalize_message_avatar_url("javascript:alert(1)") + + def test_invalid_url_returns_empty(self) -> None: + assert not normalize_message_avatar_url("not-a-url") diff --git a/tests/test_main.py b/tests/test_main.py index 710499d..4b8fe1f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -759,7 +759,7 @@ def test_set_feed_save_sent_webhooks_route_updates_stored_tag() -> None: assert feed_url == self.feed.url return self.feed - def set_tag(self, resource: str, key: str, value: bool) -> None: # noqa: FBT001 + def set_tag(self, resource: str, key: str, value: bool) -> None: # ruff:ignore[boolean-type-hint-positional-argument] self.tags[resource, key] = value stub_reader = StubReader() @@ -2301,7 +2301,7 @@ def test_bulk_change_feed_urls_updates_matching_feeds() -> None: def get_entries(self, **_kwargs: TestKwargValue) -> list[Entry]: return [] - def set_entry_read(self, _entry: Entry, _value: bool) -> None: # noqa: FBT001 + def set_entry_read(self, _entry: Entry, _value: bool) -> None: # ruff:ignore[boolean-type-hint-positional-argument] return stub_reader = StubReader() @@ -2386,7 +2386,7 @@ def test_webhook_entries_mass_update_preview_fragment_endpoint() -> None: app.dependency_overrides = {} -def test_bulk_change_feed_urls_force_update_overwrites_conflict() -> None: # noqa: C901 +def test_bulk_change_feed_urls_force_update_overwrites_conflict() -> None: # ruff:ignore[complex-structure] """Force update should overwrite conflicting target URLs instead of skipping them.""" @dataclass(slots=True) @@ -2429,7 +2429,7 @@ def test_bulk_change_feed_urls_force_update_overwrites_conflict() -> None: # no def get_entries(self, **_kwargs: TestKwargValue) -> list[Entry]: return [] - def set_entry_read(self, _entry: Entry, _value: bool) -> None: # noqa: FBT001 + def set_entry_read(self, _entry: Entry, _value: bool) -> None: # ruff:ignore[boolean-type-hint-positional-argument] return stub_reader = StubReader() @@ -2503,7 +2503,7 @@ def test_bulk_change_feed_urls_force_update_ignores_resolution_error() -> None: def get_entries(self, **_kwargs: TestKwargValue) -> list[Entry]: return [] - def set_entry_read(self, _entry: Entry, _value: bool) -> None: # noqa: FBT001 + def set_entry_read(self, _entry: Entry, _value: bool) -> None: # ruff:ignore[boolean-type-hint-positional-argument] return stub_reader = StubReader() @@ -2903,10 +2903,13 @@ def test_post_set_custom_saves_message() -> None: ) assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" - stub.set_tag.assert_called_once() - _feed_arg, key_arg, value_arg = stub.set_tag.call_args.args - assert key_arg == "custom_message" - assert value_arg == "Hello {{entry_title}}!" + assert stub.set_tag.call_count == 3 + # First two calls should be username/avatar + assert stub.set_tag.call_args_list[0].args[1] == "message_username" + assert stub.set_tag.call_args_list[1].args[1] == "message_avatar_url" + # Third call should be custom_message + assert stub.set_tag.call_args_list[2].args[1] == "custom_message" + assert stub.set_tag.call_args_list[2].args[2] == "Hello {{entry_title}}!" finally: app.dependency_overrides = {} @@ -2931,10 +2934,13 @@ def test_post_set_custom_allows_clearing_message() -> None: ) assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" - stub.set_tag.assert_called_once() - _feed_arg, key_arg, value_arg = stub.set_tag.call_args.args - assert key_arg == "custom_message" - assert not value_arg, f"Expected empty custom_message to be saved, got {value_arg!r}" + assert stub.set_tag.call_count == 3 + # First two calls should be username/avatar + assert stub.set_tag.call_args_list[0].args[1] == "message_username" + assert stub.set_tag.call_args_list[1].args[1] == "message_avatar_url" + # Third call should be custom_message (empty, since it changed from stored) + assert stub.set_tag.call_args_list[2].args[1] == "custom_message" + assert not stub.set_tag.call_args_list[2].args[2], "Expected empty custom_message to be saved" finally: app.dependency_overrides = {} @@ -2957,7 +2963,10 @@ def test_post_set_custom_unchanged_message_does_not_write() -> None: ) assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" - stub.set_tag.assert_not_called() + # Username/avatar are always stored (even if blank), so expect those calls but not for custom_message + assert stub.set_tag.call_count == 2 + keys = [c.args[1] for c in stub.set_tag.call_args_list] + assert keys == ["message_username", "message_avatar_url"] finally: app.dependency_overrides = {} @@ -2981,9 +2990,128 @@ def test_post_set_custom_clearing_from_default_message() -> None: ) assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" - stub.set_tag.assert_called_once() - _feed_arg, _key_arg, value_arg = stub.set_tag.call_args.args - # Must be "" not the default. - assert not value_arg, f"Expected empty string to be saved, got {value_arg!r}" + assert stub.set_tag.call_count == 3 + # First two calls should be username/avatar + assert stub.set_tag.call_args_list[0].args[1] == "message_username" + assert stub.set_tag.call_args_list[1].args[1] == "message_avatar_url" + # Third call should be custom_message (empty, since it changed from stored) + assert stub.set_tag.call_args_list[2].args[1] == "custom_message" + assert not stub.set_tag.call_args_list[2].args[2], "Expected empty string to be saved" + finally: + app.dependency_overrides = {} + + +def test_post_set_custom_saves_username_and_avatar() -> None: + """Saving a custom message with username and avatar should persist all fields.""" + stub = _make_stub_reader_for_custom() + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + with patch("discord_rss_bot.main.commit_state_change"): + response: Response = client.post( + url="/custom", + data={ + "feed_url": feed_url, + "custom_message": "Hello {{entry_title}}!", + "message_username": "My Bot", + "message_avatar_url": "https://example.com/avatar.png", + }, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + assert stub.set_tag.call_count == 3 + keys = [c.args[1] for c in stub.set_tag.call_args_list] + assert keys == ["message_username", "message_avatar_url", "custom_message"] + assert stub.set_tag.call_args_list[0].args[2] == "My Bot" + assert stub.set_tag.call_args_list[1].args[2] == "https://example.com/avatar.png" + assert stub.set_tag.call_args_list[2].args[2] == "Hello {{entry_title}}!" + finally: + app.dependency_overrides = {} + + +def test_post_set_custom_with_username_only() -> None: + """Submitting with a username but no avatar should store the username.""" + existing = "{{entry_title}}\n{{entry_link}}" + stub = _make_stub_reader_for_custom(stored_custom_message=existing) + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + with patch("discord_rss_bot.main.commit_state_change"): + response: Response = client.post( + url="/custom", + data={ + "feed_url": feed_url, + "custom_message": existing, + "message_username": "New Name", + "message_avatar_url": "", + }, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + assert stub.set_tag.call_count == 2 + assert stub.set_tag.call_args_list[0].args[1] == "message_username" + assert stub.set_tag.call_args_list[0].args[2] == "New Name" + assert stub.set_tag.call_args_list[1].args[1] == "message_avatar_url" + assert not stub.set_tag.call_args_list[1].args[2] + finally: + app.dependency_overrides = {} + + +def test_post_set_custom_with_avatar_only() -> None: + """Submitting with an avatar but no username should store the avatar.""" + existing = "{{entry_title}}\n{{entry_link}}" + stub = _make_stub_reader_for_custom(stored_custom_message=existing) + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + with patch("discord_rss_bot.main.commit_state_change"): + response: Response = client.post( + url="/custom", + data={ + "feed_url": feed_url, + "custom_message": existing, + "message_username": "", + "message_avatar_url": "https://example.com/icon.png", + }, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + assert stub.set_tag.call_count == 2 + assert stub.set_tag.call_args_list[0].args[1] == "message_username" + assert not stub.set_tag.call_args_list[0].args[2] + assert stub.set_tag.call_args_list[1].args[1] == "message_avatar_url" + assert stub.set_tag.call_args_list[1].args[2] == "https://example.com/icon.png" + finally: + app.dependency_overrides = {} + + +def test_post_set_custom_clears_username_and_avatar() -> None: + """Clearing both identity fields should store empty strings.""" + existing = "{{entry_title}}\n{{entry_link}}" + stub = _make_stub_reader_for_custom(stored_custom_message=existing) + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + with patch("discord_rss_bot.main.commit_state_change"): + response: Response = client.post( + url="/custom", + data={ + "feed_url": feed_url, + "custom_message": existing, + "message_username": "", + "message_avatar_url": "", + }, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + assert stub.set_tag.call_count == 2 + assert stub.set_tag.call_args_list[0].args[1] == "message_username" + assert not stub.set_tag.call_args_list[0].args[2] + assert stub.set_tag.call_args_list[1].args[1] == "message_avatar_url" + assert not stub.set_tag.call_args_list[1].args[2] finally: app.dependency_overrides = {}