"""RSS endpoint tests."""
import pytest
from openbb_news import registry, rss
@pytest.fixture
def mocked_pipeline(monkeypatch, stub_session_factory, sample_rss, sample_article_html):
monkeypatch.setattr(
"openbb_news.registry.get_feed_url",
lambda key: f"http://feed.test/{key}",
)
stub_session_factory(
{
"http://feed.test/example": sample_rss,
"https://example.test/article/1": sample_article_html,
}
)
async def test_rss_with_body(mocked_pipeline):
out = await rss.rss(source="example", limit=5, fetch_body=True)
items = out.results
assert len(items) == 2
assert items[0].title == "First Article"
assert items[0].date.endswith("+00:00")
assert items[0].author == "Jane Doe"
assert items[0].excerpt == "A short summary."
assert items[0].url == "https://example.test/article/1"
assert "First paragraph" in items[0].body
# The extracted article body is used verbatim — no source link appended.
assert "Read the full story" not in items[0].body
assert items[1].excerpt == "Plain text summary without HTML."
# Item 1 has no link, so the summary stands alone.
assert items[1].body == items[1].excerpt
assert items[1].url == ""
assert items[1].author == "Sample Feed"
async def test_rss_without_body(mocked_pipeline):
out = await rss.rss(source="example", limit=5, fetch_body=False)
results = out.results
# Item 0 has a link: the summary is followed by a source link.
assert results[0].body.startswith(results[0].excerpt)
assert (
"[Read the full story at the source ↗](https://example.test/article/1)"
in results[0].body
)
# Item 1 has no link, so the summary stands alone.
assert results[1].body == results[1].excerpt
async def test_rss_appends_source_link_when_body_fetch_fails(
monkeypatch, stub_session_factory
):
feed = (
b""
b'Paywalled'
b"- Locked Story"
b"https://paywall.test/story"
b"Just the teaser."
b"Mon, 19 May 2026 12:00:00 GMT"
b"
"
)
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/pw"
)
stub_session_factory(
{
"http://feed.test/pw": feed,
# Article page 403s / yields no extractable body.
"https://paywall.test/story": (b"Access Denied", 403),
}
)
item = (await rss.rss(source="x", limit=1, fetch_body=True)).results[0]
assert item.excerpt == "Just the teaser."
assert item.body == (
"Just the teaser.\n\n"
"[Read the full story at the source ↗](https://paywall.test/story)"
)
async def test_rss_limit_caps_entries(mocked_pipeline):
out = await rss.rss(source="example", limit=1, fetch_body=False)
assert len(out.results) == 1
async def test_rss_empty_feed(monkeypatch, stub_session_factory):
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/empty"
)
stub_session_factory({"http://feed.test/empty": b""})
out = await rss.rss(source="empty", limit=5, fetch_body=True)
assert out.results == []
async def test_rss_unknown_source():
with pytest.raises(ValueError, match="Unknown RSS feed source"):
await rss.rss(source="totally_made_up", limit=1, fetch_body=False)
async def test_rss_returns_empty_when_no_source():
out = await rss.rss(source=None, limit=5, fetch_body=False)
assert out.results == []
out2 = await rss.rss(source="", limit=5, fetch_body=False)
assert out2.results == []
async def test_rss_auto_selects_curated_default_for_outlet(
monkeypatch, stub_session_factory, sample_rss
):
monkeypatch.setattr(registry, "default_feed_for", lambda outlet: "curated_default")
monkeypatch.setattr(
"openbb_news.registry.get_feed_url",
lambda key: f"http://feed.test/{key}",
)
stub_session_factory({"http://feed.test/curated_default": sample_rss})
out = await rss.rss(outlet="any", source=None, limit=5, fetch_body=False)
assert len(out.results) == 2
assert out.results[0].title == "First Article"
async def test_rss_returns_empty_for_unknown_outlet(monkeypatch):
monkeypatch.setattr(registry, "default_feed_for", lambda outlet: None)
out = await rss.rss(outlet="ghost", source=None, limit=5, fetch_body=False)
assert out.results == []
async def test_rss_sorts_newest_first(monkeypatch, stub_session_factory):
feed = (
b""
b"Reversed Feed"
b"- Oldest"
b"Mon, 17 May 2026 09:00:00 GMT
"
b"- Middle"
b"Mon, 18 May 2026 09:00:00 GMT
"
b"- Newest"
b"Mon, 19 May 2026 09:00:00 GMT
"
b""
)
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/rev"
)
stub_session_factory({"http://feed.test/rev": feed})
out = await rss.rss(source="x", limit=5, fetch_body=False)
assert [item.title for item in out.results] == ["Newest", "Middle", "Oldest"]
async def test_rss_sorts_undated_entries_to_bottom(monkeypatch, stub_session_factory):
feed = (
b""
b"Mixed Feed"
b"- No Date"
b"no pubdate
"
b"- Dated"
b"Mon, 19 May 2026 09:00:00 GMT
"
b""
)
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/mixed"
)
stub_session_factory({"http://feed.test/mixed": feed})
out = await rss.rss(source="x", limit=5, fetch_body=False)
assert [item.title for item in out.results] == ["Dated", "No Date"]
async def test_rss_handles_missing_title(monkeypatch, stub_session_factory):
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/notitle"
)
feed = (
b""
b"- Body only
"
b""
)
stub_session_factory({"http://feed.test/notitle": feed})
items = (await rss.rss(source="x", limit=1, fetch_body=False)).results
assert items[0].title == "(untitled)"
assert items[0].author == "Unknown"
async def test_rss_providers_lists_known_providers(monkeypatch):
monkeypatch.setattr(registry, "load_config", lambda: {})
out = await rss.rss_providers()
keys = [entry["value"] for entry in out]
assert "bbc" in keys
assert "pr_newswire" in keys
labels = {entry["value"]: entry["label"] for entry in out}
assert labels["bbc"] == "BBC"
assert labels["axios"] == "Axios"
async def test_rss_feeds_filtered_by_provider(monkeypatch):
monkeypatch.setattr(registry, "load_config", lambda: {})
out = await rss.rss_feeds(outlet="bbc")
labels = [entry["label"] for entry in out]
assert "World" in labels
assert "Politics" in labels
assert labels == sorted(labels)
fox_out = await rss.rss_feeds(outlet="fox_news")
fox_labels = [entry["label"] for entry in fox_out]
assert "Latest Headlines" in fox_labels
assert "Politics" in fox_labels
async def test_rss_feeds_no_provider_returns_empty(monkeypatch):
monkeypatch.setattr(registry, "load_config", lambda: {})
assert await rss.rss_feeds(outlet=None) == []
def test_news_item_data_shape():
item = rss.NewsItemData(
title="t",
date="2026-01-01T00:00:00+00:00",
author="a",
url="https://example.test/a",
excerpt="e",
body="b",
)
assert item.title == "t"
assert item.body == "b"
assert item.url == "https://example.test/a"
async def test_rss_body_uses_markdown_links_when_extraction_fails(
monkeypatch, stub_session_factory
):
feed = (
b""
b'Aggregator'
b"- "
b"Cluster"
b"https://aggregator.test/x"
b"NYT headline '
b'BBC headline '
b"]]>"
b"Mon, 19 May 2026 12:00:00 GMT"
b"
"
)
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/agg"
)
stub_session_factory(
{
"http://feed.test/agg": feed,
"https://aggregator.test/x": b"
not an article
",
}
)
items = (await rss.rss(source="x", limit=1, fetch_body=True)).results
assert len(items) == 1
body = items[0].body
assert "[NYT headline](https://nyt.test/story)" in body
assert "[BBC headline](https://bbc.test/story)" in body
assert items[0].url == "https://aggregator.test/x"
async def test_rss_body_includes_inline_image_from_article(
monkeypatch, stub_session_factory
):
feed = (
b""
b'X'
b"- Story"
b"https://aggregator.test/story"
b"x"
b"Wed, 20 May 2026 13:35:34 EDT"
b"
"
)
article = (
b""
b"Opening sentence.
"
b'
'
b"Closing sentence.
"
b""
)
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/x"
)
stub_session_factory(
{"http://feed.test/x": feed, "https://aggregator.test/story": article}
)
items = (await rss.rss(source="x", limit=1, fetch_body=True)).results
body = items[0].body
assert "" in body
assert body.index("Opening sentence.") < body.index("![photo]")
assert body.index("![photo]") < body.index("Closing sentence.")
async def test_rss_body_prepends_og_image_when_no_inline(
monkeypatch, stub_session_factory
):
feed = (
b""
b'X'
b"- Story"
b"https://aggregator.test/story"
b"x"
b"Wed, 20 May 2026 13:35:34 EDT"
b"
"
)
article = (
b""
b''
b""
b"One.
Two.
"
b""
)
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/x"
)
stub_session_factory(
{"http://feed.test/x": feed, "https://aggregator.test/story": article}
)
items = (await rss.rss(source="x", limit=1, fetch_body=True)).results
assert items[0].body.startswith("")
async def test_rss_body_preserves_cbc_style_description_image(
monkeypatch, stub_session_factory
):
feed = (
b""
b'CBC'
b"- Story"
b"https://aggregator.test/story"
b""
b"
Lead text.
"
b"]]>"
b"Wed, 20 May 2026 13:35:34 EDT"
b" "
)
monkeypatch.setattr(
"openbb_news.registry.get_feed_url", lambda key: "http://feed.test/x"
)
stub_session_factory(
{
"http://feed.test/x": feed,
"https://aggregator.test/story": b"not an article
",
}
)
items = (await rss.rss(source="x", limit=1, fetch_body=True)).results
assert "" in items[0].body
async def test_gather_bodies_returns_none_for_empty_link(stub_session_factory):
session = stub_session_factory({})
out = await rss._gather_bodies(session, [None, ""])
assert out == [None, None]
assert session.calls == []
async def test_gather_bodies_swallows_per_item_exceptions(
monkeypatch, stub_session_factory, sample_article_html
):
import aiohttp
session = stub_session_factory(
{
"https://good.test/a": sample_article_html,
"https://bad.test/a": aiohttp.ClientError("synthetic failure"),
}
)
out = await rss._gather_bodies(
session, ["https://good.test/a", "https://bad.test/a"]
)
assert out[0] is not None
assert "First paragraph" in out[0]
assert out[1] is None
async def test_gather_bodies_propagates_cancellation(monkeypatch):
import asyncio
async def cancelling_fetch(session, url):
raise asyncio.CancelledError
monkeypatch.setattr("openbb_news.parser.fetch_article_body", cancelling_fetch)
with pytest.raises(asyncio.CancelledError):
await rss._gather_bodies(object(), ["https://x.test/a"])
async def test_gather_bodies_swallows_unexpected_exceptions(monkeypatch):
async def broken_fetch(session, url):
raise RuntimeError("internal bug")
monkeypatch.setattr("openbb_news.parser.fetch_article_body", broken_fetch)
out = await rss._gather_bodies(object(), ["https://x.test/a", "https://x.test/b"])
assert out == [None, None]