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.
@@ -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"