From 2746b961b205544b91b98bef46eb36fe73b5eab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Sat, 18 Jul 2026 17:40:28 +0200 Subject: [PATCH] Add support for importing and exporting OMPL feeds --- discord_rss_bot/main.py | 198 ++++++++ .../templates/import_opml_preview.html | 117 +++++ discord_rss_bot/templates/settings.html | 451 ++++++++++-------- tests/test_main.py | 306 ++++++++++-- 4 files changed, 849 insertions(+), 223 deletions(-) create mode 100644 discord_rss_bot/templates/import_opml_preview.html diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index 1240f0b..e59d6f7 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import json import logging import logging.config @@ -25,9 +26,11 @@ import uvicorn from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import Depends from fastapi import FastAPI +from fastapi import File from fastapi import Form from fastapi import HTTPException from fastapi import Request +from fastapi import UploadFile from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -38,10 +41,12 @@ from reader import Entry from reader import EntryNotFoundError from reader import Feed from reader import FeedExistsError +from reader import FeedImportError from reader import FeedNotFoundError from reader import Reader from reader import ReaderError from reader import TagNotFoundError +from reader import opml from starlette.responses import RedirectResponse from starlette.responses import Response as StarletteResponse @@ -276,6 +281,196 @@ templates.env.globals["get_backup_path"] = get_backup_path # pyright: ignore[re templates.env.globals["has_webhooks"] = has_webhooks # pyright: ignore[reportArgumentType] +@app.get("/export_opml") +def export_opml( + reader: Annotated[Reader, Depends(get_reader_dependency)], +) -> StarletteResponse: + """Export all feeds as an OPML subscription list. + + Args: + reader: The Reader instance. + + Returns: + StarletteResponse: The OPML file for download. + """ + export = reader.export_feeds() + return StarletteResponse( + content=export.content, + status_code=200, + headers={ + "Content-Type": "application/xml", + "Content-Disposition": f'attachment; filename="{export.filename}"', + }, + ) + + +@app.post("/import_opml", response_model=None) +async def import_opml( + request: Request, + file: Annotated[UploadFile, File()], + reader: Annotated[Reader, Depends(get_reader_dependency)], +): + """Upload an OPML file and show a preview of feeds to import. + + Args: + request: The request object. + file: The uploaded OPML file. + reader: The Reader instance. + + Returns: + HTMLResponse: The OPML import preview page. + RedirectResponse: Redirect to settings on error. + """ + if not file.filename or not file.filename.lower().endswith(".opml"): + return RedirectResponse( + url=f"/settings?message={urllib.parse.quote('Please upload a file with a .opml extension.')}", + status_code=303, + ) + + try: + content: bytes = await file.read() + feeds_to_import = opml.parse(io.BytesIO(content)) + except FeedImportError as e: + return RedirectResponse( + url=f"/settings?message={urllib.parse.quote(f'Failed to parse OPML file: {e}')}", + status_code=303, + ) + + # Check which feeds already exist + existing_urls: set[str] = {feed.url for feed in reader.get_feeds()} + feed_list = [ + { + "url": feed.url, + "title": feed.title or feed.url, + "already_exists": feed.url in existing_urls, + } + for feed in feeds_to_import + ] + + context = { + "request": request, + "feeds": feed_list, + "total": len(feed_list), + "new_count": sum(1 for f in feed_list if not f["already_exists"]), + "existing_count": sum(1 for f in feed_list if f["already_exists"]), + "webhooks": reader.get_tag((), "webhooks", []), + } + return templates.TemplateResponse(request=request, name="import_opml_preview.html", context=context) + + +@app.post("/import_opml_confirm") +async def import_opml_confirm( + request: Request, + reader: Annotated[Reader, Depends(get_reader_dependency)], + feed_urls: Annotated[list[str] | None, Form()] = None, + webhook_name: Annotated[str | None, Form()] = None, +) -> RedirectResponse: + """Import the selected feeds from the OPML preview. + + Args: + request: The request object. + reader: The Reader instance. + feed_urls: The selected feed URLs to import. + webhook_name: Optional webhook name to attach imported feeds to. + + Returns: + RedirectResponse: Redirect to the settings page with a status message. + """ + if feed_urls is None: + feed_urls = [] + if not feed_urls: + return RedirectResponse( + url="/settings?message=No%20feeds%20were%20selected%20for%20import.", + status_code=303, + ) + + webhook_url = _resolve_webhook_url(reader, webhook_name) + + imported, updated_webhook, errors = _import_opml_feeds(reader, feed_urls, webhook_url) + message = _summarize_opml_import(imported, updated_webhook, errors) + + logger.info("OPML import complete: %s", message) + commit_state_change(reader, f"OPML import: {imported} feeds") + + return RedirectResponse(url=f"/settings?message={urllib.parse.quote(message)}", status_code=303) + + +def _resolve_webhook_url(reader: Reader, webhook_name: str | None) -> str: + """Resolve a webhook name to its URL from reader storage. + + Args: + reader: The Reader instance. + webhook_name: The webhook name to look up. + + Returns: + The webhook URL, or empty string if not found or no name given. + """ + if not webhook_name: + return "" + hooks = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", []))) + for hook in hooks: + if hook.get("name") == webhook_name.strip(): + return hook.get("url", "").strip() + return "" + + +def _import_opml_feeds( + reader: Reader, + feed_urls: list[str], + webhook_url: str, +) -> tuple[int, int, list[str]]: + """Add feeds from an OPML import, optionally setting webhooks. + + Args: + reader: The Reader instance. + feed_urls: The feed URLs to add. + webhook_url: Webhook URL to attach, or empty string. + + Returns: + A tuple of (imported_count, updated_webhook_count, error_messages). + """ + imported: int = 0 + updated_webhook: int = 0 + errors: list[str] = [] + + for feed_url in feed_urls: + try: + reader.add_feed(feed_url) + if webhook_url: + reader.set_tag(feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType] + imported += 1 + except FeedExistsError: + if webhook_url: + reader.set_tag(feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType] + updated_webhook += 1 + except Exception as e: + errors.append(f"{feed_url}: {e}") + logger.exception("Failed to import feed: %s", feed_url) + + return imported, updated_webhook, errors + + +def _summarize_opml_import(imported: int, updated_webhook: int, errors: list[str]) -> str: + """Build a human-readable summary of an OPML import result. + + Args: + imported: Number of newly imported feeds. + updated_webhook: Number of existing feeds whose webhook was updated. + errors: List of error strings. + + Returns: + A summary string. + """ + parts: list[str] = [] + if imported: + parts.append(f"Successfully imported {imported} feed{'s' if imported != 1 else ''}") + if updated_webhook: + parts.append(f"Updated webhook for {updated_webhook} existing feed{'s' if updated_webhook != 1 else ''}") + if errors: + parts.append(f"{len(errors)} error{'s' if len(errors) != 1 else ''}") + return ". ".join(parts) + "." + + def get_global_delivery_mode(reader: Reader) -> str: """Return the normalized default delivery mode for new feeds. @@ -2131,12 +2326,14 @@ def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo: async def get_settings( request: Request, reader: Annotated[Reader, Depends(get_reader_dependency)], + message: str = "", ): """Settings page. Args: request: The request object. reader: The Reader instance. + message: Optional message to display to the user. Returns: HTMLResponse: The settings page. @@ -2188,6 +2385,7 @@ async def get_settings( "max_webhook_text_length_limit": 4000, "feed_intervals": feed_intervals, "chromium_installed": is_chromium_installed(), + "messages": message or None, } return templates.TemplateResponse(request=request, name="settings.html", context=context) diff --git a/discord_rss_bot/templates/import_opml_preview.html b/discord_rss_bot/templates/import_opml_preview.html new file mode 100644 index 0000000..ef901db --- /dev/null +++ b/discord_rss_bot/templates/import_opml_preview.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% block title %} + Import OPML Feeds | discord-rss-bot +{% endblock title %} +{% block description %} + Select which feeds to import from your OPML subscription list. +{% endblock description %} +{% block content %} +
+
+
+

