cli-web-reddit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_web/reddit/README.md +68 -0
- cli_web/reddit/__init__.py +3 -0
- cli_web/reddit/__main__.py +6 -0
- cli_web/reddit/commands/__init__.py +0 -0
- cli_web/reddit/commands/actions.py +268 -0
- cli_web/reddit/commands/auth_cmd.py +73 -0
- cli_web/reddit/commands/feed.py +115 -0
- cli_web/reddit/commands/me.py +139 -0
- cli_web/reddit/commands/post.py +93 -0
- cli_web/reddit/commands/search.py +66 -0
- cli_web/reddit/commands/subreddit.py +184 -0
- cli_web/reddit/commands/user.py +90 -0
- cli_web/reddit/core/__init__.py +0 -0
- cli_web/reddit/core/auth.py +204 -0
- cli_web/reddit/core/client.py +475 -0
- cli_web/reddit/core/exceptions.py +63 -0
- cli_web/reddit/core/models.py +253 -0
- cli_web/reddit/reddit_cli.py +174 -0
- cli_web/reddit/skills/SKILL.md +143 -0
- cli_web/reddit/tests/TEST.md +109 -0
- cli_web/reddit/tests/__init__.py +0 -0
- cli_web/reddit/tests/conftest.py +9 -0
- cli_web/reddit/tests/test_core.py +568 -0
- cli_web/reddit/tests/test_e2e.py +312 -0
- cli_web/reddit/utils/__init__.py +0 -0
- cli_web/reddit/utils/doctor.py +188 -0
- cli_web/reddit/utils/helpers.py +91 -0
- cli_web/reddit/utils/mcp_server.py +290 -0
- cli_web/reddit/utils/output.py +133 -0
- cli_web/reddit/utils/repl_skin.py +486 -0
- cli_web_reddit-0.1.0.dist-info/METADATA +15 -0
- cli_web_reddit-0.1.0.dist-info/RECORD +35 -0
- cli_web_reddit-0.1.0.dist-info/WHEEL +5 -0
- cli_web_reddit-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_reddit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Unit tests for cli-web-reddit — mocked HTTP, no network required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from cli_web.reddit.core.exceptions import (
|
|
10
|
+
AuthError,
|
|
11
|
+
NetworkError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
RedditError,
|
|
15
|
+
ServerError,
|
|
16
|
+
)
|
|
17
|
+
from cli_web.reddit.core.models import (
|
|
18
|
+
_collect_comments,
|
|
19
|
+
extract_listing_posts,
|
|
20
|
+
extract_listing_posts_and_comments,
|
|
21
|
+
extract_listing_subreddits,
|
|
22
|
+
format_comment,
|
|
23
|
+
format_post_detail,
|
|
24
|
+
format_post_summary,
|
|
25
|
+
format_subreddit_info,
|
|
26
|
+
format_subreddit_search,
|
|
27
|
+
format_user_info,
|
|
28
|
+
)
|
|
29
|
+
from cli_web.reddit.utils.helpers import (
|
|
30
|
+
handle_errors,
|
|
31
|
+
json_error,
|
|
32
|
+
resolve_json_mode,
|
|
33
|
+
truncate,
|
|
34
|
+
)
|
|
35
|
+
from click.testing import CliRunner
|
|
36
|
+
|
|
37
|
+
# ── Sample fixtures ─────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
SAMPLE_POST_CHILD = {
|
|
40
|
+
"kind": "t3",
|
|
41
|
+
"data": {
|
|
42
|
+
"id": "abc123",
|
|
43
|
+
"title": "Test post title",
|
|
44
|
+
"author": "testuser",
|
|
45
|
+
"subreddit": "python",
|
|
46
|
+
"score": 42,
|
|
47
|
+
"num_comments": 5,
|
|
48
|
+
"upvote_ratio": 0.95,
|
|
49
|
+
"created_utc": 1700000000.0,
|
|
50
|
+
"url": "https://example.com",
|
|
51
|
+
"permalink": "/r/python/comments/abc123/test/",
|
|
52
|
+
"is_self": True,
|
|
53
|
+
"over_18": False,
|
|
54
|
+
"stickied": False,
|
|
55
|
+
"link_flair_text": "Discussion",
|
|
56
|
+
"selftext": "Body text here",
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
SAMPLE_COMMENT_CHILD = {
|
|
61
|
+
"kind": "t1",
|
|
62
|
+
"data": {
|
|
63
|
+
"id": "xyz789",
|
|
64
|
+
"author": "commenter",
|
|
65
|
+
"body": "Great post!",
|
|
66
|
+
"score": 10,
|
|
67
|
+
"created_utc": 1700001000.0,
|
|
68
|
+
"is_submitter": False,
|
|
69
|
+
"depth": 0,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
SAMPLE_SAVED_COMMENT_CHILD = {
|
|
74
|
+
"kind": "t1",
|
|
75
|
+
"data": {
|
|
76
|
+
"id": "saved1",
|
|
77
|
+
"author": "commenter2",
|
|
78
|
+
"body": "Saved this for later",
|
|
79
|
+
"score": 5,
|
|
80
|
+
"created_utc": 1700002000.0,
|
|
81
|
+
"is_submitter": False,
|
|
82
|
+
"depth": 0,
|
|
83
|
+
"subreddit": "learnpython",
|
|
84
|
+
"permalink": "/r/learnpython/comments/xxx/slug/saved1/",
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
SAMPLE_SUBREDDIT_CHILD = {
|
|
89
|
+
"kind": "t5",
|
|
90
|
+
"data": {
|
|
91
|
+
"display_name": "python",
|
|
92
|
+
"title": "Python",
|
|
93
|
+
"public_description": "News about Python",
|
|
94
|
+
"subscribers": 1200000,
|
|
95
|
+
"accounts_active": 5000,
|
|
96
|
+
"created_utc": 1200000000.0,
|
|
97
|
+
"over18": False,
|
|
98
|
+
"subreddit_type": "public",
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
SAMPLE_USER = {
|
|
103
|
+
"data": {
|
|
104
|
+
"name": "spez",
|
|
105
|
+
"link_karma": 100000,
|
|
106
|
+
"comment_karma": 50000,
|
|
107
|
+
"total_karma": 150000,
|
|
108
|
+
"created_utc": 1100000000.0,
|
|
109
|
+
"is_gold": True,
|
|
110
|
+
"has_verified_email": True,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
SAMPLE_LISTING = {
|
|
115
|
+
"data": {
|
|
116
|
+
"children": [SAMPLE_POST_CHILD],
|
|
117
|
+
"after": "t3_next123",
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _mock_response(status_code=200, json_data=None, text="", headers=None):
|
|
123
|
+
"""Create a mock response object."""
|
|
124
|
+
resp = MagicMock()
|
|
125
|
+
resp.status_code = status_code
|
|
126
|
+
resp.json.return_value = json_data or {}
|
|
127
|
+
resp.text = text
|
|
128
|
+
resp.headers = headers or {}
|
|
129
|
+
return resp
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Model tests ──────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.unit
|
|
136
|
+
class TestModels:
|
|
137
|
+
"""Test response model formatting functions."""
|
|
138
|
+
|
|
139
|
+
def test_format_post_summary(self):
|
|
140
|
+
result = format_post_summary(SAMPLE_POST_CHILD)
|
|
141
|
+
assert result["id"] == "abc123"
|
|
142
|
+
assert result["title"] == "Test post title"
|
|
143
|
+
assert result["author"] == "testuser"
|
|
144
|
+
assert result["subreddit"] == "python"
|
|
145
|
+
assert result["score"] == 42
|
|
146
|
+
assert result["num_comments"] == 5
|
|
147
|
+
assert result["flair"] == "Discussion"
|
|
148
|
+
assert result["is_self"] is True
|
|
149
|
+
|
|
150
|
+
def test_format_comment(self):
|
|
151
|
+
result = format_comment(SAMPLE_COMMENT_CHILD)
|
|
152
|
+
assert result["id"] == "xyz789"
|
|
153
|
+
assert result["author"] == "commenter"
|
|
154
|
+
assert result["body"] == "Great post!"
|
|
155
|
+
assert result["score"] == 10
|
|
156
|
+
assert result["depth"] == 0
|
|
157
|
+
|
|
158
|
+
def test_format_subreddit_info(self):
|
|
159
|
+
result = format_subreddit_info(SAMPLE_SUBREDDIT_CHILD)
|
|
160
|
+
assert result["name"] == "python"
|
|
161
|
+
assert result["subscribers"] == 1200000
|
|
162
|
+
assert result["type"] == "public"
|
|
163
|
+
assert result["over_18"] is False
|
|
164
|
+
|
|
165
|
+
def test_format_subreddit_search(self):
|
|
166
|
+
result = format_subreddit_search(SAMPLE_SUBREDDIT_CHILD)
|
|
167
|
+
assert result["name"] == "python"
|
|
168
|
+
assert result["subscribers"] == 1200000
|
|
169
|
+
|
|
170
|
+
def test_format_user_info(self):
|
|
171
|
+
result = format_user_info(SAMPLE_USER)
|
|
172
|
+
assert result["name"] == "spez"
|
|
173
|
+
assert result["total_karma"] == 150000
|
|
174
|
+
assert result["is_gold"] is True
|
|
175
|
+
|
|
176
|
+
def test_extract_listing_posts(self):
|
|
177
|
+
posts, after = extract_listing_posts(SAMPLE_LISTING)
|
|
178
|
+
assert len(posts) == 1
|
|
179
|
+
assert posts[0]["id"] == "abc123"
|
|
180
|
+
assert after == "t3_next123"
|
|
181
|
+
|
|
182
|
+
def test_extract_listing_posts_empty(self):
|
|
183
|
+
posts, after = extract_listing_posts({"data": {"children": [], "after": None}})
|
|
184
|
+
assert len(posts) == 0
|
|
185
|
+
assert after is None
|
|
186
|
+
|
|
187
|
+
def test_extract_listing_subreddits(self):
|
|
188
|
+
listing = {"data": {"children": [SAMPLE_SUBREDDIT_CHILD], "after": None}}
|
|
189
|
+
subs, after = extract_listing_subreddits(listing)
|
|
190
|
+
assert len(subs) == 1
|
|
191
|
+
assert subs[0]["name"] == "python"
|
|
192
|
+
assert after is None
|
|
193
|
+
|
|
194
|
+
def test_extract_listing_posts_and_comments_keeps_both_kinds(self):
|
|
195
|
+
"""Mixed listing with t3 and t1 should return both."""
|
|
196
|
+
listing = {
|
|
197
|
+
"data": {
|
|
198
|
+
"children": [SAMPLE_POST_CHILD, SAMPLE_SAVED_COMMENT_CHILD],
|
|
199
|
+
"after": "cursor123",
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
posts, comments, after = extract_listing_posts_and_comments(listing)
|
|
203
|
+
assert len(posts) == 1
|
|
204
|
+
assert len(comments) == 1
|
|
205
|
+
assert posts[0]["id"] == "abc123"
|
|
206
|
+
assert comments[0]["id"] == "saved1"
|
|
207
|
+
assert comments[0]["subreddit"] == "learnpython"
|
|
208
|
+
assert after == "cursor123"
|
|
209
|
+
|
|
210
|
+
def test_format_post_detail_basic(self):
|
|
211
|
+
comments_listing = {
|
|
212
|
+
"data": {"children": [SAMPLE_COMMENT_CHILD]},
|
|
213
|
+
}
|
|
214
|
+
result = format_post_detail(SAMPLE_POST_CHILD, comments_listing)
|
|
215
|
+
assert result["id"] == "abc123"
|
|
216
|
+
assert len(result["comments"]) == 1
|
|
217
|
+
assert result["comments"][0]["id"] == "xyz789"
|
|
218
|
+
|
|
219
|
+
def test_format_post_detail_flattens_nested_comments(self):
|
|
220
|
+
"""Nested replies should be flattened into the comments list."""
|
|
221
|
+
nested = {
|
|
222
|
+
"data": {
|
|
223
|
+
"children": [
|
|
224
|
+
{
|
|
225
|
+
"kind": "t1",
|
|
226
|
+
"data": {
|
|
227
|
+
"id": "parent1",
|
|
228
|
+
"author": "user1",
|
|
229
|
+
"body": "Top level",
|
|
230
|
+
"score": 5,
|
|
231
|
+
"created_utc": 1700000000.0,
|
|
232
|
+
"is_submitter": False,
|
|
233
|
+
"depth": 0,
|
|
234
|
+
"replies": {
|
|
235
|
+
"data": {
|
|
236
|
+
"children": [
|
|
237
|
+
{
|
|
238
|
+
"kind": "t1",
|
|
239
|
+
"data": {
|
|
240
|
+
"id": "child1",
|
|
241
|
+
"author": "user2",
|
|
242
|
+
"body": "Reply",
|
|
243
|
+
"score": 3,
|
|
244
|
+
"created_utc": 1700001000.0,
|
|
245
|
+
"is_submitter": False,
|
|
246
|
+
"depth": 1,
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
result = format_post_detail(SAMPLE_POST_CHILD, nested)
|
|
258
|
+
assert len(result["comments"]) == 2
|
|
259
|
+
assert result["comments"][0]["id"] == "parent1"
|
|
260
|
+
assert result["comments"][1]["id"] == "child1"
|
|
261
|
+
|
|
262
|
+
def test_collect_comments_empty(self):
|
|
263
|
+
comments = []
|
|
264
|
+
_collect_comments([], comments)
|
|
265
|
+
assert len(comments) == 0
|
|
266
|
+
|
|
267
|
+
def test_format_post_summary_deleted_author(self):
|
|
268
|
+
child = {"kind": "t3", "data": {"id": "del1"}}
|
|
269
|
+
result = format_post_summary(child)
|
|
270
|
+
assert result["author"] == "[deleted]"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── Client tests ──────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.mark.unit
|
|
277
|
+
class TestClient:
|
|
278
|
+
"""Test HTTP client with mocked responses."""
|
|
279
|
+
|
|
280
|
+
@patch("cli_web.reddit.core.client.curl_requests.Session")
|
|
281
|
+
@patch("cli_web.reddit.core.client.get_bearer_token", return_value=None)
|
|
282
|
+
def test_feed_hot(self, mock_token, MockSession):
|
|
283
|
+
from cli_web.reddit.core.client import RedditClient
|
|
284
|
+
|
|
285
|
+
session = MagicMock()
|
|
286
|
+
MockSession.return_value = session
|
|
287
|
+
session.get.return_value = _mock_response(json_data=SAMPLE_LISTING)
|
|
288
|
+
|
|
289
|
+
client = RedditClient()
|
|
290
|
+
data = client.feed_hot(limit=3)
|
|
291
|
+
assert data["data"]["children"][0]["kind"] == "t3"
|
|
292
|
+
|
|
293
|
+
@patch("cli_web.reddit.core.client.curl_requests.Session")
|
|
294
|
+
@patch("cli_web.reddit.core.client.get_bearer_token", return_value=None)
|
|
295
|
+
def test_404_raises_not_found(self, mock_token, MockSession):
|
|
296
|
+
from cli_web.reddit.core.client import RedditClient
|
|
297
|
+
|
|
298
|
+
session = MagicMock()
|
|
299
|
+
MockSession.return_value = session
|
|
300
|
+
session.get.side_effect = [_mock_response(200), _mock_response(404)]
|
|
301
|
+
|
|
302
|
+
client = RedditClient()
|
|
303
|
+
with pytest.raises(NotFoundError):
|
|
304
|
+
client.feed_hot()
|
|
305
|
+
|
|
306
|
+
@patch("cli_web.reddit.core.client.curl_requests.Session")
|
|
307
|
+
@patch("cli_web.reddit.core.client.get_bearer_token", return_value=None)
|
|
308
|
+
def test_429_raises_rate_limit(self, mock_token, MockSession):
|
|
309
|
+
from cli_web.reddit.core.client import RedditClient
|
|
310
|
+
|
|
311
|
+
session = MagicMock()
|
|
312
|
+
MockSession.return_value = session
|
|
313
|
+
session.get.side_effect = [
|
|
314
|
+
_mock_response(200),
|
|
315
|
+
_mock_response(429, headers={"retry-after": "60"}),
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
client = RedditClient()
|
|
319
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
320
|
+
client.feed_hot()
|
|
321
|
+
assert exc_info.value.retry_after == 60.0
|
|
322
|
+
|
|
323
|
+
@patch("cli_web.reddit.core.client.curl_requests.Session")
|
|
324
|
+
@patch("cli_web.reddit.core.client.get_bearer_token", return_value=None)
|
|
325
|
+
def test_500_raises_server_error(self, mock_token, MockSession):
|
|
326
|
+
from cli_web.reddit.core.client import RedditClient
|
|
327
|
+
|
|
328
|
+
session = MagicMock()
|
|
329
|
+
MockSession.return_value = session
|
|
330
|
+
session.get.side_effect = [_mock_response(200), _mock_response(500)]
|
|
331
|
+
|
|
332
|
+
client = RedditClient()
|
|
333
|
+
with pytest.raises(ServerError) as exc_info:
|
|
334
|
+
client.feed_hot()
|
|
335
|
+
assert exc_info.value.status_code == 500
|
|
336
|
+
|
|
337
|
+
@patch("cli_web.reddit.core.client.curl_requests.Session")
|
|
338
|
+
@patch("cli_web.reddit.core.client.get_bearer_token", return_value=None)
|
|
339
|
+
def test_403_raises_auth_error(self, mock_token, MockSession):
|
|
340
|
+
from cli_web.reddit.core.client import RedditClient
|
|
341
|
+
|
|
342
|
+
session = MagicMock()
|
|
343
|
+
MockSession.return_value = session
|
|
344
|
+
session.get.side_effect = [_mock_response(200), _mock_response(403)]
|
|
345
|
+
|
|
346
|
+
client = RedditClient()
|
|
347
|
+
with pytest.raises(AuthError):
|
|
348
|
+
client.feed_hot()
|
|
349
|
+
|
|
350
|
+
@patch("cli_web.reddit.core.client.curl_requests.Session")
|
|
351
|
+
@patch("cli_web.reddit.core.client.get_bearer_token", return_value=None)
|
|
352
|
+
def test_generic_4xx_raises_reddit_error(self, mock_token, MockSession):
|
|
353
|
+
from cli_web.reddit.core.client import RedditClient
|
|
354
|
+
|
|
355
|
+
session = MagicMock()
|
|
356
|
+
MockSession.return_value = session
|
|
357
|
+
session.get.side_effect = [_mock_response(200), _mock_response(418, text="I'm a teapot")]
|
|
358
|
+
|
|
359
|
+
client = RedditClient()
|
|
360
|
+
with pytest.raises(RedditError):
|
|
361
|
+
client.feed_hot()
|
|
362
|
+
|
|
363
|
+
@patch("cli_web.reddit.core.client.curl_requests.Session")
|
|
364
|
+
@patch("cli_web.reddit.core.client.get_bearer_token", return_value=None)
|
|
365
|
+
def test_network_error(self, mock_token, MockSession):
|
|
366
|
+
from cli_web.reddit.core.client import RedditClient
|
|
367
|
+
|
|
368
|
+
session = MagicMock()
|
|
369
|
+
MockSession.return_value = session
|
|
370
|
+
session.get.side_effect = [_mock_response(200), ConnectionError("fail")]
|
|
371
|
+
|
|
372
|
+
client = RedditClient()
|
|
373
|
+
with pytest.raises(NetworkError):
|
|
374
|
+
client.feed_hot()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ── Helpers tests ──────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@pytest.mark.unit
|
|
381
|
+
class TestHelpers:
|
|
382
|
+
"""Test shared utility functions."""
|
|
383
|
+
|
|
384
|
+
def test_json_error(self):
|
|
385
|
+
result = json.loads(json_error("NOT_FOUND", "Item not found"))
|
|
386
|
+
assert result["error"] is True
|
|
387
|
+
assert result["code"] == "NOT_FOUND"
|
|
388
|
+
assert result["message"] == "Item not found"
|
|
389
|
+
|
|
390
|
+
def test_json_error_with_extra(self):
|
|
391
|
+
result = json.loads(json_error("RATE_LIMITED", "Too fast", retry_after=60))
|
|
392
|
+
assert result["retry_after"] == 60
|
|
393
|
+
|
|
394
|
+
def test_truncate_long(self):
|
|
395
|
+
assert truncate("a" * 100, 10) == "a" * 10 + "..."
|
|
396
|
+
|
|
397
|
+
def test_truncate_short(self):
|
|
398
|
+
assert truncate("short", 50) == "short"
|
|
399
|
+
|
|
400
|
+
def test_truncate_none(self):
|
|
401
|
+
assert truncate(None) == ""
|
|
402
|
+
|
|
403
|
+
def test_truncate_empty(self):
|
|
404
|
+
assert truncate("") == ""
|
|
405
|
+
|
|
406
|
+
def test_handle_errors_not_found_exit_1(self):
|
|
407
|
+
with pytest.raises(SystemExit) as exc:
|
|
408
|
+
with handle_errors():
|
|
409
|
+
raise NotFoundError("gone")
|
|
410
|
+
assert exc.value.code == 1
|
|
411
|
+
|
|
412
|
+
def test_handle_errors_server_error_exit_2(self):
|
|
413
|
+
with pytest.raises(SystemExit) as exc:
|
|
414
|
+
with handle_errors():
|
|
415
|
+
raise ServerError("down", status_code=503)
|
|
416
|
+
assert exc.value.code == 2
|
|
417
|
+
|
|
418
|
+
def test_handle_errors_network_error_exit_2(self):
|
|
419
|
+
with pytest.raises(SystemExit) as exc:
|
|
420
|
+
with handle_errors():
|
|
421
|
+
raise NetworkError("timeout")
|
|
422
|
+
assert exc.value.code == 2
|
|
423
|
+
|
|
424
|
+
def test_handle_errors_keyboard_interrupt_exit_130(self):
|
|
425
|
+
with pytest.raises(SystemExit) as exc:
|
|
426
|
+
with handle_errors():
|
|
427
|
+
raise KeyboardInterrupt()
|
|
428
|
+
assert exc.value.code == 130
|
|
429
|
+
|
|
430
|
+
def test_handle_errors_json_mode_not_found(self, capsys):
|
|
431
|
+
with pytest.raises(SystemExit):
|
|
432
|
+
with handle_errors(json_mode=True):
|
|
433
|
+
raise NotFoundError("gone")
|
|
434
|
+
out = capsys.readouterr().out
|
|
435
|
+
data = json.loads(out)
|
|
436
|
+
assert data["code"] == "NOT_FOUND"
|
|
437
|
+
|
|
438
|
+
def test_handle_errors_json_mode_server_error(self, capsys):
|
|
439
|
+
with pytest.raises(SystemExit):
|
|
440
|
+
with handle_errors(json_mode=True):
|
|
441
|
+
raise ServerError("down")
|
|
442
|
+
out = capsys.readouterr().out
|
|
443
|
+
data = json.loads(out)
|
|
444
|
+
assert data["code"] == "SERVER_ERROR"
|
|
445
|
+
|
|
446
|
+
def test_handle_errors_json_mode_network_error(self, capsys):
|
|
447
|
+
with pytest.raises(SystemExit):
|
|
448
|
+
with handle_errors(json_mode=True):
|
|
449
|
+
raise NetworkError("dns fail")
|
|
450
|
+
out = capsys.readouterr().out
|
|
451
|
+
data = json.loads(out)
|
|
452
|
+
assert data["code"] == "NETWORK_ERROR"
|
|
453
|
+
|
|
454
|
+
def test_handle_errors_rate_limit_exit_1(self):
|
|
455
|
+
with pytest.raises(SystemExit) as exc:
|
|
456
|
+
with handle_errors():
|
|
457
|
+
raise RateLimitError("slow down", retry_after=30)
|
|
458
|
+
assert exc.value.code == 1
|
|
459
|
+
|
|
460
|
+
def test_resolve_json_mode_explicit_true(self):
|
|
461
|
+
assert resolve_json_mode(True) is True
|
|
462
|
+
|
|
463
|
+
def test_resolve_json_mode_explicit_false_no_ctx(self):
|
|
464
|
+
assert resolve_json_mode(False) is False
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ── CLI Click tests ──────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@pytest.mark.unit
|
|
471
|
+
class TestCLIClick:
|
|
472
|
+
"""Test CLI commands with Click test runner and mocked client."""
|
|
473
|
+
|
|
474
|
+
def test_version(self):
|
|
475
|
+
from cli_web.reddit.reddit_cli import cli
|
|
476
|
+
|
|
477
|
+
runner = CliRunner()
|
|
478
|
+
result = runner.invoke(cli, ["--version"])
|
|
479
|
+
assert result.exit_code == 0
|
|
480
|
+
assert "0.1.0" in result.output
|
|
481
|
+
|
|
482
|
+
def test_help(self):
|
|
483
|
+
from cli_web.reddit.reddit_cli import cli
|
|
484
|
+
|
|
485
|
+
runner = CliRunner()
|
|
486
|
+
result = runner.invoke(cli, ["--help"])
|
|
487
|
+
assert result.exit_code == 0
|
|
488
|
+
assert "feed" in result.output.lower()
|
|
489
|
+
|
|
490
|
+
@patch("cli_web.reddit.commands.feed.RedditClient")
|
|
491
|
+
def test_feed_hot_json(self, MockClient):
|
|
492
|
+
from cli_web.reddit.reddit_cli import cli
|
|
493
|
+
|
|
494
|
+
instance = MockClient.return_value
|
|
495
|
+
instance.feed_hot.return_value = SAMPLE_LISTING
|
|
496
|
+
|
|
497
|
+
runner = CliRunner()
|
|
498
|
+
result = runner.invoke(cli, ["feed", "hot", "--json", "--limit", "3"])
|
|
499
|
+
assert result.exit_code == 0
|
|
500
|
+
data = json.loads(result.output)
|
|
501
|
+
assert "posts" in data
|
|
502
|
+
assert len(data["posts"]) == 1
|
|
503
|
+
|
|
504
|
+
@patch("cli_web.reddit.commands.search.RedditClient")
|
|
505
|
+
def test_search_posts_json(self, MockClient):
|
|
506
|
+
from cli_web.reddit.reddit_cli import cli
|
|
507
|
+
|
|
508
|
+
instance = MockClient.return_value
|
|
509
|
+
instance.search_posts.return_value = SAMPLE_LISTING
|
|
510
|
+
|
|
511
|
+
runner = CliRunner()
|
|
512
|
+
result = runner.invoke(cli, ["search", "posts", "python", "--json"])
|
|
513
|
+
assert result.exit_code == 0
|
|
514
|
+
data = json.loads(result.output)
|
|
515
|
+
assert "posts" in data
|
|
516
|
+
|
|
517
|
+
@patch("cli_web.reddit.commands.feed.RedditClient")
|
|
518
|
+
def test_root_json_flows_to_subcommand(self, MockClient):
|
|
519
|
+
"""Root --json flag should be inherited by subcommands."""
|
|
520
|
+
from cli_web.reddit.reddit_cli import cli
|
|
521
|
+
|
|
522
|
+
instance = MockClient.return_value
|
|
523
|
+
instance.feed_hot.return_value = SAMPLE_LISTING
|
|
524
|
+
|
|
525
|
+
runner = CliRunner()
|
|
526
|
+
result = runner.invoke(cli, ["--json", "feed", "hot", "--limit", "3"])
|
|
527
|
+
assert result.exit_code == 0
|
|
528
|
+
data = json.loads(result.output)
|
|
529
|
+
assert "posts" in data
|
|
530
|
+
|
|
531
|
+
@patch("cli_web.reddit.commands.feed.RedditClient")
|
|
532
|
+
def test_feed_hot_json_error_on_not_found(self, MockClient):
|
|
533
|
+
from cli_web.reddit.reddit_cli import cli
|
|
534
|
+
|
|
535
|
+
instance = MockClient.return_value
|
|
536
|
+
instance.feed_hot.side_effect = NotFoundError("not found")
|
|
537
|
+
|
|
538
|
+
runner = CliRunner()
|
|
539
|
+
result = runner.invoke(cli, ["feed", "hot", "--json"])
|
|
540
|
+
data = json.loads(result.output)
|
|
541
|
+
assert data["error"] is True
|
|
542
|
+
assert data["code"] == "NOT_FOUND"
|
|
543
|
+
|
|
544
|
+
@patch("cli_web.reddit.commands.feed.RedditClient")
|
|
545
|
+
def test_feed_new_json(self, MockClient):
|
|
546
|
+
from cli_web.reddit.reddit_cli import cli
|
|
547
|
+
|
|
548
|
+
instance = MockClient.return_value
|
|
549
|
+
instance.feed_new.return_value = SAMPLE_LISTING
|
|
550
|
+
|
|
551
|
+
runner = CliRunner()
|
|
552
|
+
result = runner.invoke(cli, ["feed", "new", "--json"])
|
|
553
|
+
assert result.exit_code == 0
|
|
554
|
+
data = json.loads(result.output)
|
|
555
|
+
assert "posts" in data
|
|
556
|
+
|
|
557
|
+
@patch("cli_web.reddit.commands.search.RedditClient")
|
|
558
|
+
def test_search_posts_json_error_on_network(self, MockClient):
|
|
559
|
+
from cli_web.reddit.reddit_cli import cli
|
|
560
|
+
|
|
561
|
+
instance = MockClient.return_value
|
|
562
|
+
instance.search_posts.side_effect = NetworkError("timeout")
|
|
563
|
+
|
|
564
|
+
runner = CliRunner()
|
|
565
|
+
result = runner.invoke(cli, ["search", "posts", "test", "--json"])
|
|
566
|
+
data = json.loads(result.output)
|
|
567
|
+
assert data["error"] is True
|
|
568
|
+
assert data["code"] == "NETWORK_ERROR"
|