返回 last30days-skill
test_reddit_arctic.py
根目录 / tests / test_reddit_arctic.py
1 """Tests for reddit_arctic — keyless post-score lookup via arctic-shift (U6)."""
2
3 from unittest import mock
4
5 import pytest
6
7 from lib import reddit_arctic
8
9
10 @pytest.fixture(autouse=True)
11 def _no_arctic_network():
12 # Override conftest's stub so this module exercises the real fetch_scores;
13 # network is mocked per-test at http.get. Clear the in-run cache each time.
14 reddit_arctic._cache.clear()
15 yield
16 reddit_arctic._cache.clear()
17
18
19 def _resp(rows):
20 return {"data": rows}
21
22
23 class TestFetchScores:
24 def test_returns_scores_by_id(self):
25 with mock.patch.object(reddit_arctic.http, "get",
26 return_value=_resp([{"id": "abc", "score": 1531, "num_comments": 336}])):
27 out = reddit_arctic.fetch_scores(["abc"])
28 assert out == {"abc": {"score": 1531, "num_comments": 336}}
29
30 def test_strips_t3_prefix(self):
31 with mock.patch.object(reddit_arctic.http, "get",
32 return_value=_resp([{"id": "t3_xyz", "score": 5, "num_comments": 2}])):
33 out = reddit_arctic.fetch_scores(["xyz"])
34 assert out["xyz"]["score"] == 5
35
36 def test_rate_limit_response_degrades(self):
37 # arctic-shift answers {"error": "...slow down"} with no data list.
38 with mock.patch.object(reddit_arctic.http, "get",
39 return_value={"error": "Timeout. Maybe slow down a bit"}):
40 out = reddit_arctic.fetch_scores(["abc"])
41 assert out == {}
42
43 def test_network_error_degrades(self):
44 with mock.patch.object(reddit_arctic.http, "get", side_effect=Exception("boom")):
45 out = reddit_arctic.fetch_scores(["abc"])
46 assert out == {}
47
48 def test_in_run_cache_avoids_refetch(self):
49 with mock.patch.object(reddit_arctic.http, "get",
50 return_value=_resp([{"id": "abc", "score": 9, "num_comments": 1}])) as g:
51 reddit_arctic.fetch_scores(["abc"])
52 reddit_arctic.fetch_scores(["abc"]) # served from cache, no 2nd call
53 assert g.call_count == 1
54
55 def test_dedupes_into_single_batch(self):
56 with mock.patch.object(reddit_arctic.http, "get",
57 return_value=_resp([{"id": "a", "score": 1, "num_comments": 0},
58 {"id": "b", "score": 2, "num_comments": 0}])) as g:
59 out = reddit_arctic.fetch_scores(["a", "b", "a", ""])
60 assert g.call_count == 1
61 assert set(out) == {"a", "b"}
62
63
64 def _listing_row(pid="abc123", title="matcha farm tour", score=406, ncmt=88,
65 created=1783000000, subreddit="tea", permalink="/r/tea/comments/abc123/x/"):
66 return {
67 "id": pid, "title": title, "score": score, "num_comments": ncmt,
68 "created_utc": created, "subreddit": subreddit,
69 "permalink": permalink, "author": "u", "selftext": "",
70 }
71
72
73 class TestFetchListings:
74 """fetch_listings serves scored subreddit listings from the archive —
75 the keyless fallback for hosts where shreddit partials 403."""
76
77 def test_returns_normalized_scored_posts(self):
78 with mock.patch.object(
79 reddit_arctic.http, "get",
80 return_value=_resp([_listing_row()]),
81 ) as g:
82 out = reddit_arctic.fetch_listings(["tea"], query="matcha")
83 assert len(out) == 1
84 post = out[0]
85 assert post["title"] == "matcha farm tour"
86 assert post["score"] == 406
87 assert post["engagement"]["score"] == 406
88 assert post["num_comments"] == 88
89 assert post["subreddit"] == "tea"
90 assert post["metadata"]["post_id"] == "abc123" # t3_ prefix stripped
91 assert post["date"] == "2026-07-02" # created_utc -> YYYY-MM-DD
92 assert post["url"].startswith("https://www.reddit.com/r/tea/comments/")
93 assert post["why_relevant"] == "Reddit listing (arctic-shift)"
94 # one call per subreddit, recent-first, depth-default volume
95 assert "subreddit=tea" in g.call_args[0][0]
96 assert "limit=25" in g.call_args[0][0]
97
98 def test_strips_r_prefix_and_skips_all(self):
99 with mock.patch.object(reddit_arctic.http, "get",
100 return_value=_resp([_listing_row(subreddit="tea")])) as g:
101 out = reddit_arctic.fetch_listings(["r/tea", "all", ""])
102 assert len(out) == 1
103 assert g.call_count == 1 # only r/tea fetched; "all"/"" skipped
104
105 def test_processes_all_subreddits_no_cap(self):
106 """All requested subreddits are processed (no hard cap)."""
107 subs = [f"sub{i}" for i in range(20)]
108 with mock.patch.object(reddit_arctic.http, "get",
109 return_value=_resp([_listing_row()])) as g:
110 reddit_arctic.fetch_listings(subs)
111 # All 20 should be fetched (no cap).
112 assert g.call_count == 20
113
114 def test_depth_controls_volume(self):
115 for depth, want in (("quick", 10), ("default", 25), ("deep", 50)):
116 with mock.patch.object(reddit_arctic.http, "get",
117 return_value=_resp([_listing_row()])) as g:
118 reddit_arctic.fetch_listings(["tea"], depth=depth)
119 assert f"limit={want}" in g.call_args[0][0], depth
120
121 def test_multi_sort_request_increases_limit(self):
122 """When multiple sorts are requested, fetch 2x posts to compensate."""
123 with mock.patch.object(reddit_arctic.http, "get",
124 return_value=_resp([_listing_row()])) as g:
125 reddit_arctic.fetch_listings(["tea"], depth="default", sorts=["top", "hot", "new"])
126 # default depth = 25, with 3 sorts → 25 * 2 = 50
127 assert "limit=50" in g.call_args[0][0]
128
129 def test_single_sort_uses_base_limit(self):
130 """Single sort uses base limit, no multiplier."""
131 with mock.patch.object(reddit_arctic.http, "get",
132 return_value=_resp([_listing_row()])) as g:
133 reddit_arctic.fetch_listings(["tea"], depth="default", sorts=["top"])
134 assert "limit=25" in g.call_args[0][0]
135
136 def test_dedupes_by_url(self):
137 rows = [_listing_row(), _listing_row()]
138 with mock.patch.object(reddit_arctic.http, "get", return_value=_resp(rows)):
139 out = reddit_arctic.fetch_listings(["tea"])
140 assert len(out) == 1
141
142 def test_network_error_degrades_to_empty(self):
143 with mock.patch.object(reddit_arctic.http, "get", side_effect=Exception("boom")):
144 assert reddit_arctic.fetch_listings(["tea"]) == []
145
146 def test_rate_limit_response_degrades_to_empty(self):
147 with mock.patch.object(reddit_arctic.http, "get",
148 return_value={"error": "Timeout. Maybe slow down a bit"}):
149 assert reddit_arctic.fetch_listings(["tea"]) == []
150
151 def test_empty_subreddits_returns_empty(self):
152 assert reddit_arctic.fetch_listings([]) == []
153
154 def test_removed_author_normalized(self):
155 row = _listing_row()
156 row["author"] = "[deleted]"
157 with mock.patch.object(reddit_arctic.http, "get", return_value=_resp([row])):
158 out = reddit_arctic.fetch_listings(["tea"])
159 assert out[0]["author"] == "[deleted]"
160
161 def test_cache_is_size_bounded(self):
162 # The in-run memo never grows past CACHE_MAX; scores are still returned
163 # for the current call once the cache is full.
164 with mock.patch.object(reddit_arctic, "CACHE_MAX", 1), \
165 mock.patch.object(reddit_arctic.http, "get",
166 return_value=_resp([{"id": "a", "score": 1, "num_comments": 0},
167 {"id": "b", "score": 2, "num_comments": 0}])):
168 out = reddit_arctic.fetch_scores(["a", "b"])
169 assert set(out) == {"a", "b"} # both returned
170 assert len(reddit_arctic._cache) <= 1 # cache stayed bounded
171
172 def test_empty_input_makes_no_call(self):
173 with mock.patch.object(reddit_arctic.http, "get") as g:
174 out = reddit_arctic.fetch_scores([])
175 assert out == {}
176 g.assert_not_called()
177
178 def test_malformed_rows_skipped(self):
179 rows = [{"id": "ok", "score": 7, "num_comments": 3},
180 {"id": "", "score": 1}, # no id
181 "not-a-dict", # junk
182 {"id": "bad", "score": "x"}] # unparseable score
183 with mock.patch.object(reddit_arctic.http, "get", return_value=_resp(rows)):
184 out = reddit_arctic.fetch_scores(["ok", "bad"])
185 assert out == {"ok": {"score": 7, "num_comments": 3}}
186
186 lines PYTHON