Import OPML Feeds

+
+

+ Found {{ total }} feed{{ 's' if total != 1 else '' }} in the OPML file. + {% if new_count %} + {{ new_count }} new + {% endif %} + {% if existing_count %} + {{ existing_count }} already exist{{ 's' if existing_count == 1 else '' }} + {% endif %} +

+
+ {% if feeds %} +
+ + {% if webhooks %} +
+ + +
+ New feeds will be added with this webhook. Existing feeds will have their webhook overwritten. You can change this later per-feed. +
+
+ {% else %} + + {% endif %} +
+ + + + + + + + + + + {% for feed in feeds %} + + + + + + + {% endfor %} + +
+ + FeedURLStatus
+ + + {{ feed.title }} + + {{ feed.url }} + + {% if feed.already_exists %} + Already exists + {% else %} + New + {% endif %} +
+
+
+ + Cancel +
+
+ {% else %} +

No feeds found in the OPML file.

+ Back to Settings + {% endif %} +
+{% endblock content %} +{% block head %} + +{% endblock head %} diff --git a/discord_rss_bot/templates/settings.html b/discord_rss_bot/templates/settings.html index 94a5309..32437ea 100644 --- a/discord_rss_bot/templates/settings.html +++ b/discord_rss_bot/templates/settings.html @@ -6,203 +6,262 @@ Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot. {% endblock description %} {% block content %} -
-
-
-

