| 1 | """Tests for scripts/lib/reddit_rss.py — keyless Reddit RSS discovery.""" |
| 2 | |
| 3 | from pathlib import Path |
| 4 | from unittest import mock |
| 5 | |
| 6 | from lib import reddit_rss |
| 7 | |
| 8 | FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "reddit_search_rss_sample.xml" |
| 9 | |
| 10 | |
| 11 | def _feed_text(): |
| 12 | return FIXTURE.read_text(encoding="utf-8") |
| 13 | |
| 14 | |
| 15 | class TestParseFeed: |
| 16 | """_parse_feed turns Atom entries into normalized post dicts.""" |
| 17 | |
| 18 | def test_parses_entries(self): |
| 19 | posts = reddit_rss._parse_feed(_feed_text(), query="lifelock") |
| 20 | assert len(posts) == 5 |
| 21 | for p in posts: |
| 22 | assert p["title"] |
| 23 | assert "/comments/" in p["url"] |
| 24 | assert p["url"].startswith("https://www.reddit.com/") |
| 25 | |
| 26 | def test_normalized_shape_matches_scrapecreators(self): |
| 27 | post = reddit_rss._parse_feed(_feed_text(), query="x")[0] |
| 28 | required = {"id", "title", "url", "score", "num_comments", "subreddit", |
| 29 | "created_utc", "author", "selftext", "date", |
| 30 | "engagement", "relevance", "why_relevant", "metadata"} |
| 31 | assert required.issubset(set(post.keys())) |
| 32 | assert set(post["engagement"].keys()) == {"score", "num_comments", "upvote_ratio"} |
| 33 | assert post["why_relevant"] == "Reddit RSS" |
| 34 | |
| 35 | def test_score_is_placeholder_zero(self): |
| 36 | # RSS carries no engagement score; it is backfilled during enrichment. |
| 37 | for p in reddit_rss._parse_feed(_feed_text(), query="x"): |
| 38 | assert p["score"] == 0 |
| 39 | assert p["engagement"]["score"] == 0 |
| 40 | |
| 41 | def test_subreddit_derivation(self): |
| 42 | post = reddit_rss._parse_feed(_feed_text(), query="x")[0] |
| 43 | assert post["subreddit"] == "Rakuten" |
| 44 | |
| 45 | def test_date_parsed_to_iso(self): |
| 46 | post = reddit_rss._parse_feed(_feed_text(), query="x")[0] |
| 47 | assert post["date"] and len(post["date"]) == 10 # YYYY-MM-DD |
| 48 | assert isinstance(post["created_utc"], float) |
| 49 | |
| 50 | def test_author_strips_u_prefix(self): |
| 51 | authors = [p["author"] for p in reddit_rss._parse_feed(_feed_text(), query="x")] |
| 52 | assert all(not a.startswith("/u/") and not a.startswith("u/") for a in authors) |
| 53 | |
| 54 | def test_empty_and_malformed_feed_never_raises(self): |
| 55 | assert reddit_rss._parse_feed("", query="x") == [] |
| 56 | assert reddit_rss._parse_feed("<not xml", query="x") == [] |
| 57 | assert reddit_rss._parse_feed("<feed></feed>", query="x") == [] |
| 58 | |
| 59 | def test_entry_without_comments_link_skipped(self): |
| 60 | feed = ( |
| 61 | '<feed xmlns="http://www.w3.org/2005/Atom"><entry>' |
| 62 | '<title>Subreddit itself</title>' |
| 63 | '<link href="https://www.reddit.com/r/test/" />' |
| 64 | '<updated>2026-05-20T00:00:00+00:00</updated></entry></feed>' |
| 65 | ) |
| 66 | assert reddit_rss._parse_feed(feed, query="x") == [] |
| 67 | |
| 68 | |
| 69 | class TestSearchRss: |
| 70 | """search_rss fans out, dedupes, assigns IDs, and honors depth limits.""" |
| 71 | |
| 72 | def test_dedupe_and_ids(self): |
| 73 | # Same feed returned for every URL -> deduped to 5 unique posts. |
| 74 | with mock.patch.object(reddit_rss.http, "get_text", return_value=_feed_text()): |
| 75 | posts = reddit_rss.search_rss("lifelock", depth="default", |
| 76 | subreddits=["Rakuten", "ConsumerAdvice"]) |
| 77 | urls = [p["url"] for p in posts] |
| 78 | assert len(urls) == len(set(urls)) # no duplicates |
| 79 | assert [p["id"] for p in posts] == [f"R{i+1}" for i in range(len(posts))] |
| 80 | |
| 81 | def test_depth_limit_quick(self): |
| 82 | with mock.patch.object(reddit_rss.http, "get_text", return_value=_feed_text()): |
| 83 | posts = reddit_rss.search_rss("lifelock", depth="quick") |
| 84 | assert len(posts) <= reddit_rss.DEPTH_LIMITS["quick"] |
| 85 | |
| 86 | def test_all_feeds_fail_returns_empty(self): |
| 87 | with mock.patch.object(reddit_rss.http, "get_text", return_value=None): |
| 88 | posts = reddit_rss.search_rss("lifelock", subreddits=["Rakuten"]) |
| 89 | assert posts == [] |
| 90 | |
| 91 | def test_builds_keyless_rss_urls(self): |
| 92 | urls = reddit_rss._build_urls("life lock", "default", ["Rakuten"]) |
| 93 | assert any("search.rss?q=life+lock" in u and "/r/" not in u.split("?")[0] for u in urls) |
| 94 | assert any("/r/Rakuten/search.rss" in u and "restrict_sr=on" in u for u in urls) |
| 95 | assert any("/r/Rakuten/top.rss" in u for u in urls) |
| 96 | assert all(".json" not in u for u in urls) # never the dead endpoint |
| 97 |