From 5323245cf62370d5c68d6ca7b25fa35d09bbc863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Sun, 15 Mar 2026 20:45:00 +0100 Subject: [PATCH] Fix /post_entry --- discord_rss_bot/main.py | 26 ++++++++-- tests/test_main.py | 104 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index 2838c10..56fd7d2 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -1080,7 +1080,11 @@ def create_html_for_feed( # noqa: C901, PLR0914 ) entry_id: str = urllib.parse.quote(entry.id) - to_discord_html: str = f"Send to Discord" + encoded_source_feed_url: str = urllib.parse.quote(source_feed_url) + to_discord_html: str = ( + f"" + "Send to Discord" + ) # Check if this is a YouTube feed entry and the entry has a link is_youtube_feed = "youtube.com/feeds/videos.xml" in entry.feed.url @@ -1427,18 +1431,32 @@ async def search( async def post_entry( entry_id: str, reader: Annotated[Reader, Depends(get_reader_dependency)], + feed_url: str = "", ): """Send single entry to Discord. Args: entry_id: The entry to send. + feed_url: Optional feed URL used to disambiguate entries with identical IDs. reader: The Reader instance. Returns: RedirectResponse: Redirect to the feed page. """ unquoted_entry_id: str = urllib.parse.unquote(entry_id) - entry: Entry | None = next((entry for entry in reader.get_entries() if entry.id == unquoted_entry_id), None) + clean_feed_url: str = urllib.parse.unquote(feed_url.strip()) if feed_url else "" + + # Prefer feed-scoped lookup when feed_url is provided. This avoids ambiguity when + # multiple feeds contain entries with the same ID. + entry: Entry | None = None + if clean_feed_url: + entry = next( + (entry for entry in reader.get_entries(feed=clean_feed_url) if entry.id == unquoted_entry_id), + None, + ) + else: + entry = next((entry for entry in reader.get_entries() if entry.id == unquoted_entry_id), None) + if entry is None: return HTMLResponse(status_code=404, content=f"Entry '{entry_id}' not found.") @@ -1446,8 +1464,8 @@ async def post_entry( return result # Redirect to the feed page. - clean_feed_url: str = entry.feed.url.strip() - return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) + redirect_feed_url: str = entry.feed.url.strip() + return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(redirect_feed_url)}", status_code=303) @app.post("/modify_webhook", response_class=HTMLResponse) diff --git a/tests/test_main.py b/tests/test_main.py index 25aff2a..53d44a1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -480,6 +480,110 @@ def test_update_feed_not_found() -> None: assert "Feed not found" in response.text +def test_post_entry_send_to_discord() -> None: + """Test that /post_entry sends an entry to Discord and redirects to the feed page. + + Regression test for the bug where the injected reader was not passed to + send_entry_to_discord, meaning the dependency-injected reader was silently ignored. + """ + # Ensure webhook and feed exist. + client.post(url="/delete_webhook", data={"webhook_url": webhook_url}) + response: Response = client.post( + url="/add_webhook", + data={"webhook_name": webhook_name, "webhook_url": webhook_url}, + ) + assert response.status_code == 200, f"Failed to add webhook: {response.text}" + + client.post(url="/remove", data={"feed_url": feed_url}) + response = client.post(url="/add", data={"feed_url": feed_url, "webhook_dropdown": webhook_name}) + assert response.status_code == 200, f"Failed to add feed: {response.text}" + + # Retrieve an entry from the feed to get a valid entry ID. + reader: main_module.Reader = main_module.get_reader_dependency() + entries: list[Entry] = list(reader.get_entries(feed=feed_url, limit=1)) + assert entries, "Feed should have at least one entry to send" + entry_to_send: main_module.Entry = entries[0] + encoded_id: str = urllib.parse.quote(entry_to_send.id) + + no_redirect_client = TestClient(app, follow_redirects=False) + + # Patch execute_webhook so no real HTTP requests are made to Discord. + with patch("discord_rss_bot.feeds.execute_webhook") as mock_execute: + response = no_redirect_client.get( + url="/post_entry", + params={"entry_id": encoded_id, "feed_url": urllib.parse.quote(feed_url)}, + ) + + assert response.status_code == 303, f"Expected redirect after sending, got {response.status_code}: {response.text}" + location: str = response.headers.get("location", "") + assert "feed?feed_url=" in location, f"Should redirect to feed page, got: {location}" + assert mock_execute.called, "execute_webhook should have been called to deliver the entry to Discord" + + # Cleanup. + client.post(url="/remove", data={"feed_url": feed_url}) + + +def test_post_entry_unknown_id_returns_404() -> None: + """Test that /post_entry returns 404 when the entry ID does not exist.""" + response: Response = client.get( + url="/post_entry", + params={"entry_id": "https://nonexistent.example.com/entry-that-does-not-exist"}, + ) + assert response.status_code == 404, f"Expected 404 for unknown entry, got {response.status_code}" + + +def test_post_entry_uses_feed_url_to_disambiguate_duplicate_ids() -> None: + """When IDs collide across feeds, /post_entry should pick the entry from provided feed_url.""" + + @dataclass(slots=True) + class DummyFeed: + url: str + + @dataclass(slots=True) + class DummyEntry: + id: str + feed: DummyFeed + feed_url: str + + feed_a = "https://example.com/feed-a.xml" + feed_b = "https://example.com/feed-b.xml" + shared_id = "https://example.com/shared-entry-id" + + entry_a: Entry = cast("Entry", DummyEntry(id=shared_id, feed=DummyFeed(feed_a), feed_url=feed_a)) + entry_b: Entry = cast("Entry", DummyEntry(id=shared_id, feed=DummyFeed(feed_b), feed_url=feed_b)) + + class StubReader: + def get_entries(self, feed: str | None = None) -> list[Entry]: + if feed == feed_a: + return [entry_a] + if feed == feed_b: + return [entry_b] + return [entry_a, entry_b] + + selected_feed_urls: list[str] = [] + + def fake_send_entry_to_discord(entry: Entry, reader: object | None = None) -> None: + selected_feed_urls.append(entry.feed.url) + + app.dependency_overrides[get_reader_dependency] = StubReader + no_redirect_client = TestClient(app, follow_redirects=False) + + try: + with patch("discord_rss_bot.main.send_entry_to_discord", side_effect=fake_send_entry_to_discord): + response: Response = no_redirect_client.get( + url="/post_entry", + params={"entry_id": urllib.parse.quote(shared_id), "feed_url": urllib.parse.quote(feed_b)}, + ) + + assert response.status_code == 303, f"Expected redirect after sending, got {response.status_code}" + assert selected_feed_urls == [feed_b], f"Expected feed-b entry, got: {selected_feed_urls}" + + location = response.headers.get("location", "") + assert urllib.parse.quote(feed_b) in location, f"Expected redirect to feed-b page, got: {location}" + finally: + app.dependency_overrides = {} + + def test_navbar_backup_link_hidden_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: """Test that the backup link is not shown in the navbar when GIT_BACKUP_PATH is not set.""" # Ensure GIT_BACKUP_PATH is not set