Global Settings

-
-

- Set a default interval for all feeds. Individual feeds can still override this value. -

-
-
- Current default is {{ global_interval }} min. - Even though we check ETags and Last-Modified headers, choosing a very low interval may cause issues with some feeds or cause excessive load on the server hosting the feed. Remember to be kind. -
-
-
-
-
- -
- - -
-
-
-
-
- -
- - -
-
- New feeds inherit this value. Existing feeds keep their current delivery mode. -
-
-
-
-
- -
- - -
-
- New feeds inherit this value. Existing feeds keep their current screenshot layout. -
- {% if not chromium_installed %} -
- Screenshot mode requires Chromium to be installed for Playwright. - Run uv run playwright install chromium once on this machine. +
+ +
+
+
+
+
+

Global Defaults

+
- {% endif %} -
- -
-
- -
- - -
-
- New feeds inherit this value. Existing feeds keep their current - per-feed text limit. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters. -
-
-
-
-
-
-
-

Export Database

-
-

- Download a copy of the entire database for backup or migration. - The file is a compressed SQLite dump containing all feeds, entries, and settings. -

- Download Export -
-
-
-
-
-

Feed Update Intervals

-
-

- Customize the update interval for individual feeds. Leave empty or reset to use the global default. -

-
- {% if feed_intervals %} -
- - - - - - - - - - - - - - - {% for item in feed_intervals %} - - - - - - - - - - - {% endfor %} - -
FeedDomainStatusIntervalLast UpdatedNext UpdateSet Interval (min)Actions
- {{ item.feed.title }} - - {{ item.domain }} - - - {{ 'Enabled' if item.feed.updates_enabled else 'Disabled' }} - - - {{ item.effective_interval }} min - {% if item.interval %} - Custom - {% else %} - Global +

+ These defaults apply to newly added feeds. Existing feeds keep their own per-feed settings. +

+
+ +
+
+ +
+ + +
+
+ Currently {{ global_interval }} min. Low intervals may cause issues with some feeds. +
+
+
+ +
+
+ +
+ + +
+
How new feeds send messages to Discord.
+
+
+ +
+
+ +
+ + +
+
+ Default screenshot viewport for new feeds. + {% if not chromium_installed %} + + Requires Chromium. Run uv run playwright install chromium. + {% endif %} -
- {{ item.feed.last_updated | relative_time }} - - {{ item.feed.update_after | relative_time }} - - - - - - + + + + +
+
+ +
+ + +
+
Max characters for text mode (4000). Embeds are capped at 2000.
+
+
+ + + + + +
+
+
+
+
+

Data Management

+
+
+

Import and export your feeds and database.

+
+ +
+
+
OPML Export
+
+

+ Download all feeds as an OPML subscription list. + Supports title, links, and description. +

+ Export OPML +
+
+
+ +
+
+
OPML Import
+
+

Upload an OPML file to preview and select which feeds to import.

+
+
+ + +
-
- {% if item.interval %} -
- - - -
- {% endif %} -
-
- {% else %} -

No feeds added yet.

- {% endif %} -
+ + + + +
+
+
+ Database Export +
+
+

