Compare commits
2 commits
1065838ef7
...
a22601a854
| Author | SHA1 | Date | |
|---|---|---|---|
|
a22601a854 |
|||
|
c481c7c88f |
15 changed files with 297 additions and 51 deletions
|
|
@ -49,10 +49,10 @@ jobs:
|
||||||
docker buildx inspect --bootstrap
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
- name: Lint Python code
|
- 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
|
- name: Check Python formatting
|
||||||
run: ruff format --check --verbose
|
run: ruff format --check
|
||||||
|
|
||||||
- name: Lint Dockerfile
|
- name: Lint Dockerfile
|
||||||
run: docker build --check .
|
run: docker build --check .
|
||||||
|
|
|
||||||
|
|
@ -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"
|
entry_updated: str = entry.updated.strftime("%Y-%m-%d %H:%M:%S") if entry.updated else "Never"
|
||||||
|
|
||||||
list_of_replacements: list[dict[str, str]] = [
|
list_of_replacements: list[dict[str, str]] = [
|
||||||
{"{{feed_author}}": feed.author or ""},
|
{"{{feed_author}}": feed.authors_str or ""},
|
||||||
{"{{feed_added}}": feed_added},
|
{"{{feed_added}}": feed_added},
|
||||||
{"{{feed_last_exception}}": feed_last_exception},
|
{"{{feed_last_exception}}": feed_last_exception},
|
||||||
{"{{feed_last_updated}}": feed_last_updated},
|
{"{{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_user_title}}": feed.user_title or ""},
|
||||||
{"{{feed_version}}": feed.version or ""},
|
{"{{feed_version}}": feed.version or ""},
|
||||||
{"{{entry_added}}": entry_added},
|
{"{{entry_added}}": entry_added},
|
||||||
{"{{entry_author}}": entry.author or ""},
|
{"{{entry_author}}": entry.authors_str or ""},
|
||||||
{"{{entry_content}}": content},
|
{"{{entry_content}}": content},
|
||||||
{"{{entry_content_raw}}": entry.content[0].value if entry.content else ""},
|
{"{{entry_content_raw}}": entry.content[0].value if entry.content else ""},
|
||||||
{"{{entry_id}}": entry.id or ""},
|
{"{{entry_id}}": entry.id or ""},
|
||||||
|
|
@ -318,7 +318,7 @@ def replace_tags_in_embed(feed: Feed, entry: Entry, reader: Reader) -> CustomEmb
|
||||||
embed.title = ""
|
embed.title = ""
|
||||||
|
|
||||||
list_of_replacements: list[dict[str, str]] = [
|
list_of_replacements: list[dict[str, str]] = [
|
||||||
{"{{feed_author}}": feed.author or ""},
|
{"{{feed_author}}": feed.authors_str or ""},
|
||||||
{"{{feed_added}}": feed_added or ""},
|
{"{{feed_added}}": feed_added or ""},
|
||||||
{"{{feed_last_exception}}": feed_last_exception},
|
{"{{feed_last_exception}}": feed_last_exception},
|
||||||
{"{{feed_last_updated}}": feed_last_updated or ""},
|
{"{{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_user_title}}": feed.user_title or ""},
|
||||||
{"{{feed_version}}": feed.version or ""},
|
{"{{feed_version}}": feed.version or ""},
|
||||||
{"{{entry_added}}": entry_added or ""},
|
{"{{entry_added}}": entry_added or ""},
|
||||||
{"{{entry_author}}": entry.author or ""},
|
{"{{entry_author}}": entry.authors_str or ""},
|
||||||
{"{{entry_content}}": content or ""},
|
{"{{entry_content}}": content or ""},
|
||||||
{"{{entry_content_raw}}": entry.content[0].value if entry.content else ""},
|
{"{{entry_content_raw}}": entry.content[0].value if entry.content else ""},
|
||||||
{"{{entry_id}}": entry.id},
|
{"{{entry_id}}": entry.id},
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,11 @@ from urllib.parse import parse_qs
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx2
|
||||||
import tldextract
|
import tldextract
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from httpx2 import HTTPError
|
||||||
|
from httpx2 import Response
|
||||||
from markdownify import markdownify
|
from markdownify import markdownify
|
||||||
from playwright.sync_api import Browser
|
from playwright.sync_api import Browser
|
||||||
from playwright.sync_api import Page
|
from playwright.sync_api import Page
|
||||||
|
|
@ -702,7 +704,7 @@ def get_webhook_files(webhook: DiscordWebhook) -> list[WebhookFile]: # noqa: C9
|
||||||
return files
|
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."""
|
"""Return Discord's retry delay for a rate-limited response when available."""
|
||||||
response_json: JsonObject = get_response_json(response)
|
response_json: JsonObject = get_response_json(response)
|
||||||
retry_after: JsonValue = response_json.get("retry_after")
|
retry_after: JsonValue = response_json.get("retry_after")
|
||||||
|
|
@ -727,7 +729,7 @@ def request_discord_webhook(
|
||||||
files: list[WebhookFile] | None,
|
files: list[WebhookFile] | None,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
rate_limit_retry: bool,
|
rate_limit_retry: bool,
|
||||||
) -> httpx.Response:
|
) -> Response:
|
||||||
"""Send a Discord webhook request with optional multipart files.
|
"""Send a Discord webhook request with optional multipart files.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -742,7 +744,7 @@ def request_discord_webhook(
|
||||||
else:
|
else:
|
||||||
request_kwargs["json"] = payload
|
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
|
if not rate_limit_retry or response.status_code != 429: # noqa: PLR2004
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -751,11 +753,11 @@ def request_discord_webhook(
|
||||||
return response
|
return response
|
||||||
|
|
||||||
time.sleep(max(0.0, retry_after))
|
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:
|
def send_webhook_message(webhook: DiscordWebhook, payload: JsonObject) -> Response:
|
||||||
"""Execute a Discord webhook message create request using httpx.
|
"""Execute a Discord webhook message create request using httpx2.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Discord API response.
|
Discord API response.
|
||||||
|
|
@ -777,11 +779,11 @@ def edit_sent_webhook_message(
|
||||||
message_id: str,
|
message_id: str,
|
||||||
webhook: DiscordWebhook,
|
webhook: DiscordWebhook,
|
||||||
payload: JsonObject,
|
payload: JsonObject,
|
||||||
) -> httpx.Response:
|
) -> Response:
|
||||||
"""Edit an already-sent Discord webhook message.
|
"""Edit an already-sent Discord webhook message.
|
||||||
|
|
||||||
Returns:
|
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)
|
clean_webhook_url, params = get_webhook_query_params(webhook_url, payload, webhook=webhook, wait=True)
|
||||||
return request_discord_webhook(
|
return request_discord_webhook(
|
||||||
|
|
@ -938,8 +940,13 @@ def update_sent_webhook_record_for_entry(
|
||||||
|
|
||||||
now: str = datetime.datetime.now(tz=datetime.UTC).isoformat()
|
now: str = datetime.datetime.now(tz=datetime.UTC).isoformat()
|
||||||
try:
|
try:
|
||||||
response = edit_sent_webhook_message(webhook_url_value, message_id_value, webhook, edit_payload)
|
response: Response = edit_sent_webhook_message(
|
||||||
except (AssertionError, RequestException, httpx.HTTPError, OSError, ValueError) as e:
|
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)
|
logger.exception("Failed to edit Discord webhook message %s for entry %s", message_id_value, entry.id)
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
|
|
@ -1484,13 +1491,13 @@ def fetch_ttvdrops_campaign_media_items(entry: Entry) -> list[JsonObject]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
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
|
if response.status_code != 200: # noqa: PLR2004
|
||||||
logger.warning("Failed to fetch ttvdrops campaign data from %s: %s", api_url, response.text[:500])
|
logger.warning("Failed to fetch ttvdrops campaign data from %s: %s", api_url, response.text[:500])
|
||||||
return []
|
return []
|
||||||
|
|
||||||
response_json = cast("JsonValue", response.json())
|
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)
|
logger.exception("Failed to fetch ttvdrops campaign data from %s", api_url)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -1759,7 +1766,7 @@ def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, d
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
update_sent_webhooks_for_modified_entries(effective_reader, modified_entries)
|
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.")
|
logger.exception("Failed to update saved Discord webhooks for modified feed entries.")
|
||||||
|
|
||||||
# Loop through the unread entries.
|
# Loop through the unread entries.
|
||||||
|
|
@ -1826,7 +1833,7 @@ def execute_webhook(
|
||||||
|
|
||||||
request_payload: JsonObject = get_webhook_request_payload(webhook)
|
request_payload: JsonObject = get_webhook_request_payload(webhook)
|
||||||
payload: JsonObject = get_webhook_message_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)
|
logger.debug("Discord webhook response for entry %s: status=%s", entry.id, response.status_code)
|
||||||
if response.status_code not in {200, 204}:
|
if response.status_code not in {200, 204}:
|
||||||
msg: str = f"Error sending entry to Discord: {response.text}\n{pprint.pformat(request_payload)}"
|
msg: str = f"Error sending entry to Discord: {response.text}\n{pprint.pformat(request_payload)}"
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ def get_entry_fields(entry: Entry) -> dict[str, str]:
|
||||||
"title": entry.title or "",
|
"title": entry.title or "",
|
||||||
"summary": entry.summary or "",
|
"summary": entry.summary or "",
|
||||||
"content": content_value,
|
"content": content_value,
|
||||||
"author": entry.author or "",
|
"author": entry.authors_str or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from typing import Annotated
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import httpx
|
import httpx2
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
@ -30,7 +30,8 @@ from fastapi import Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from httpx import Response
|
from httpx2 import HTTPError
|
||||||
|
from httpx2 import Response
|
||||||
from markdownify import markdownify
|
from markdownify import markdownify
|
||||||
from reader import Entry
|
from reader import Entry
|
||||||
from reader import EntryNotFoundError
|
from reader import EntryNotFoundError
|
||||||
|
|
@ -82,6 +83,7 @@ from discord_rss_bot.settings import get_reader
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from reader.types import JSONType
|
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">
|
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>
|
{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}
|
{text}
|
||||||
{video_embed_html}
|
{video_embed_html}
|
||||||
|
|
@ -1939,8 +1941,8 @@ def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo:
|
||||||
return our_hook
|
return our_hook
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response: Response = httpx.get(clean_hook_url, timeout=10.0)
|
response: Response = httpx2.get(clean_hook_url, timeout=10.0)
|
||||||
except httpx.HTTPError as e:
|
except HTTPError as e:
|
||||||
logger.warning("Failed to fetch webhook metadata for %s: %s", clean_hook_url, e)
|
logger.warning("Failed to fetch webhook metadata for %s: %s", clean_hook_url, e)
|
||||||
return our_hook
|
return our_hook
|
||||||
|
|
||||||
|
|
@ -2209,7 +2211,7 @@ async def update_feed(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
update_sent_webhooks_for_modified_entries(reader, modified_entries)
|
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.exception("Failed to update saved Discord webhooks for manually updated feed: %s", feed_url)
|
||||||
|
|
||||||
logger.info("Manually updated feed: %s", feed_url)
|
logger.info("Manually updated feed: %s", feed_url)
|
||||||
|
|
@ -2230,18 +2232,18 @@ async def manual_backup(
|
||||||
Returns:
|
Returns:
|
||||||
RedirectResponse: Redirect to the index page with a success or error message.
|
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:
|
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")
|
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)
|
return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
commit_state_change(reader, "Manual backup triggered from web UI")
|
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")
|
logger.info("Manual git backup completed successfully")
|
||||||
except Exception as e:
|
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")
|
logger.exception("Manual git backup failed")
|
||||||
|
|
||||||
return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303)
|
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"
|
return clean_url, "URL is invalid"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response: Response = httpx.get(clean_url, follow_redirects=True, timeout=10.0)
|
response: Response = httpx2.get(clean_url, follow_redirects=True, timeout=10.0)
|
||||||
except httpx.HTTPError as e:
|
except HTTPError as e:
|
||||||
return clean_url, str(e)
|
return clean_url, str(e)
|
||||||
|
|
||||||
if not response.is_success:
|
if not response.is_success:
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="text-muted small mb-0">
|
<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 }}
|
{{ row.published_label }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{{feed_author}}
|
{{feed_author}}
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
</code>{{ feed.author }}
|
</code>{{ feed.authors_str }}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code>
|
<code>
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{{entry_author}}
|
{{entry_author}}
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
</code>{{ entry.author }}
|
</code>{{ entry.authors_str }}
|
||||||
</li>
|
</li>
|
||||||
{% if entry.content %}
|
{% if entry.content %}
|
||||||
<li>
|
<li>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{{feed_author}}
|
{{feed_author}}
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
</code>{{feed.author}}
|
</code>{{feed.authors_str}}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code>
|
<code>
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{{entry_author}}
|
{{entry_author}}
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
</code>{{entry.author}}
|
</code>{{entry.authors_str}}
|
||||||
</li>
|
</li>
|
||||||
{% if entry.content %}
|
{% if entry.content %}
|
||||||
<li>
|
<li>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class DiscordWebhook:
|
||||||
"""Discord webhook request data.
|
"""Discord webhook request data.
|
||||||
|
|
||||||
This intentionally mirrors the subset of `discord-webhook` used by the app
|
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
|
def __init__( # noqa: D107
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"apscheduler>=3.11.0",
|
"apscheduler>=3.11.0",
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"httpx",
|
"httpx2",
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"lxml",
|
"lxml",
|
||||||
"markdownify",
|
"markdownify",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from reader import Entry
|
from reader import Entry
|
||||||
from reader import Feed
|
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 entry_should_be_skipped
|
||||||
from discord_rss_bot.filter.blacklist import feed_has_blacklist_tags
|
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 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
|
from discord_rss_bot.filter.evaluator import get_filter_values_from_reader
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
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:
|
def test_should_be_skipped() -> None:
|
||||||
reader: Reader = get_reader()
|
reader: Reader = get_reader()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ def make_feed() -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
added=None,
|
added=None,
|
||||||
author="Feed Author",
|
author="Feed Author",
|
||||||
|
authors_str="Entry Author",
|
||||||
last_exception=None,
|
last_exception=None,
|
||||||
last_updated=None,
|
last_updated=None,
|
||||||
link="https://example.com/feed",
|
link="https://example.com/feed",
|
||||||
|
|
@ -60,6 +61,7 @@ def make_entry(summary: str) -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
added=None,
|
added=None,
|
||||||
author="Entry Author",
|
author="Entry Author",
|
||||||
|
authors_str="Entry Author",
|
||||||
content=[],
|
content=[],
|
||||||
feed=feed,
|
feed=feed,
|
||||||
feed_url=feed.url,
|
feed_url=feed.url,
|
||||||
|
|
@ -186,6 +188,41 @@ def test_replace_tags_in_text_message_skips_non_string_replacement_values(
|
||||||
assert rendered == "{{entry_id}}"
|
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:
|
def test_get_first_image_prefers_content_image_over_summary_image() -> None:
|
||||||
summary = '<p><img src="https://example.com/from-summary.jpg" /></p>'
|
summary = '<p><img src="https://example.com/from-summary.jpg" /></p>'
|
||||||
content = '<p><img src="https://example.com/from-content.jpg" /></p>'
|
content = '<p><img src="https://example.com/from-content.jpg" /></p>'
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from pathlib import Path
|
||||||
from typing import LiteralString
|
from typing import LiteralString
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
from unittest.mock import call
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
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),
|
("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(
|
def test_fetch_ttvdrops_campaign_media_items_extracts_reward_alt_text(
|
||||||
mock_get: MagicMock,
|
mock_get: MagicMock,
|
||||||
feed_url: str,
|
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()
|
reader.set_tag.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("discord_rss_bot.feeds.httpx.request")
|
@patch("discord_rss_bot.feeds.httpx2.request")
|
||||||
def test_send_webhook_message_posts_components_with_httpx(mock_request: MagicMock) -> None:
|
def test_send_webhook_message_posts_components_with_httpx2(mock_request: MagicMock) -> None:
|
||||||
response = MagicMock(status_code=200, text='{"id": "message-1"}')
|
response = MagicMock(status_code=200, text='{"id": "message-1"}')
|
||||||
mock_request.return_value = response
|
mock_request.return_value = response
|
||||||
components: list[feeds.JsonValue] = [
|
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:
|
def test_send_webhook_message_uploads_files_as_multipart(mock_request: MagicMock) -> None:
|
||||||
response = MagicMock(status_code=200, text='{"id": "message-2"}')
|
response = MagicMock(status_code=200, text='{"id": "message-2"}')
|
||||||
mock_request.return_value = response
|
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
|
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.edit_sent_webhook_message")
|
||||||
@patch("discord_rss_bot.feeds.create_webhook_for_entry")
|
@patch("discord_rss_bot.feeds.create_webhook_for_entry")
|
||||||
def test_update_sent_webhooks_for_modified_entries_edits_changed_payload(
|
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
|
assert updated_count == 1
|
||||||
mock_edit_sent_webhook_message.assert_called_once()
|
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"}
|
assert edit_payload == {"content": "New title"}
|
||||||
records = state["sent_webhooks"]
|
records = state["sent_webhooks"]
|
||||||
assert isinstance(records, list)
|
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 record_changed is True
|
||||||
assert message_was_edited 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 isinstance(edit_payload["embeds"], list)
|
||||||
assert edit_payload["embeds"][0]["image"] == previous_image
|
assert edit_payload["embeds"][0]["image"] == previous_image
|
||||||
assert isinstance(updated_record["payload"], dict)
|
assert isinstance(updated_record["payload"], dict)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||||
from dataclasses import field
|
from dataclasses import field
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
@ -24,7 +25,7 @@ if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import Response
|
from httpx2 import Response
|
||||||
from reader import Entry
|
from reader import Entry
|
||||||
from reader import Reader
|
from reader import Reader
|
||||||
|
|
||||||
|
|
@ -330,6 +331,7 @@ def test_blacklist_preview_uses_50_entry_limit() -> None:
|
||||||
title: str
|
title: str
|
||||||
summary: str
|
summary: str
|
||||||
author: str
|
author: str
|
||||||
|
authors_str: str
|
||||||
link: str
|
link: str
|
||||||
published: datetime | None
|
published: datetime | None
|
||||||
content: list[DummyContent] = field(default_factory=lambda: [DummyContent("content")])
|
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}",
|
title=f"Entry {index}",
|
||||||
summary=f"Summary {index}",
|
summary=f"Summary {index}",
|
||||||
author="Author",
|
author="Author",
|
||||||
|
authors_str="Author",
|
||||||
link=f"https://example.com/entry-{index}",
|
link=f"https://example.com/entry-{index}",
|
||||||
published=datetime(2024, 1, 1, tzinfo=UTC),
|
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
|
title: str
|
||||||
summary: str
|
summary: str
|
||||||
author: str
|
author: str
|
||||||
|
authors_str: str
|
||||||
link: str
|
link: str
|
||||||
published: datetime | None
|
published: datetime | None
|
||||||
content: list[DummyContent] = field(default_factory=list)
|
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,
|
feed=self.feed,
|
||||||
title="World of Warcraft",
|
title="World of Warcraft",
|
||||||
summary="<p>Massive MMO news update</p>",
|
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",
|
link="https://example.com/wow-1",
|
||||||
published=datetime(2024, 1, 1, tzinfo=UTC),
|
published=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
content=[DummyContent("<p>The expansion launches soon.</p>")],
|
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 '<mark class="filter-preview__match filter-preview__match--danger">orld</mark>' in response.text
|
||||||
assert "Massive MMO news update" in response.text
|
assert "Massive MMO news update" in response.text
|
||||||
assert "The expansion launches soon." 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:
|
finally:
|
||||||
app.dependency_overrides = {}
|
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:
|
def test_settings_page_shows_screenshot_layout_setting() -> None:
|
||||||
response: Response = client.get(url="/settings")
|
response: Response = client.get(url="/settings")
|
||||||
assert response.status_code == 200, f"/settings failed: {response.text}"
|
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
|
original_feed_url: str | None = None
|
||||||
link: str = "https://example.com/post"
|
link: str = "https://example.com/post"
|
||||||
title: str = "Example title"
|
title: str = "Example title"
|
||||||
author: str = "Author"
|
author: str = "Legacy Author"
|
||||||
|
authors_str: str = "Author One, Author Two"
|
||||||
summary: str = "Summary"
|
summary: str = "Summary"
|
||||||
content: list[DummyContent] = field(default_factory=lambda: [DummyContent("Content")])
|
content: list[DummyContent] = field(default_factory=lambda: [DummyContent("Content")])
|
||||||
published: None = None
|
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-b.xml" in html
|
||||||
assert "From another feed: https://example.com/feed-a.xml" not 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:
|
def test_webhook_entries_webhook_not_found() -> None:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
|
||||||
from discord_rss_bot.main import app
|
from discord_rss_bot.main import app
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from httpx import Response
|
from httpx2 import Response
|
||||||
|
|
||||||
client: TestClient = TestClient(app)
|
client: TestClient = TestClient(app)
|
||||||
webhook_name: str = "Test Webhook for Update Interval"
|
webhook_name: str = "Test Webhook for Update Interval"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue