| 1 | import unittest |
| 2 | |
| 3 | from lib.reddit import ( |
| 4 | _extract_date, |
| 5 | _extract_score, |
| 6 | _extract_subreddit_name, |
| 7 | _normalize_reddit_id, |
| 8 | _total_engagement, |
| 9 | enrich_with_comments, |
| 10 | ) |
| 11 | |
| 12 | |
| 13 | class TestExtractSubredditName(unittest.TestCase): |
| 14 | def test_from_string(self): |
| 15 | self.assertEqual("openclaw", _extract_subreddit_name("openclaw")) |
| 16 | |
| 17 | def test_from_dict_with_name(self): |
| 18 | self.assertEqual( |
| 19 | "openclaw", |
| 20 | _extract_subreddit_name({"id": "t5_ghydwa", "name": "openclaw"}), |
| 21 | ) |
| 22 | |
| 23 | def test_from_dict_with_display_name(self): |
| 24 | self.assertEqual( |
| 25 | "LocalLLM", |
| 26 | _extract_subreddit_name({"display_name": "LocalLLM"}), |
| 27 | ) |
| 28 | |
| 29 | def test_from_dict_name_preferred_over_display_name(self): |
| 30 | self.assertEqual( |
| 31 | "name_wins", |
| 32 | _extract_subreddit_name({"name": "name_wins", "display_name": "display"}), |
| 33 | ) |
| 34 | |
| 35 | def test_empty_string(self): |
| 36 | self.assertEqual("", _extract_subreddit_name("")) |
| 37 | |
| 38 | def test_empty_dict(self): |
| 39 | self.assertEqual("", _extract_subreddit_name({})) |
| 40 | |
| 41 | def test_strips_whitespace(self): |
| 42 | self.assertEqual("test", _extract_subreddit_name(" test ")) |
| 43 | |
| 44 | |
| 45 | class TestExtractScore(unittest.TestCase): |
| 46 | def test_ups(self): |
| 47 | self.assertEqual(42, _extract_score({"ups": 42})) |
| 48 | |
| 49 | def test_score_field(self): |
| 50 | self.assertEqual(77, _extract_score({"score": 77})) |
| 51 | |
| 52 | def test_votes(self): |
| 53 | self.assertEqual(99, _extract_score({"votes": 99})) |
| 54 | |
| 55 | def test_ups_preferred_over_votes(self): |
| 56 | self.assertEqual(10, _extract_score({"ups": 10, "votes": 99})) |
| 57 | |
| 58 | def test_missing(self): |
| 59 | self.assertEqual(0, _extract_score({})) |
| 60 | |
| 61 | def test_zero_preserved(self): |
| 62 | self.assertEqual(0, _extract_score({"ups": 0})) |
| 63 | |
| 64 | def test_zero_ups_does_not_fall_through(self): |
| 65 | # ups=0 should be returned, not fall through to score |
| 66 | self.assertEqual(0, _extract_score({"ups": 0, "score": 5})) |
| 67 | |
| 68 | |
| 69 | class TestExtractDate(unittest.TestCase): |
| 70 | def test_unix_timestamp(self): |
| 71 | self.assertEqual("2024-05-03", _extract_date({"created_utc": 1714694957})) |
| 72 | |
| 73 | def test_iso_string(self): |
| 74 | result = _extract_date({"created_at": "2024-05-03T01:09:17.620000+0000"}) |
| 75 | self.assertEqual("2024-05-03", result) |
| 76 | |
| 77 | def test_iso_with_z_suffix(self): |
| 78 | result = _extract_date({"created_at": "2024-05-03T01:09:17Z"}) |
| 79 | self.assertEqual("2024-05-03", result) |
| 80 | |
| 81 | def test_created_utc_preferred(self): |
| 82 | result = _extract_date({"created_utc": 1714694957, "created_at": "2025-01-01T00:00:00Z"}) |
| 83 | self.assertEqual("2024-05-03", result) |
| 84 | |
| 85 | def test_missing(self): |
| 86 | self.assertIsNone(_extract_date({})) |
| 87 | |
| 88 | |
| 89 | class TestNormalizeRedditId(unittest.TestCase): |
| 90 | def test_strips_t3_prefix(self): |
| 91 | self.assertEqual("abc123", _normalize_reddit_id("t3_abc123")) |
| 92 | |
| 93 | def test_no_prefix(self): |
| 94 | self.assertEqual("abc123", _normalize_reddit_id("abc123")) |
| 95 | |
| 96 | def test_empty(self): |
| 97 | self.assertEqual("", _normalize_reddit_id("")) |
| 98 | |
| 99 | def test_none(self): |
| 100 | self.assertEqual("", _normalize_reddit_id(None)) |
| 101 | |
| 102 | |
| 103 | class TestTotalEngagement(unittest.TestCase): |
| 104 | def test_score_plus_comments(self): |
| 105 | item = {"engagement": {"score": 100, "num_comments": 50}} |
| 106 | self.assertEqual(150, _total_engagement(item)) |
| 107 | |
| 108 | def test_high_comments_low_score(self): |
| 109 | item = {"engagement": {"score": 1, "num_comments": 1387}} |
| 110 | self.assertEqual(1388, _total_engagement(item)) |
| 111 | |
| 112 | def test_missing_engagement(self): |
| 113 | self.assertEqual(0, _total_engagement({})) |
| 114 | |
| 115 | def test_none_values(self): |
| 116 | item = {"engagement": {"score": None, "num_comments": None}} |
| 117 | self.assertEqual(0, _total_engagement(item)) |
| 118 | |
| 119 | def test_score_only(self): |
| 120 | item = {"engagement": {"score": 42}} |
| 121 | self.assertEqual(42, _total_engagement(item)) |
| 122 | |
| 123 | |
| 124 | class TestEnrichSelectsTopEngagement(unittest.TestCase): |
| 125 | """Verify enrich_with_comments picks threads by total engagement, not list order.""" |
| 126 | |
| 127 | def test_high_comment_thread_enriched_over_low_engagement(self): |
| 128 | """A thread with 1387 comments but score:1 should be enriched before |
| 129 | a thread with score:5 and 0 comments.""" |
| 130 | from unittest.mock import patch |
| 131 | |
| 132 | items = [ |
| 133 | # Low engagement thread (first in list) |
| 134 | { |
| 135 | "id": "R1", |
| 136 | "url": "https://www.reddit.com/r/test/comments/low", |
| 137 | "engagement": {"score": 5, "num_comments": 0}, |
| 138 | }, |
| 139 | # High engagement thread (second in list) |
| 140 | { |
| 141 | "id": "R2", |
| 142 | "url": "https://www.reddit.com/r/test/comments/high", |
| 143 | "engagement": {"score": 1, "num_comments": 1387}, |
| 144 | }, |
| 145 | # Medium engagement |
| 146 | { |
| 147 | "id": "R3", |
| 148 | "url": "https://www.reddit.com/r/test/comments/med", |
| 149 | "engagement": {"score": 50, "num_comments": 10}, |
| 150 | }, |
| 151 | ] |
| 152 | |
| 153 | enriched_urls = [] |
| 154 | |
| 155 | def mock_fetch_comments(url, token): |
| 156 | enriched_urls.append(url) |
| 157 | return [{"body": "Great thread!", "ups": 10, "author": "testuser"}] |
| 158 | |
| 159 | # Only allow 1 enrichment to prove selection order matters |
| 160 | with patch("lib.reddit.fetch_post_comments", side_effect=mock_fetch_comments): |
| 161 | result = enrich_with_comments(items, token="fake", depth="quick") |
| 162 | |
| 163 | # With quick depth (3 enrichments), all 3 should be enriched. |
| 164 | # But the key assertion: the high-comment thread (R2) must be included. |
| 165 | self.assertIn( |
| 166 | "https://www.reddit.com/r/test/comments/high", |
| 167 | enriched_urls, |
| 168 | "High-comment thread should always be selected for enrichment", |
| 169 | ) |
| 170 | |
| 171 | def test_enrichment_order_by_engagement(self): |
| 172 | """With a budget of 1, only the highest-engagement thread gets enriched.""" |
| 173 | from unittest.mock import patch |
| 174 | |
| 175 | items = [ |
| 176 | { |
| 177 | "id": "R1", |
| 178 | "url": "https://www.reddit.com/r/test/comments/a", |
| 179 | "engagement": {"score": 200, "num_comments": 5}, |
| 180 | }, |
| 181 | { |
| 182 | "id": "R2", |
| 183 | "url": "https://www.reddit.com/r/test/comments/b", |
| 184 | "engagement": {"score": 1, "num_comments": 1500}, |
| 185 | }, |
| 186 | ] |
| 187 | |
| 188 | enriched_urls = [] |
| 189 | |
| 190 | def mock_fetch_comments(url, token): |
| 191 | enriched_urls.append(url) |
| 192 | return [{"body": "Comment", "ups": 5, "author": "user"}] |
| 193 | |
| 194 | # Override DEPTH_CONFIG to allow only 1 enrichment |
| 195 | custom_config = {"comment_enrichments": 1} |
| 196 | with patch("lib.reddit.DEPTH_CONFIG", {"test": custom_config, "default": custom_config}), \ |
| 197 | patch("lib.reddit.fetch_post_comments", side_effect=mock_fetch_comments): |
| 198 | enrich_with_comments(items, token="fake", depth="test") |
| 199 | |
| 200 | # R2 has 1501 total engagement vs R1's 205 -- R2 should be picked |
| 201 | self.assertEqual(len(enriched_urls), 1) |
| 202 | self.assertEqual( |
| 203 | enriched_urls[0], |
| 204 | "https://www.reddit.com/r/test/comments/b", |
| 205 | ) |
| 206 | |
| 207 | |
| 208 | class TestEnrichmentBudget(unittest.TestCase): |
| 209 | """Tests for the enrichment time budget in enrich_with_comments().""" |
| 210 | |
| 211 | def _make_items(self, n): |
| 212 | return [ |
| 213 | {"url": f"https://reddit.com/r/test/comments/{i}/post", "score": 100 - i, "num_comments": 50, |
| 214 | "engagement": {"score": 100 - i, "num_comments": 50}} |
| 215 | for i in range(n) |
| 216 | ] |
| 217 | |
| 218 | def test_all_complete_within_budget(self): |
| 219 | """When enrichment is fast, all items get comments.""" |
| 220 | from unittest.mock import patch |
| 221 | items = self._make_items(3) |
| 222 | fast_comments = [{"body": "Great post!", "score": 42, "author": "user1"}] |
| 223 | |
| 224 | with patch("lib.reddit.fetch_post_comments", return_value=fast_comments): |
| 225 | result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=60) |
| 226 | |
| 227 | enriched = [i for i in result if i.get("top_comments")] |
| 228 | self.assertEqual(len(enriched), 3) |
| 229 | |
| 230 | def test_budget_zero_returns_items_unenriched(self): |
| 231 | """With budget=0, items are returned without enrichment (not discarded).""" |
| 232 | import time as _time |
| 233 | from unittest.mock import patch |
| 234 | |
| 235 | items = self._make_items(3) |
| 236 | |
| 237 | def slow_fetch(url, token): |
| 238 | _time.sleep(2) |
| 239 | return [{"body": "comment", "score": 10, "author": "u"}] |
| 240 | |
| 241 | with patch("lib.reddit.fetch_post_comments", side_effect=slow_fetch): |
| 242 | result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=0) |
| 243 | |
| 244 | # All 3 items returned (not discarded) |
| 245 | self.assertEqual(len(result), 3) |
| 246 | |
| 247 | def test_empty_items_returns_immediately(self): |
| 248 | result = enrich_with_comments([], "fake-token", depth="default", budget_seconds=60) |
| 249 | self.assertEqual(result, []) |
| 250 | |
| 251 | def test_exceptions_dont_crash(self): |
| 252 | """If enrichment raises, items are returned without comments.""" |
| 253 | from unittest.mock import patch |
| 254 | items = self._make_items(3) |
| 255 | |
| 256 | with patch("lib.reddit.fetch_post_comments", side_effect=ConnectionError("boom")): |
| 257 | result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=60) |
| 258 | |
| 259 | self.assertEqual(len(result), 3) |
| 260 | enriched = [i for i in result if i.get("top_comments")] |
| 261 | self.assertEqual(len(enriched), 0) |
| 262 | |
| 263 | if __name__ == "__main__": |
| 264 | unittest.main() |
| 265 | |
| 266 | |
| 267 | def test_window_to_time_filter_rounds_up_for_rolling_buckets(monkeypatch): |
| 268 | """Recent calendar windows need the next Reddit rolling bucket (Greptile on #860).""" |
| 269 | from datetime import date |
| 270 | from lib import reddit |
| 271 | |
| 272 | class _FrozenDateTime: |
| 273 | @staticmethod |
| 274 | def now(tz=None): |
| 275 | class _D: |
| 276 | @staticmethod |
| 277 | def date(): |
| 278 | return date(2026, 7, 24) |
| 279 | return _D() |
| 280 | |
| 281 | monkeypatch.setattr(reddit, "datetime", _FrozenDateTime) |
| 282 | assert reddit._window_to_time_filter("2026-07-23", "2026-07-24") == "week" |
| 283 | assert reddit._window_to_time_filter("2026-07-17", "2026-07-24") == "month" |
| 284 | assert reddit._window_to_time_filter("2026-06-24", "2026-07-24") == "month" |
| 285 | # Just past the month bucket (+1 slack) needs year. |
| 286 | assert reddit._window_to_time_filter("2026-06-23", "2026-07-24") == "year" |
| 287 | |
| 288 | |
| 289 | def test_window_to_time_filter_covers_historical_from_date(monkeypatch): |
| 290 | """Short historical windows must still reach from_date (Greptile follow-up on #860).""" |
| 291 | from datetime import date |
| 292 | from lib import reddit |
| 293 | |
| 294 | class _FrozenDateTime: |
| 295 | @staticmethod |
| 296 | def now(tz=None): |
| 297 | class _D: |
| 298 | @staticmethod |
| 299 | def date(): |
| 300 | return date(2026, 7, 24) |
| 301 | return _D() |
| 302 | |
| 303 | monkeypatch.setattr(reddit, "datetime", _FrozenDateTime) |
| 304 | # One-day request ending two weeks ago: span alone would pick "week" and |
| 305 | # miss the entire range; age of from_date requires "month". |
| 306 | assert reddit._window_to_time_filter("2026-07-09", "2026-07-10") == "month" |
| 307 |