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