| 1 | """Tests for scripts/lib/reddit_keyless.py — tiered keyless Reddit pipeline.""" |
| 2 | |
| 3 | from unittest import mock |
| 4 | |
| 5 | from lib import reddit_keyless |
| 6 | |
| 7 | |
| 8 | def _post(i, date="2026-05-20", rel=0.0): |
| 9 | url = f"https://www.reddit.com/r/test/comments/{i:06d}/post_{i}/" |
| 10 | return { |
| 11 | "id": "", "title": f"Post {i}", "url": url, "score": 0, "num_comments": 0, |
| 12 | "subreddit": "test", "created_utc": None, "author": "u", "selftext": "", |
| 13 | "date": date, "engagement": {"score": 0, "num_comments": 0, "upvote_ratio": None}, |
| 14 | "relevance": rel, "why_relevant": "Reddit RSS", "metadata": {}, |
| 15 | } |
| 16 | |
| 17 | |
| 18 | def _scored(i, score, ncmt=0): |
| 19 | p = _post(i) |
| 20 | p["score"] = score |
| 21 | p["num_comments"] = ncmt |
| 22 | p["engagement"]["score"] = score |
| 23 | p["engagement"]["num_comments"] = ncmt |
| 24 | p["why_relevant"] = "Reddit listing" |
| 25 | p["metadata"] = {"post_id": f"{i:06d}"} |
| 26 | return p |
| 27 | |
| 28 | |
| 29 | class TestDiscovery: |
| 30 | """RSS breadth + scored listings are the keyless discovery path (no .json).""" |
| 31 | |
| 32 | def test_keyless_path_runs_rss_and_listings(self): |
| 33 | with mock.patch.object(reddit_keyless.reddit_rss, "search_rss", |
| 34 | return_value=[_post(1), _post(2)]) as rss, \ |
| 35 | mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 36 | return_value=[]), \ |
| 37 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 38 | return_value=[]): |
| 39 | out = reddit_keyless._discover("topic", "default", ["test"]) |
| 40 | assert len(out) == 2 |
| 41 | rss.assert_called_once() |
| 42 | |
| 43 | def test_listing_scores_backfill_rss_posts(self): |
| 44 | # RSS finds post 1 (no score); listing card for post 1 carries the score. |
| 45 | rss_post = _post(1) |
| 46 | listing_post = _scored(1, score=52692, ncmt=1743) |
| 47 | listing_post["subreddit"] = "test" # Match the requested subreddit. |
| 48 | with mock.patch.object(reddit_keyless.reddit_rss, "search_rss", |
| 49 | return_value=[rss_post]), \ |
| 50 | mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 51 | return_value=[listing_post]), \ |
| 52 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 53 | return_value=[]): # No arctic supplement. |
| 54 | out = reddit_keyless._discover("topic", "default", ["test"]) |
| 55 | # listing post (scored) is kept; RSS dup of same url is dropped |
| 56 | assert len(out) == 1 |
| 57 | assert out[0]["engagement"]["score"] == 52692 |
| 58 | assert out[0]["num_comments"] == 1743 |
| 59 | |
| 60 | def test_scores_flow_to_distinct_rss_posts(self): |
| 61 | # Distinct RSS post whose id matches a listing card gets backfilled. |
| 62 | rss_post = _post(7) # url .../000007/... |
| 63 | listing_post = _scored(7, score=999) |
| 64 | listing_post["url"] = "https://www.reddit.com/r/test/comments/zzzzzz/other/" |
| 65 | with mock.patch.object(reddit_keyless.reddit_rss, "search_rss", |
| 66 | return_value=[rss_post]), \ |
| 67 | mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 68 | return_value=[listing_post]): |
| 69 | out = reddit_keyless._discover("topic", "default", ["test"]) |
| 70 | backfilled = [p for p in out if p["url"] == rss_post["url"]][0] |
| 71 | assert backfilled["engagement"]["score"] == 999 |
| 72 | |
| 73 | def test_bare_query_does_not_merge_listing_discovery(self): |
| 74 | # No subreddits provided: derived-subreddit listings must NOT be added as |
| 75 | # results (avoids flooding with off-topic high-upvote posts) — only used |
| 76 | # to backfill scores onto the keyword-matched RSS posts. |
| 77 | rss_post = _post(1) # on-topic keyword match |
| 78 | offtopic_listing = _scored(99, score=88888) # high score, unrelated sub |
| 79 | offtopic_listing["url"] = "https://www.reddit.com/r/random/comments/zzz999/x/" |
| 80 | with mock.patch.object(reddit_keyless.reddit_rss, "search_rss", |
| 81 | return_value=[rss_post]), \ |
| 82 | mock.patch.object(reddit_keyless, "_top_subreddits", return_value=["random"]), \ |
| 83 | mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 84 | return_value=[offtopic_listing]): |
| 85 | out = reddit_keyless._discover("topic", "default", None) |
| 86 | urls = [p["url"] for p in out] |
| 87 | assert rss_post["url"] in urls |
| 88 | assert offtopic_listing["url"] not in urls # not merged as discovery |
| 89 | |
| 90 | def test_discover_never_raises_returns_empty(self): |
| 91 | with mock.patch.object(reddit_keyless.reddit_rss, "search_rss", return_value=[]), \ |
| 92 | mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", return_value=[]): |
| 93 | assert reddit_keyless._discover("t", "default", None) == [] |
| 94 | |
| 95 | |
| 96 | class TestSearchAndEnrich: |
| 97 | """Full pipeline: discover -> date filter -> rank -> enrich -> reindex.""" |
| 98 | |
| 99 | def _patch_enrich_passthrough(self): |
| 100 | return mock.patch.object( |
| 101 | reddit_keyless.reddit_shreddit, "fetch_comments", |
| 102 | return_value={"top_comments": [], "comment_insights": [], "num_comments": None}, |
| 103 | ) |
| 104 | |
| 105 | def test_returns_empty_when_no_discovery(self): |
| 106 | with mock.patch.object(reddit_keyless, "_discover", return_value=[]): |
| 107 | assert reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") == [] |
| 108 | |
| 109 | def test_date_filter_keeps_in_range_and_unknown(self): |
| 110 | posts = [_post(1, date="2026-05-10"), _post(2, date="2020-01-01"), |
| 111 | _post(3, date=None)] |
| 112 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 113 | self._patch_enrich_passthrough(): |
| 114 | out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") |
| 115 | titles = {p["title"] for p in out} |
| 116 | assert "Post 1" in titles and "Post 3" in titles |
| 117 | assert "Post 2" not in titles |
| 118 | |
| 119 | def test_reindexes_ids(self): |
| 120 | posts = [_post(1), _post(2), _post(3)] |
| 121 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 122 | self._patch_enrich_passthrough(): |
| 123 | out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") |
| 124 | assert [p["id"] for p in out] == ["R1", "R2", "R3"] |
| 125 | |
| 126 | def test_enrichment_attaches_comments(self): |
| 127 | posts = [_post(1)] |
| 128 | enriched = { |
| 129 | "top_comments": [{"score": 9, "date": "2026-05-19", "author": "a", |
| 130 | "excerpt": "great", "url": "https://reddit.com/x"}], |
| 131 | "comment_insights": ["great point about X"], |
| 132 | "num_comments": 14, |
| 133 | } |
| 134 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 135 | mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments", |
| 136 | return_value=enriched): |
| 137 | out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") |
| 138 | assert out[0]["top_comments"][0]["score"] == 9 |
| 139 | assert out[0]["num_comments"] == 14 |
| 140 | assert out[0]["engagement"]["num_comments"] == 14 |
| 141 | |
| 142 | def test_enrichment_failure_keeps_posts(self): |
| 143 | posts = [_post(i) for i in range(8)] |
| 144 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 145 | mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments", |
| 146 | side_effect=Exception("svc down")): |
| 147 | out = reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31") |
| 148 | assert len(out) == 8 # all posts retained despite enrichment failure |
| 149 | |
| 150 | def test_only_top_n_enriched_by_depth(self): |
| 151 | posts = [_post(i, rel=1.0 - i / 100) for i in range(10)] |
| 152 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 153 | mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments", |
| 154 | return_value={"top_comments": [], "comment_insights": [], |
| 155 | "num_comments": None}) as fc: |
| 156 | reddit_keyless.search_and_enrich("t", "2026-05-01", "2026-05-31", depth="quick") |
| 157 | # quick depth enriches only top 3 posts |
| 158 | assert fc.call_count == reddit_keyless.ENRICH_LIMITS["quick"] |
| 159 | |
| 160 | |
| 161 | class TestSlotPriority: |
| 162 | """Enrichment slot selection prefers entity-matching posts (R1-R3).""" |
| 163 | |
| 164 | @staticmethod |
| 165 | def _titled(i, title, score=0, selftext=""): |
| 166 | p = _post(i) |
| 167 | p["title"] = title |
| 168 | p["selftext"] = selftext |
| 169 | p["score"] = score |
| 170 | p["engagement"]["score"] = score |
| 171 | return p |
| 172 | |
| 173 | def test_on_topic_low_score_beats_off_topic_high_score(self): |
| 174 | # 3 off-topic monsters + 2 on-topic small threads; quick depth = 3 slots. |
| 175 | posts = [ |
| 176 | self._titled(1, "Stop asking what model to run", score=2662), |
| 177 | self._titled(2, "RTX 4090 PSA", score=2068), |
| 178 | self._titled(3, "Gemma 4 release", score=997), |
| 179 | self._titled(4, "My OpenClaw self-migrated", score=73), |
| 180 | self._titled(5, "Using openclaw with Claude API key is so expensive", score=47), |
| 181 | ] |
| 182 | enriched_urls = [] |
| 183 | |
| 184 | def _capture(url): |
| 185 | enriched_urls.append(url) |
| 186 | return {"top_comments": [], "comment_insights": [], "num_comments": None} |
| 187 | |
| 188 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 189 | mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments", |
| 190 | side_effect=_capture): |
| 191 | reddit_keyless.search_and_enrich( |
| 192 | "openclaw", "2026-05-01", "2026-05-31", depth="quick") |
| 193 | assert posts[3]["url"] in enriched_urls |
| 194 | assert posts[4]["url"] in enriched_urls |
| 195 | assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["quick"] |
| 196 | |
| 197 | def test_slot_priority_grounds_on_head_token_not_full_phrase(self): |
| 198 | # Mirrors rerank's head-token grounding: a post naming the brand head |
| 199 | # ("Stripe") lands in the match tier even without the trailing search |
| 200 | # descriptor ("payments"), so it is not buried under an unrelated |
| 201 | # high-upvote post that never names the brand. |
| 202 | head_only = self._titled(1, "Stripe is friendly to 'friendly fraud'", score=5) |
| 203 | off_topic = self._titled(2, "PayPal raises dispute fees again", score=900) |
| 204 | out = reddit_keyless._slot_priority("Stripe payments", [off_topic, head_only]) |
| 205 | assert out[0] is head_only |
| 206 | assert out[1] is off_topic |
| 207 | |
| 208 | def test_intent_modifier_topic_prioritizes_head_token_match(self): |
| 209 | # Intent-modifier topics still partition by the brand head token: the |
| 210 | # on-entity post wins over a high-upvote post that never names the brand. |
| 211 | on_topic = self._titled(1, "Hermes Agent v0.13 is great", score=1) |
| 212 | off_topic = self._titled(2, "LangGraph tutorial walkthrough", score=900) |
| 213 | out = reddit_keyless._slot_priority("Hermes Agent review", [off_topic, on_topic]) |
| 214 | assert out[0] is on_topic |
| 215 | |
| 216 | def test_all_miss_keeps_score_order_and_full_slots(self): |
| 217 | posts = [self._titled(i, f"Gemma thread {i}", score=1000 - i) for i in range(5)] |
| 218 | out = reddit_keyless._slot_priority("openclaw", posts) |
| 219 | assert out == posts # order unchanged |
| 220 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 221 | mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments", |
| 222 | return_value={"top_comments": [], "comment_insights": [], |
| 223 | "num_comments": None}) as fc: |
| 224 | reddit_keyless.search_and_enrich( |
| 225 | "openclaw", "2026-05-01", "2026-05-31", depth="quick") |
| 226 | assert fc.call_count == reddit_keyless.ENRICH_LIMITS["quick"] |
| 227 | |
| 228 | def test_same_tier_order_preserved(self): |
| 229 | posts = [self._titled(i, f"openclaw thread {i}", score=100 - i) for i in range(4)] |
| 230 | out = reddit_keyless._slot_priority("openclaw", posts) |
| 231 | assert out == posts |
| 232 | |
| 233 | def test_empty_entity_falls_back_to_token_overlap(self): |
| 234 | # Pure intent-modifier topic yields no primary entity; fallback path |
| 235 | # must not raise and must keep every post. |
| 236 | posts = [self._titled(1, "Post one"), self._titled(2, "review of things")] |
| 237 | out = reddit_keyless._slot_priority("review", posts) |
| 238 | assert len(out) == 2 |
| 239 | assert {p["url"] for p in out} == {p["url"] for p in posts} |
| 240 | |
| 241 | def test_selftext_match_lands_in_match_tier(self): |
| 242 | body_match = self._titled(1, "Need help with my setup", score=2, |
| 243 | selftext="my openclaw agent keeps asking for ssh keys") |
| 244 | off_topic = self._titled(2, "Gemma 4 with QAT", score=700) |
| 245 | out = reddit_keyless._slot_priority("openclaw", [off_topic, body_match]) |
| 246 | assert out[0] is body_match |
| 247 | |
| 248 | def test_none_score_posts_do_not_break_partition(self): |
| 249 | p1 = self._titled(1, "openclaw tips") |
| 250 | p1["engagement"]["score"] = None |
| 251 | p2 = self._titled(2, "Gemma news") |
| 252 | p2["engagement"]["score"] = None |
| 253 | out = reddit_keyless._slot_priority("openclaw", [p2, p1]) |
| 254 | assert out[0] is p1 |
| 255 | |
| 256 | def test_partition_never_raises(self): |
| 257 | posts = [self._titled(1, "openclaw tips", score=1)] |
| 258 | with mock.patch("lib.rerank._primary_entity", side_effect=Exception("boom")): |
| 259 | out = reddit_keyless._slot_priority("openclaw", posts) |
| 260 | assert out == posts |
| 261 | |
| 262 | @staticmethod |
| 263 | def _titled_nc(i, title, score=0, ncmt=0, selftext=""): |
| 264 | """_titled variant that also sets a real comment count (both surfaces).""" |
| 265 | p = TestSlotPriority._titled(i, title, score=score, selftext=selftext) |
| 266 | p["num_comments"] = ncmt |
| 267 | p["engagement"]["num_comments"] = ncmt |
| 268 | return p |
| 269 | |
| 270 | def test_comment_count_orders_within_match_tier(self): |
| 271 | # Two entity-matching posts: the low-score high-comment thread wins the slot. |
| 272 | high_comments = self._titled_nc(1, "openclaw thread with lots of discussion", score=1, ncmt=45) |
| 273 | low_comments = self._titled_nc(2, "openclaw thread, quiet", score=900, ncmt=3) |
| 274 | out = reddit_keyless._slot_priority("openclaw", [low_comments, high_comments]) |
| 275 | assert out[0] is high_comments |
| 276 | assert out[1] is low_comments |
| 277 | |
| 278 | def test_entity_match_tier_beats_comment_count(self): |
| 279 | # Entity priority is preserved: a miss with 100 comments still follows a |
| 280 | # match with 1 comment, regardless of discussion volume. |
| 281 | match = self._titled_nc(1, "openclaw tips", score=10, ncmt=1) |
| 282 | miss = self._titled_nc(2, "Gemma news", score=100, ncmt=100) |
| 283 | out = reddit_keyless._slot_priority("openclaw", [miss, match]) |
| 284 | assert out[0] is match |
| 285 | assert out[1] is miss |
| 286 | |
| 287 | def test_equal_comment_counts_preserve_incoming_order_stable(self): |
| 288 | # Stable tiebreak: equal comment counts preserve the incoming order. The |
| 289 | # score-first order is established by search_and_enrich's provisional |
| 290 | # sort before _slot_priority runs; _slot_priority must not re-sort ties. |
| 291 | p1 = self._titled_nc(1, "openclaw thread a", score=100, ncmt=5) |
| 292 | p2 = self._titled_nc(2, "openclaw thread b", score=50, ncmt=5) |
| 293 | out = reddit_keyless._slot_priority("openclaw", [p2, p1]) |
| 294 | assert out[0] is p2 |
| 295 | assert out[1] is p1 |
| 296 | |
| 297 | def test_unknown_comment_count_ties_with_zero(self): |
| 298 | # Missing/None comment count is treated as 0: it ties with a known-zero |
| 299 | # post (stable) and sorts below any positive-count post in its tier. |
| 300 | unknown = self._titled_nc(1, "openclaw unknown", score=100, ncmt=None) |
| 301 | positive = self._titled_nc(2, "openclaw positive", score=10, ncmt=3) |
| 302 | known_zero = self._titled_nc(3, "openclaw zero", score=5, ncmt=0) |
| 303 | out = reddit_keyless._slot_priority("openclaw", [known_zero, unknown, positive]) |
| 304 | assert out[0] is positive |
| 305 | assert out[1:] == [known_zero, unknown] |
| 306 | |
| 307 | def test_richest_thread_gets_slot_when_score_ranked_low(self): |
| 308 | # Issue #906 regression: a 45-comment thread ranked last by score must |
| 309 | # get an enrichment slot at default depth (limit 8) while a 4-comment |
| 310 | # thread above it in score order does not. All posts are in the same |
| 311 | # entity tier; there are more posts than slots so ordering matters. |
| 312 | posts = [ |
| 313 | self._titled_nc(1, "openclaw thread one", score=1000, ncmt=4), |
| 314 | self._titled_nc(2, "openclaw thread two", score=900, ncmt=4), |
| 315 | self._titled_nc(3, "openclaw thread three", score=800, ncmt=4), |
| 316 | self._titled_nc(4, "openclaw thread four", score=700, ncmt=4), |
| 317 | self._titled_nc(5, "openclaw thread five", score=600, ncmt=6), |
| 318 | self._titled_nc(6, "openclaw thread six", score=500, ncmt=5), |
| 319 | self._titled_nc(7, "openclaw thread seven", score=300, ncmt=4), |
| 320 | self._titled_nc(9, "openclaw thread nine", score=250, ncmt=7), |
| 321 | self._titled_nc(10, "openclaw thread ten", score=200, ncmt=8), |
| 322 | self._titled_nc(11, "openclaw thread eleven", score=150, ncmt=9), |
| 323 | self._titled_nc(8, "openclaw thread eight", score=77, ncmt=45), |
| 324 | ] |
| 325 | enriched_urls = [] |
| 326 | |
| 327 | def _capture(url): |
| 328 | enriched_urls.append(url) |
| 329 | return {"top_comments": [], "comment_insights": [], "num_comments": None} |
| 330 | |
| 331 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 332 | mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments", |
| 333 | side_effect=_capture): |
| 334 | reddit_keyless.search_and_enrich( |
| 335 | "openclaw", "2026-05-01", "2026-05-31", depth="default") |
| 336 | assert posts[10]["url"] in enriched_urls # 45-comment thread enriched |
| 337 | assert posts[6]["url"] not in enriched_urls # 4-comment thread above it skipped |
| 338 | assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["default"] |
| 339 | |
| 340 | def test_miss_tier_orders_by_comments_for_leftover_slots(self): |
| 341 | # Review finding #1 (validated): when the entity-match tier is smaller |
| 342 | # than ENRICH_LIMITS, leftover slots are filled from the miss tier in |
| 343 | # comment-count order. 1 match + 4 misses at quick depth (limit 4): the |
| 344 | # three most-commented misses get slots, the least-commented miss does not. |
| 345 | # Score order deliberately differs from comment order so this test |
| 346 | # discriminates the miss-tier sort from the old score-first order. |
| 347 | posts = [ |
| 348 | self._titled_nc(1, "openclaw thread", score=100, ncmt=2), |
| 349 | self._titled_nc(2, "Gemma thread A", score=5, ncmt=30), |
| 350 | self._titled_nc(3, "Gemma thread B", score=40, ncmt=9), |
| 351 | self._titled_nc(4, "Gemma thread C", score=30, ncmt=2), |
| 352 | self._titled_nc(5, "Gemma thread D", score=20, ncmt=1), |
| 353 | ] |
| 354 | enriched_urls = [] |
| 355 | |
| 356 | def _capture(url): |
| 357 | enriched_urls.append(url) |
| 358 | return {"top_comments": [], "comment_insights": [], "num_comments": None} |
| 359 | |
| 360 | with mock.patch.object(reddit_keyless, "_discover", return_value=posts), \ |
| 361 | mock.patch.object(reddit_keyless.reddit_shreddit, "fetch_comments", |
| 362 | side_effect=_capture): |
| 363 | reddit_keyless.search_and_enrich( |
| 364 | "openclaw", "2026-05-01", "2026-05-31", depth="quick") |
| 365 | assert posts[0]["url"] in enriched_urls # entity match always slotted |
| 366 | assert posts[1]["url"] in enriched_urls # 30-comment miss (top miss) |
| 367 | assert posts[2]["url"] in enriched_urls # 9-comment miss |
| 368 | assert posts[3]["url"] in enriched_urls # 2-comment miss takes the last slot |
| 369 | assert posts[4]["url"] not in enriched_urls # 1-comment miss below the cut |
| 370 | assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["quick"] |
| 371 | |
| 372 | |
| 373 | class TestScoredListingsFallback: |
| 374 | """_scored_listings falls back to the arctic-shift archive when the |
| 375 | shreddit listing partials return nothing (datacenter egress 403).""" |
| 376 | |
| 377 | def test_arctic_fallback_when_shreddit_empty(self): |
| 378 | arctic_post = _scored(1, score=406) |
| 379 | with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 380 | return_value=[]), \ |
| 381 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 382 | return_value=[arctic_post]) as arctic: |
| 383 | out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha") |
| 384 | assert out == [arctic_post] |
| 385 | arctic.assert_called_once_with(["tea"], depth="quick", query="matcha", sorts=None) |
| 386 | |
| 387 | def test_shreddit_and_arctic_both_called_deduped(self): |
| 388 | """Shreddit and arctic are both called; arctic supplements missing posts.""" |
| 389 | shreddit_post = _scored(1, score=42) |
| 390 | shreddit_post["subreddit"] = "tea" |
| 391 | arctic_post = _scored(2, score=100) |
| 392 | arctic_post["subreddit"] = "tea" |
| 393 | with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 394 | return_value=[shreddit_post]), \ |
| 395 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 396 | return_value=[arctic_post]) as arctic: |
| 397 | out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha") |
| 398 | # Both shreddit and arctic posts should be in the result (deduped by URL). |
| 399 | assert len(out) == 2 |
| 400 | urls = {p["url"] for p in out} |
| 401 | assert shreddit_post["url"] in urls |
| 402 | assert arctic_post["url"] in urls |
| 403 | arctic.assert_called_once() |
| 404 | |
| 405 | def test_both_empty_returns_empty(self): |
| 406 | with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 407 | return_value=[]), \ |
| 408 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 409 | return_value=[]): |
| 410 | out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha") |
| 411 | assert out == [] |
| 412 | |
| 413 | def test_never_raises_when_arctic_fails(self): |
| 414 | with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 415 | return_value=[]), \ |
| 416 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 417 | side_effect=Exception("boom")): |
| 418 | out = reddit_keyless._scored_listings(["tea"], depth="quick", query="matcha") |
| 419 | assert out == [] |
| 420 | |
| 421 | def test_dedicated_sorts_passed_through(self): |
| 422 | with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 423 | return_value=[]), \ |
| 424 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 425 | return_value=[]) as arctic: |
| 426 | reddit_keyless._scored_listings( |
| 427 | ["Kanye"], depth="default", query="Kanye", sorts=["top", "hot", "new"] |
| 428 | ) |
| 429 | arctic.assert_called_once_with( |
| 430 | ["Kanye"], depth="default", query="Kanye", sorts=["top", "hot", "new"] |
| 431 | ) |
| 432 | |
| 433 | def test_arctic_supplements_all_subreddits(self): |
| 434 | """Arctic is called for all subreddits to supplement any failed sort lanes.""" |
| 435 | shreddit_post = _scored(1, score=100) |
| 436 | shreddit_post["subreddit"] = "tea" |
| 437 | arctic_post_tea = _scored(2, score=200) |
| 438 | arctic_post_tea["subreddit"] = "tea" |
| 439 | arctic_post_coffee = _scored(3, score=150) |
| 440 | arctic_post_coffee["subreddit"] = "coffee" |
| 441 | |
| 442 | def shreddit_side_effect(subs, **kwargs): |
| 443 | # Shreddit only returns posts for "tea", not "coffee". |
| 444 | return [shreddit_post] if "tea" in subs else [] |
| 445 | |
| 446 | with mock.patch.object(reddit_keyless.reddit_listing, "fetch_listings", |
| 447 | side_effect=shreddit_side_effect), \ |
| 448 | mock.patch.object(reddit_keyless.reddit_arctic, "fetch_listings", |
| 449 | return_value=[arctic_post_tea, arctic_post_coffee]) as arctic: |
| 450 | out = reddit_keyless._scored_listings( |
| 451 | ["tea", "coffee"], depth="quick", query="beverages" |
| 452 | ) |
| 453 | # Arctic is called for ALL requested subreddits to supplement any failed sorts. |
| 454 | arctic.assert_called_once() |
| 455 | call_args = arctic.call_args |
| 456 | assert set(call_args[0][0]) == {"tea", "coffee"}, "arctic should be called for all subs" |
| 457 | # All posts should be in the result (deduped by URL). |
| 458 | urls = [p["url"] for p in out] |
| 459 | assert shreddit_post["url"] in urls |
| 460 | assert arctic_post_tea["url"] in urls |
| 461 | assert arctic_post_coffee["url"] in urls |
| 462 |