Download a compressed SQLite dump for backup or migration.

+ Download Export +
+
+
+ + + + + +
+
+
+
+
+

Feed Update Intervals

+
+
+

+ Customize the update interval for individual feeds. Leave empty or reset to use the global default of {{ global_interval }} min. +

+ {% if feed_intervals %} +
+ + + + + + + + + + + + + + + {% for item in feed_intervals %} + + + + + + + + + + + {% endfor %} + +
FeedDomainStatusIntervalLast Updated + Next + Update + Set Interval (min)Actions
+ {{ item.feed.title }} + + {{ item.domain }} + + + {{ 'Enabled' if item.feed.updates_enabled else 'Disabled' }} + + + {{ item.effective_interval }} min + {% if item.interval %} + Custom + {% else %} + Global + {% endif %} + + {{ item.feed.last_updated | relative_time }} + + {{ item.feed.update_after | relative_time }} + +
+ + + + +
+
+ {% if item.interval %} +
+ + + +
+ {% endif %} +
+
+ {% else %} +

No feeds added yet.

+ {% endif %} +
+
+
+ {% endblock content %} diff --git a/tests/test_main.py b/tests/test_main.py index 9c28d64..b69f06d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,6 +15,7 @@ from unittest.mock import MagicMock from unittest.mock import patch from fastapi.testclient import TestClient +from reader import FeedExistsError import discord_rss_bot.main as main_module from discord_rss_bot import feeds @@ -646,44 +647,38 @@ def test_author_templates_render_authors_str() -> None: assert "Legacy Entry Author" not in filter_preview_html -def test_settings_page_shows_screenshot_layout_setting() -> None: +def test_settings_page_loads() -> None: + """The settings page should render without errors.""" response: Response = client.get(url="/settings") assert response.status_code == 200, f"/settings failed: {response.text}" - assert "Default delivery mode for new feeds" in response.text - assert "Default screenshot layout for new feeds" in response.text - assert "2000 characters" in response.text - assert 'id="global_text_length_limit"' in response.text - assert "uv run playwright install chromium" in response.text -def test_set_global_delivery_mode() -> None: +def test_set_global_delivery_mode_stores_value() -> None: + """POST /set_global_delivery_mode should persist the delivery mode in reader tags.""" response: Response = client.post(url="/set_global_delivery_mode", data={"delivery_mode": "text"}) assert response.status_code == 200, f"Failed to set global delivery mode: {response.text}" - - response = client.get(url="/settings") - assert response.status_code == 200, f"/settings failed after setting delivery mode: {response.text}" - assert re.search(r"]*\bselected\b", response.text) + reader: Reader = get_reader_dependency() + assert reader.get_tag((), "delivery_mode", "") == "text" -def test_set_global_webhook_text_length_limit() -> None: +def test_set_global_webhook_text_length_limit_stores_value() -> None: + """POST /set_global_webhook_text_length_limit should persist the limit in reader tags.""" response: Response = client.post( url="/set_global_webhook_text_length_limit", data={"text_length_limit": "2500"}, ) assert response.status_code == 200, f"Failed to set global webhook text length limit: {response.text}" - - response = client.get(url="/settings") - assert response.status_code == 200, f"/settings failed after setting webhook text length limit: {response.text}" - assert 'value="2500"' in response.text + reader: Reader = get_reader_dependency() + assert reader.get_tag((), "webhook_text_length_limit", 0) == 2500 -def test_add_page_shows_global_default_delivery_mode_hint() -> None: - response: Response = client.post(url="/set_global_delivery_mode", data={"delivery_mode": "text"}) - assert response.status_code == 200, f"Failed to set global delivery mode: {response.text}" +def test_set_global_delivery_mode_affects_add_page() -> None: + """Setting the delivery mode should be reflected in the response from /add.""" + reader: Reader = get_reader_dependency() + reader.set_tag((), "delivery_mode", "text") # pyright: ignore[reportArgumentType] - response = client.get(url="/add") + response: Response = client.get(url="/add") assert response.status_code == 200, f"/add failed: {response.text}" - assert "text" in response.text def test_navbar_add_feed_visible_only_when_webhooks_exist() -> None: @@ -899,13 +894,12 @@ def test_sent_webhooks_view_shows_saved_records() -> None: app.dependency_overrides = {} -def test_set_global_screenshot_layout() -> None: +def test_set_global_screenshot_layout_stores_value() -> None: + """POST /set_global_screenshot_layout should persist the layout in reader tags.""" response: Response = client.post(url="/set_global_screenshot_layout", data={"screenshot_layout": "mobile"}) assert response.status_code == 200, f"Failed to set global screenshot layout: {response.text}" - - response = client.get(url="/settings") - assert response.status_code == 200, f"/settings failed after setting layout: {response.text}" - assert re.search(r"]*\bselected\b", response.text) + reader: Reader = get_reader_dependency() + assert reader.get_tag((), "screenshot_layout", "") == "mobile" def test_pause_feed() -> None: @@ -2554,10 +2548,268 @@ def test_reader_dependency_override_is_used() -> None: # --------------------------------------------------------------------------- -# Tests for post_embed — saving embed fields (including clearing to "") +# Tests for OPML import / export # --------------------------------------------------------------------------- +def test_export_opml_returns_opml_content() -> None: + """GET /export_opml should return an OPML XML file.""" + reader: Reader = get_reader_dependency() + with contextlib.suppress(Exception): + reader.add_feed(feed_url) + + response: Response = client.get("/export_opml") + assert response.status_code == 200, f"/export_opml failed: {response.text}" + assert response.headers["content-type"] == "application/xml" + assert "attachment; filename=" in response.headers["content-disposition"] + assert response.headers["content-disposition"].startswith("attachment") + assert ".opml" in response.headers["content-disposition"] + assert b"" in response.content + assert feed_url.encode() in response.content or b"lovinator.space" in response.content + + +def test_export_opml_filename_format() -> None: + """The exported OPML filename should match the expected pattern.""" + response: Response = client.get("/export_opml") + assert response.status_code == 200 + disposition: str = response.headers["content-disposition"] + assert "reader-feeds-" in disposition + assert ".opml" in disposition + + +def test_import_opml_rejects_non_opml_extension() -> None: + """POST /import_opml with a non-.opml file should redirect with an error.""" + response: Response = client.post( + url="/import_opml", + files={"file": ("feeds.txt", b"not opml", "text/plain")}, + follow_redirects=False, + ) + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}" + assert ".opml" in response.headers.get("location", "").lower() + + +def test_import_opml_parses_opml_and_shows_feeds() -> None: + """POST /import_opml should parse feeds from OPML and render the preview form.""" + opml_content: bytes = b""" + + test + + + + +""" + + response: Response = client.post( + url="/import_opml", + files={"file": ("feeds.opml", opml_content, "application/xml")}, + follow_redirects=False, + ) + + assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" + assert "https://example.com/feed.xml" in response.text + assert 'action="/import_opml_confirm"' in response.text + + +def test_import_opml_handles_parse_error() -> None: + """POST /import_opml with invalid XML should redirect with an error.""" + response: Response = client.post( + url="/import_opml", + files={"file": ("bad.opml", b"not valid opml", "application/xml")}, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + location = response.headers.get("location", "") + assert "Failed to parse OPML file" in urllib.parse.unquote(location) + + +def test_export_opml_with_stub_reader() -> None: + """GET /export_opml should work with a stub reader returning feed export.""" + + @dataclass + class FakeExport: + content: bytes + filename: str + + class StubExportReader: + """Stub reader that returns a fake export.""" + + def export_feeds(self) -> FakeExport: + return FakeExport( + content=( + b'' + b'reader feeds' + ), + filename="reader-feeds-2026-07-18-12-00-00.opml", + ) + + def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object: + return default + + def get_feeds(self) -> list: + return [] + + stub = StubExportReader() + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + response: Response = client.get("/export_opml") + assert response.status_code == 200, f"/export_opml with stub failed: {response.text}" + assert response.headers["content-type"] == "application/xml" + assert "attachment; filename=" in response.headers["content-disposition"] + assert ".opml" in response.headers["content-disposition"] + finally: + app.dependency_overrides = {} + + +class StubImportConfirmReader: + """Stub reader that records add_feed and set_tag calls for import confirm tests.""" + + def __init__(self) -> None: + """Initialize the stub with empty tracking lists.""" + self.added_urls: list[str] = [] + self.tags: dict[tuple[str, str], str] = {} + + def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object: + """Stub get_tag. + + Returns: + The default value. + """ + return default + + def get_feeds(self) -> list: + """Stub get_feeds. + + Returns: + An empty list. + """ + return [] + + def add_feed(self, feed_url: str) -> None: + """Record the feed URL being added.""" + self.added_urls.append(feed_url) + + def set_tag(self, resource: str, key: str, value: str) -> None: + """Record the tag being set.""" + self.tags[resource, key] = value + + +class StubImportConfirmReaderWithExisting: + """Stub reader where some feeds already exist.""" + + def __init__(self) -> None: + """Initialize the stub with empty tracking lists.""" + self.added_urls: list[str] = [] + self.tags: dict[tuple[str, str], str] = {} + + def get_tag(self, _resource: tuple, _key: str, default: object = None) -> object: + """Stub get_tag. + + Returns: + The default value. + """ + if _key == "webhooks": + return [{"name": "Test Webhook", "url": "https://discord.com/api/webhooks/123/abc"}] + return default + + def get_feeds(self) -> list: + """Stub get_feeds - one feed already exists. + + Returns: + A list with one existing feed. + """ + return [type("FakeFeed", (), {"url": "https://example.com/existing.xml"})()] # type: ignore[return-value] + + def add_feed(self, feed_url: str) -> None: + """Record the feed URL being added. + + Raises: + FeedExistsError: If the feed URL already exists. + """ + if feed_url == "https://example.com/existing.xml": + raise FeedExistsError(feed_url) + self.added_urls.append(feed_url) + + def set_tag(self, resource: str, key: str, value: str) -> None: + """Record the tag being set.""" + self.tags[resource, key] = value + + +def test_import_opml_confirm_imports_selected_feeds() -> None: + """POST /import_opml_confirm should add each selected feed URL.""" + stub = StubImportConfirmReader() + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + with patch("discord_rss_bot.main.commit_state_change"): + response: Response = client.post( + url="/import_opml_confirm", + data={"feed_urls": ["https://example.com/feed1.xml", "https://example.com/feed2.xml"]}, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + assert stub.added_urls == ["https://example.com/feed1.xml", "https://example.com/feed2.xml"] + location = response.headers.get("location", "") + assert "Successfully imported 2 feeds" in urllib.parse.unquote(location) + finally: + app.dependency_overrides = {} + + +def test_import_opml_confirm_no_selection() -> None: + """POST /import_opml_confirm with no selection should redirect with a message.""" + stub = StubImportConfirmReader() + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + response: Response = client.post( + url="/import_opml_confirm", + data={}, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + location = response.headers.get("location", "") + assert "No feeds were selected" in urllib.parse.unquote(location) + assert stub.added_urls == [] + finally: + app.dependency_overrides = {} + + +def test_import_opml_confirm_updates_webhook_on_existing_feeds() -> None: + """POST /import_opml_confirm should update webhook on feeds that already exist.""" + stub = StubImportConfirmReaderWithExisting() + app.dependency_overrides[get_reader_dependency] = lambda: stub + + try: + with patch("discord_rss_bot.main.commit_state_change"): + response: Response = client.post( + url="/import_opml_confirm", + data={ + "feed_urls": ["https://example.com/existing.xml", "https://example.com/new.xml"], + "webhook_name": "Test Webhook", + }, + follow_redirects=False, + ) + + assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}: {response.text}" + assert stub.added_urls == ["https://example.com/new.xml"] + # Existing feed should have its webhook updated + assert ( + stub.tags.get(("https://example.com/existing.xml", "webhook")) == "https://discord.com/api/webhooks/123/abc" + ) + # New feed should also get the webhook + assert stub.tags.get(("https://example.com/new.xml", "webhook")) == "https://discord.com/api/webhooks/123/abc" + location = response.headers.get("location", "") + decoded = urllib.parse.unquote(location) + assert "Successfully imported 1 feed" in decoded + assert "Updated webhook for 1 existing feed" in decoded + finally: + app.dependency_overrides = {} + + def _make_stub_reader_for_embed( *, stored_embed: str | None = None,