Compare commits

..

2 commits

Author SHA1 Message Date
a22601a854
Add more tests
Some checks failed
Test and build Docker image / docker (push) Failing after 1s
2026-05-31 01:18:28 +02:00
c481c7c88f
Fix warnings; use httpx2 and authors_str 2026-05-31 01:04:58 +02:00
15 changed files with 297 additions and 51 deletions

View file

@ -49,10 +49,10 @@ jobs:
docker buildx inspect --bootstrap
- name: Lint Python code
run: ruff check --exit-non-zero-on-fix --verbose
run: ruff check --exit-non-zero-on-fix
- name: Check Python formatting
run: ruff format --check --verbose
run: ruff format --check
- name: Lint Dockerfile
run: docker build --check .

View file

@ -155,7 +155,7 @@ def replace_tags_in_text_message(entry: Entry, reader: Reader) -> str:
entry_updated: str = entry.updated.strftime("%Y-%m-%d %H:%M:%S") if entry.updated else "Never"
list_of_replacements: list[dict[str, str]] = [
{"{{feed_author}}": feed.author or ""},
{"{{feed_author}}": feed.authors_str or ""},
{"{{feed_added}}": feed_added},
{"{{feed_last_exception}}": feed_last_exception},
{"{{feed_last_updated}}": feed_last_updated},
@ -168,7 +168,7 @@ def replace_tags_in_text_message(entry: Entry, reader: Reader) -> str:
{"{{feed_user_title}}": feed.user_title or ""},
{"{{feed_version}}": feed.version or ""},
{"{{entry_added}}": entry_added},
{"{{entry_author}}": entry.author or ""},
{"{{entry_author}}": entry.authors_str or ""},
{"{{entry_content}}": content},
{"{{entry_content_raw}}": entry.content[0].value if entry.content else ""},
{"{{entry_id}}": entry.id or ""},
@ -318,7 +318,7 @@ def replace_tags_in_embed(feed: Feed, entry: Entry, reader: Reader) -> CustomEmb
embed.title = ""
list_of_replacements: list[dict[str, str]] = [
{"{{feed_author}}": feed.author or ""},
{"{{feed_author}}": feed.authors_str or ""},
{"{{feed_added}}": feed_added or ""},
{"{{feed_last_exception}}": feed_last_exception},
{"{{feed_last_updated}}": feed_last_updated or ""},
@ -331,7 +331,7 @@ def replace_tags_in_embed(feed: Feed, entry: Entry, reader: Reader) -> CustomEmb
{"{{feed_user_title}}": feed.user_title or ""},
{"{{feed_version}}": feed.version or ""},
{"{{entry_added}}": entry_added or ""},
{"{{entry_author}}": entry.author or ""},
{"{{entry_author}}": entry.authors_str or ""},
{"{{entry_content}}": content or ""},
{"{{entry_content_raw}}": entry.content[0].value if entry.content else ""},
{"{{entry_id}}": entry.id},

View file

@ -22,9 +22,11 @@ from urllib.parse import parse_qs
from urllib.parse import urljoin
from urllib.parse import urlparse
import httpx
import httpx2
import tldextract
from fastapi import HTTPException
from httpx2 import HTTPError
from httpx2 import Response
from markdownify import markdownify
from playwright.sync_api import Browser
from playwright.sync_api import Page
@ -702,7 +704,7 @@ def get_webhook_files(webhook: DiscordWebhook) -> list[WebhookFile]: # noqa: C9
return files
def get_retry_after_seconds(response: httpx.Response) -> float | None:
def get_retry_after_seconds(response: Response) -> float | None:
"""Return Discord's retry delay for a rate-limited response when available."""
response_json: JsonObject = get_response_json(response)
retry_after: JsonValue = response_json.get("retry_after")
@ -727,7 +729,7 @@ def request_discord_webhook(
files: list[WebhookFile] | None,
timeout: float,
rate_limit_retry: bool,
) -> httpx.Response:
) -> Response:
"""Send a Discord webhook request with optional multipart files.
Returns:
@ -742,7 +744,7 @@ def request_discord_webhook(
else:
request_kwargs["json"] = payload
response: httpx.Response = httpx.request(method, url, **request_kwargs)
response: Response = httpx2.request(method, url, **request_kwargs)
if not rate_limit_retry or response.status_code != 429: # noqa: PLR2004
return response
@ -751,11 +753,11 @@ def request_discord_webhook(
return response
time.sleep(max(0.0, retry_after))
return httpx.request(method, url, **request_kwargs)
return httpx2.request(method, url, **request_kwargs)
def send_webhook_message(webhook: DiscordWebhook, payload: JsonObject) -> httpx.Response:
"""Execute a Discord webhook message create request using httpx.
def send_webhook_message(webhook: DiscordWebhook, payload: JsonObject) -> Response:
"""Execute a Discord webhook message create request using httpx2.
Returns:
Discord API response.
@ -777,11 +779,11 @@ def edit_sent_webhook_message(
message_id: str,
webhook: DiscordWebhook,
payload: JsonObject,
) -> httpx.Response:
) -> Response:
"""Edit an already-sent Discord webhook message.
Returns:
httpx.Response: Discord API response.
Response: Discord API response.
"""
clean_webhook_url, params = get_webhook_query_params(webhook_url, payload, webhook=webhook, wait=True)
return request_discord_webhook(
@ -938,8 +940,13 @@ def update_sent_webhook_record_for_entry(
now: str = datetime.datetime.now(tz=datetime.UTC).isoformat()
try:
response = edit_sent_webhook_message(webhook_url_value, message_id_value, webhook, edit_payload)
except (AssertionError, RequestException, httpx.HTTPError, OSError, ValueError) as e:
response: Response = edit_sent_webhook_message(
webhook_url=webhook_url_value,
message_id=message_id_value,
webhook=webhook,
payload=edit_payload,
)
except (AssertionError, RequestException, HTTPError, OSError, ValueError) as e:
logger.exception("Failed to edit Discord webhook message %s for entry %s", message_id_value, entry.id)
return (
{
@ -1484,13 +1491,13 @@ def fetch_ttvdrops_campaign_media_items(entry: Entry) -> list[JsonObject]:
return []
try:
response: httpx.Response = httpx.get(api_url, follow_redirects=True, timeout=10.0)
response: Response = httpx2.get(api_url, follow_redirects=True, timeout=10.0)
if response.status_code != 200: # noqa: PLR2004
logger.warning("Failed to fetch ttvdrops campaign data from %s: %s", api_url, response.text[:500])
return []
response_json = cast("JsonValue", response.json())
except (httpx.HTTPError, ValueError, TypeError):
except (HTTPError, ValueError, TypeError):
logger.exception("Failed to fetch ttvdrops campaign data from %s", api_url)
return []
@ -1759,7 +1766,7 @@ def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, d
)
try:
update_sent_webhooks_for_modified_entries(effective_reader, modified_entries)
except (AssertionError, ReaderError, RequestException, httpx.HTTPError, OSError, ValueError):
except (AssertionError, ReaderError, RequestException, HTTPError, OSError, ValueError):
logger.exception("Failed to update saved Discord webhooks for modified feed entries.")
# Loop through the unread entries.
@ -1826,7 +1833,7 @@ def execute_webhook(
request_payload: JsonObject = get_webhook_request_payload(webhook)
payload: JsonObject = get_webhook_message_payload(webhook)
response: httpx.Response = send_webhook_message(webhook, request_payload)
response: Response = send_webhook_message(webhook, request_payload)
logger.debug("Discord webhook response for entry %s: status=%s", entry.id, response.status_code)
if response.status_code not in {200, 204}:
msg: str = f"Error sending entry to Discord: {response.text}\n{pprint.pformat(request_payload)}"

View file

@ -245,7 +245,7 @@ def get_entry_fields(entry: Entry) -> dict[str, str]:
"title": entry.title or "",
"summary": entry.summary or "",
"content": content_value,
"author": entry.author or "",
"author": entry.authors_str or "",
}

View file

@ -18,7 +18,7 @@ from typing import Annotated
from typing import TypedDict
from typing import cast
import httpx
import httpx2
import sentry_sdk
import uvicorn
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@ -30,7 +30,8 @@ from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from httpx import Response
from httpx2 import HTTPError
from httpx2 import Response
from markdownify import markdownify
from reader import Entry
from reader import EntryNotFoundError
@ -82,6 +83,7 @@ from discord_rss_bot.settings import get_reader
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from collections.abc import Iterable
from pathlib import Path
from reader.types import JSONType
@ -1881,7 +1883,7 @@ def create_html_for_feed( # noqa: C901, PLR0914
html += f"""<div class="p-2 mb-2 border border-dark">
{blacklisted}{whitelisted}{from_another_feed}<a class="text-muted text-decoration-none" href="{entry.link}"><h2>{entry.title}</h2></a>
{feed_link}{f"By {entry.author} @" if entry.author else ""}{published} - {to_discord_html}
{feed_link}{f"By {entry.authors_str} @" if entry.authors_str else ""}{published} - {to_discord_html}
{text}
{video_embed_html}
@ -1939,8 +1941,8 @@ def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo:
return our_hook
try:
response: Response = httpx.get(clean_hook_url, timeout=10.0)
except httpx.HTTPError as e:
response: Response = httpx2.get(clean_hook_url, timeout=10.0)
except HTTPError as e:
logger.warning("Failed to fetch webhook metadata for %s: %s", clean_hook_url, e)
return our_hook
@ -2209,7 +2211,7 @@ async def update_feed(
try:
update_sent_webhooks_for_modified_entries(reader, modified_entries)
except (AssertionError, ReaderError, httpx.HTTPError, OSError, ValueError):
except (AssertionError, ReaderError, HTTPError, OSError, ValueError):
logger.exception("Failed to update saved Discord webhooks for manually updated feed: %s", feed_url)
logger.info("Manually updated feed: %s", feed_url)
@ -2230,18 +2232,18 @@ async def manual_backup(
Returns:
RedirectResponse: Redirect to the index page with a success or error message.
"""
backup_path = get_backup_path()
backup_path: Path | None = get_backup_path()
if backup_path is None:
message = "Git backup is not configured. Set GIT_BACKUP_PATH environment variable to enable backups."
message: str = "Git backup is not configured. Set GIT_BACKUP_PATH environment variable to enable backups."
logger.warning("Manual git backup attempted but GIT_BACKUP_PATH is not configured")
return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303)
try:
commit_state_change(reader, "Manual backup triggered from web UI")
message = "Successfully created git backup!"
message: str = "Successfully created git backup!"
logger.info("Manual git backup completed successfully")
except Exception as e:
message = f"Failed to create git backup: {e}"
message: str = f"Failed to create git backup: {e}"
logger.exception("Manual git backup failed")
return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303)
@ -2419,8 +2421,8 @@ def resolve_final_feed_url(url: str) -> tuple[str, str | None]:
return clean_url, "URL is invalid"
try:
response: Response = httpx.get(clean_url, follow_redirects=True, timeout=10.0)
except httpx.HTTPError as e:
response: Response = httpx2.get(clean_url, follow_redirects=True, timeout=10.0)
except HTTPError as e:
return clean_url, str(e)
if not response.is_success:

View file

@ -33,7 +33,7 @@
{% endif %}
</h5>
<p class="text-muted small mb-0">
{% if row.entry.author %}By {{ row.entry.author }} |{% endif %}
{% if row.entry.authors_str %}By {{ row.entry.authors_str }} |{% endif %}
{{ row.published_label }}
</p>
</div>

View file

@ -21,7 +21,7 @@
{% raw %}
{{feed_author}}
{% endraw %}
</code>{{ feed.author }}
</code>{{ feed.authors_str }}
</li>
<li>
<code>
@ -114,7 +114,7 @@
{% raw %}
{{entry_author}}
{% endraw %}
</code>{{ entry.author }}
</code>{{ entry.authors_str }}
</li>
{% if entry.content %}
<li>

View file

@ -14,7 +14,7 @@
{% raw %}
{{feed_author}}
{% endraw %}
</code>{{feed.author}}
</code>{{feed.authors_str}}
</li>
<li>
<code>
@ -107,7 +107,7 @@
{% raw %}
{{entry_author}}
{% endraw %}
</code>{{entry.author}}
</code>{{entry.authors_str}}
</li>
{% if entry.content %}
<li>

View file

@ -82,7 +82,7 @@ class DiscordWebhook:
"""Discord webhook request data.
This intentionally mirrors the subset of `discord-webhook` used by the app
while leaving the actual HTTP transport to `httpx`.
while leaving the actual HTTP transport to `httpx2`.
"""
def __init__( # noqa: D107

View file

@ -7,7 +7,7 @@ requires-python = ">=3.12"
dependencies = [
"apscheduler>=3.11.0",
"fastapi",
"httpx",
"httpx2",
"jinja2",
"lxml",
"markdownify",

View file

@ -2,7 +2,9 @@ from __future__ import annotations
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING
from typing import cast
from reader import Entry
from reader import Feed
@ -12,6 +14,7 @@ from reader import make_reader
from discord_rss_bot.filter.blacklist import entry_should_be_skipped
from discord_rss_bot.filter.blacklist import feed_has_blacklist_tags
from discord_rss_bot.filter.evaluator import evaluate_entry_filters
from discord_rss_bot.filter.evaluator import get_entry_fields
from discord_rss_bot.filter.evaluator import get_filter_values_from_reader
if TYPE_CHECKING:
@ -67,6 +70,23 @@ def check_if_has_tag(reader: Reader, feed: Feed, blacklist_name: str) -> None:
assert feed_has_blacklist_tags(reader=reader, feed=feed) is False, asset_msg
def test_get_entry_fields_uses_authors_str() -> None:
entry = cast(
"Entry",
SimpleNamespace(
title="Title",
summary="Summary",
content=[],
author="Legacy Author",
authors_str="Author One, Author Two",
),
)
fields: dict[str, str] = get_entry_fields(entry)
assert fields["author"] == "Author One, Author Two"
def test_should_be_skipped() -> None:
reader: Reader = get_reader()

View file

@ -42,6 +42,7 @@ def make_feed() -> SimpleNamespace:
return SimpleNamespace(
added=None,
author="Feed Author",
authors_str="Entry Author",
last_exception=None,
last_updated=None,
link="https://example.com/feed",
@ -60,6 +61,7 @@ def make_entry(summary: str) -> SimpleNamespace:
return SimpleNamespace(
added=None,
author="Entry Author",
authors_str="Entry Author",
content=[],
feed=feed,
feed_url=feed.url,
@ -186,6 +188,41 @@ def test_replace_tags_in_text_message_skips_non_string_replacement_values(
assert rendered == "{{entry_id}}"
@patch("discord_rss_bot.custom_message.get_custom_message")
def test_replace_tags_in_text_message_uses_authors_str(mock_get_custom_message: MagicMock) -> None:
mock_get_custom_message.return_value = "{{feed_author}} | {{entry_author}}"
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
entry_ns.feed.author = "Legacy Feed Author"
entry_ns.feed.authors_str = "Feed Author One, Feed Author Two"
entry_ns.author = "Legacy Entry Author"
entry_ns.authors_str = "Entry Author One, Entry Author Two"
rendered: str = replace_tags_in_text_message(
typing.cast("Entry", entry_ns),
reader=MagicMock(),
)
assert rendered == "Feed Author One, Feed Author Two | Entry Author One, Entry Author Two"
@patch("discord_rss_bot.custom_message.get_embed")
def test_replace_tags_in_embed_uses_authors_str(mock_get_embed: MagicMock) -> None:
mock_get_embed.return_value = CustomEmbed(description="{{feed_author}} | {{entry_author}}")
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
entry_ns.feed.author = "Legacy Feed Author"
entry_ns.feed.authors_str = "Feed Author One, Feed Author Two"
entry_ns.author = "Legacy Entry Author"
entry_ns.authors_str = "Entry Author One, Entry Author Two"
embed: CustomEmbed = replace_tags_in_embed(
entry_ns.feed,
typing.cast("Entry", entry_ns),
reader=MagicMock(),
)
assert embed.description == "Feed Author One, Feed Author Two | Entry Author One, Entry Author Two"
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>'

View file

@ -9,6 +9,7 @@ from pathlib import Path
from typing import LiteralString
from typing import cast
from unittest.mock import MagicMock
from unittest.mock import call
from unittest.mock import patch
import pytest
@ -792,7 +793,7 @@ def test_get_ttvdrops_campaign_api_url_from_campaign_page() -> None:
("https://ttvdrops.lovinator.space/twitch/feed.xml?hide_paid=0&hide_paid=1", False),
],
)
@patch("discord_rss_bot.feeds.httpx.get")
@patch("discord_rss_bot.feeds.httpx2.get")
def test_fetch_ttvdrops_campaign_media_items_extracts_reward_alt_text(
mock_get: MagicMock,
feed_url: str,
@ -1351,8 +1352,8 @@ def test_execute_webhook_does_not_record_when_feed_tracking_disabled(mock_send_w
reader.set_tag.assert_not_called()
@patch("discord_rss_bot.feeds.httpx.request")
def test_send_webhook_message_posts_components_with_httpx(mock_request: MagicMock) -> None:
@patch("discord_rss_bot.feeds.httpx2.request")
def test_send_webhook_message_posts_components_with_httpx2(mock_request: MagicMock) -> None:
response = MagicMock(status_code=200, text='{"id": "message-1"}')
mock_request.return_value = response
components: list[feeds.JsonValue] = [
@ -1383,7 +1384,7 @@ def test_send_webhook_message_posts_components_with_httpx(mock_request: MagicMoc
}
@patch("discord_rss_bot.feeds.httpx.request")
@patch("discord_rss_bot.feeds.httpx2.request")
def test_send_webhook_message_uploads_files_as_multipart(mock_request: MagicMock) -> None:
response = MagicMock(status_code=200, text='{"id": "message-2"}')
mock_request.return_value = response
@ -1400,6 +1401,64 @@ def test_send_webhook_message_uploads_files_as_multipart(mock_request: MagicMock
assert "json" not in mock_request.call_args.kwargs
@patch("discord_rss_bot.feeds.time.sleep")
@patch("discord_rss_bot.feeds.httpx2.request")
def test_request_discord_webhook_retries_rate_limit_with_httpx2(
mock_request: MagicMock,
mock_sleep: MagicMock,
) -> None:
rate_limited_response = MagicMock(status_code=429, headers={})
rate_limited_response.json.return_value = {"retry_after": 0.25}
success_response = MagicMock(status_code=200)
mock_request.side_effect = [rate_limited_response, success_response]
payload: JsonObject = {"content": "Retry entry"}
request_call = call(
"POST",
"https://discord.com/api/webhooks/123/abc",
params={"wait": "true"},
timeout=30.0,
json=payload,
)
result = feeds.request_discord_webhook(
"POST",
"https://discord.com/api/webhooks/123/abc",
payload=payload,
params={"wait": "true"},
files=None,
timeout=30.0,
rate_limit_retry=True,
)
assert result is success_response
assert mock_request.call_args_list == [request_call, request_call]
mock_sleep.assert_called_once_with(0.25)
@patch("discord_rss_bot.feeds.httpx2.request")
def test_edit_sent_webhook_message_patches_message_with_httpx2(mock_request: MagicMock) -> None:
response = MagicMock(status_code=200, text='{"id": "message-3"}')
mock_request.return_value = response
payload: JsonObject = {"content": "Updated entry"}
webhook = feeds.DiscordWebhook(url="https://discord.com/api/webhooks/123/abc")
result = feeds.edit_sent_webhook_message(
"https://discord.com/api/webhooks/123/abc?thread_id=456",
"message-3",
webhook,
payload,
)
assert result is response
mock_request.assert_called_once_with(
"PATCH",
"https://discord.com/api/webhooks/123/abc/messages/message-3",
params={"thread_id": "456", "wait": "true"},
timeout=30.0,
json=payload,
)
@patch("discord_rss_bot.feeds.edit_sent_webhook_message")
@patch("discord_rss_bot.feeds.create_webhook_for_entry")
def test_update_sent_webhooks_for_modified_entries_edits_changed_payload(
@ -1462,7 +1521,7 @@ def test_update_sent_webhooks_for_modified_entries_edits_changed_payload(
assert updated_count == 1
mock_edit_sent_webhook_message.assert_called_once()
edit_payload = mock_edit_sent_webhook_message.call_args.args[3]
edit_payload = mock_edit_sent_webhook_message.call_args.kwargs["payload"]
assert edit_payload == {"content": "New title"}
records = state["sent_webhooks"]
assert isinstance(records, list)
@ -1533,7 +1592,7 @@ def test_update_sent_webhook_record_preserves_existing_embed_image_when_updated_
assert record_changed is True
assert message_was_edited is True
edit_payload = mock_edit_sent_webhook_message.call_args.args[3]
edit_payload = mock_edit_sent_webhook_message.call_args.kwargs["payload"]
assert isinstance(edit_payload["embeds"], list)
assert edit_payload["embeds"][0]["image"] == previous_image
assert isinstance(updated_record["payload"], dict)

View file

@ -7,6 +7,7 @@ from dataclasses import dataclass
from dataclasses import field
from datetime import UTC
from datetime import datetime
from types import SimpleNamespace
from typing import TYPE_CHECKING
from typing import cast
from unittest.mock import MagicMock
@ -24,7 +25,7 @@ if TYPE_CHECKING:
from pathlib import Path
import pytest
from httpx import Response
from httpx2 import Response
from reader import Entry
from reader import Reader
@ -330,6 +331,7 @@ def test_blacklist_preview_uses_50_entry_limit() -> None:
title: str
summary: str
author: str
authors_str: str
link: str
published: datetime | None
content: list[DummyContent] = field(default_factory=lambda: [DummyContent("content")])
@ -347,6 +349,7 @@ def test_blacklist_preview_uses_50_entry_limit() -> None:
title=f"Entry {index}",
summary=f"Summary {index}",
author="Author",
authors_str="Author",
link=f"https://example.com/entry-{index}",
published=datetime(2024, 1, 1, tzinfo=UTC),
),
@ -403,6 +406,7 @@ def test_blacklist_preview_shows_labeled_field_values_for_substring_match() -> N
title: str
summary: str
author: str
authors_str: str
link: str
published: datetime | None
content: list[DummyContent] = field(default_factory=list)
@ -418,7 +422,8 @@ def test_blacklist_preview_shows_labeled_field_values_for_substring_match() -> N
feed=self.feed,
title="World of Warcraft",
summary="<p>Massive MMO news update</p>",
author="Blizzard",
author="Legacy Blizzard Author",
authors_str="Blizzard Author One, Blizzard Author Two",
link="https://example.com/wow-1",
published=datetime(2024, 1, 1, tzinfo=UTC),
content=[DummyContent("<p>The expansion launches soon.</p>")],
@ -460,10 +465,90 @@ def test_blacklist_preview_shows_labeled_field_values_for_substring_match() -> N
assert '<mark class="filter-preview__match filter-preview__match--danger">orld</mark>' in response.text
assert "Massive MMO news update" in response.text
assert "The expansion launches soon." in response.text
assert "By Blizzard Author One, Blizzard Author Two |" in response.text
assert "Legacy Blizzard Author" not in response.text
finally:
app.dependency_overrides = {}
def test_author_templates_render_authors_str() -> None:
request = SimpleNamespace(url="https://example.com/page", base_url="https://example.com/")
feed = SimpleNamespace(
title="Example Feed",
url="https://example.com/feed.xml",
author="Legacy Feed Author",
authors_str="Feed Author One, Feed Author Two",
added=None,
last_exception=None,
last_updated=None,
link="https://example.com/feed",
subtitle="",
updated=None,
updates_enabled=True,
user_title="",
version="atom10",
)
entry = SimpleNamespace(
id="entry-1",
title="Entry Title",
link="https://example.com/entry-1",
author="Legacy Entry Author",
authors_str="Entry Author One, Entry Author Two",
added=None,
content=[],
important=False,
published=None,
read=False,
read_modified=None,
summary="Summary",
updated=None,
)
filter_row = SimpleNamespace(
entry=entry,
published_label="Never",
status_class="success",
status_label="Sent",
decision=SimpleNamespace(reason="Sent", blacklist_match=None, whitelist_match=None),
field_rows=[],
first_image="",
)
preview_summary = SimpleNamespace(
total=1,
sent=1,
skipped=0,
blacklist_matches=0,
whitelist_matches=0,
)
custom_html: str = main_module.templates.get_template("custom.html").render(
request=request,
feed=feed,
entry=entry,
custom_message="",
)
embed_html: str = main_module.templates.get_template("embed.html").render(
request=request,
feed=feed,
entry=entry,
)
filter_preview_html: str = main_module.templates.get_template("_filter_preview.html").render(
feed=feed,
preview_limit=50,
preview_summary=preview_summary,
preview_helper_text="",
preview_rows=[filter_row],
)
for html in (custom_html, embed_html):
assert "Feed Author One, Feed Author Two" in html
assert "Entry Author One, Entry Author Two" in html
assert "Legacy Feed Author" not in html
assert "Legacy Entry Author" not in html
assert "By Entry Author One, Entry Author Two |" in filter_preview_html
assert "Legacy Entry Author" not in filter_preview_html
def test_settings_page_shows_screenshot_layout_setting() -> None:
response: Response = client.get(url="/settings")
assert response.status_code == 200, f"/settings failed: {response.text}"
@ -1460,7 +1545,8 @@ def test_create_html_marks_entries_from_another_feed(monkeypatch: pytest.MonkeyP
original_feed_url: str | None = None
link: str = "https://example.com/post"
title: str = "Example title"
author: str = "Author"
author: str = "Legacy Author"
authors_str: str = "Author One, Author Two"
summary: str = "Summary"
content: list[DummyContent] = field(default_factory=lambda: [DummyContent("Content")])
published: None = None
@ -1499,6 +1585,41 @@ def test_create_html_marks_entries_from_another_feed(monkeypatch: pytest.MonkeyP
assert "From another feed: https://example.com/feed-b.xml" in html
assert "From another feed: https://example.com/feed-a.xml" not in html
assert "By Author One, Author Two @" in html
assert "By Legacy Author @" not in html
@patch("discord_rss_bot.main.httpx2.get")
def test_get_data_from_hook_url_fetches_metadata_with_httpx2(mock_get: MagicMock) -> None:
hook_url = "https://discord.com/api/webhooks/123/token"
response = MagicMock(is_success=True)
response.text = (
'{"type": 1, "id": "123", "name": "Discord Hook", "avatar": "avatar", '
'"channel_id": "456", "guild_id": "789", "token": "token"}'
)
mock_get.return_value = response
main_module.get_data_from_hook_url.cache_clear()
hook_info = main_module.get_data_from_hook_url("Saved Hook", f" {hook_url} ")
mock_get.assert_called_once_with(hook_url, timeout=10.0)
assert hook_info.custom_name == "Saved Hook"
assert hook_info.name == "Discord Hook"
assert hook_info.channel_id == "456"
main_module.get_data_from_hook_url.cache_clear()
@patch("discord_rss_bot.main.httpx2.get")
def test_resolve_final_feed_url_follows_redirects_with_httpx2(mock_get: MagicMock) -> None:
response = MagicMock(is_success=True)
response.url = "https://example.com/final.xml"
mock_get.return_value = response
resolved_url, error = main_module.resolve_final_feed_url(" https://example.com/original.xml ")
mock_get.assert_called_once_with("https://example.com/original.xml", follow_redirects=True, timeout=10.0)
assert resolved_url == "https://example.com/final.xml"
assert error is None
def test_webhook_entries_webhook_not_found() -> None:

View file

@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
from discord_rss_bot.main import app
if TYPE_CHECKING:
from httpx import Response
from httpx2 import Response
client: TestClient = TestClient(app)
webhook_name: str = "Test Webhook for Update Interval"