cli-web-hackernews 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/hackernews/README.md +91 -0
- cli_web/hackernews/__init__.py +0 -0
- cli_web/hackernews/__main__.py +6 -0
- cli_web/hackernews/commands/__init__.py +0 -0
- cli_web/hackernews/commands/actions.py +105 -0
- cli_web/hackernews/commands/auth.py +80 -0
- cli_web/hackernews/commands/search.py +69 -0
- cli_web/hackernews/commands/stories.py +160 -0
- cli_web/hackernews/commands/user.py +112 -0
- cli_web/hackernews/core/__init__.py +0 -0
- cli_web/hackernews/core/auth.py +290 -0
- cli_web/hackernews/core/client.py +517 -0
- cli_web/hackernews/core/exceptions.py +63 -0
- cli_web/hackernews/core/models.py +144 -0
- cli_web/hackernews/hackernews_cli.py +171 -0
- cli_web/hackernews/tests/TEST.md +143 -0
- cli_web/hackernews/tests/__init__.py +0 -0
- cli_web/hackernews/tests/test_core.py +365 -0
- cli_web/hackernews/tests/test_e2e.py +267 -0
- cli_web/hackernews/utils/__init__.py +0 -0
- cli_web/hackernews/utils/doctor.py +188 -0
- cli_web/hackernews/utils/helpers.py +73 -0
- cli_web/hackernews/utils/mcp_server.py +290 -0
- cli_web/hackernews/utils/output.py +136 -0
- cli_web/hackernews/utils/repl_skin.py +486 -0
- cli_web_hackernews-0.1.0.dist-info/METADATA +12 -0
- cli_web_hackernews-0.1.0.dist-info/RECORD +30 -0
- cli_web_hackernews-0.1.0.dist-info/WHEEL +5 -0
- cli_web_hackernews-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_hackernews-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Unit tests for cli-web-hackernews core modules (mocked HTTP, no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
from cli_web.hackernews.core.client import HackerNewsClient
|
|
10
|
+
from cli_web.hackernews.core.exceptions import (
|
|
11
|
+
AppError,
|
|
12
|
+
AuthError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
)
|
|
18
|
+
from cli_web.hackernews.core.models import Comment, SearchResult, Story, User
|
|
19
|
+
|
|
20
|
+
# ─── Model tests ─────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestStoryModel:
|
|
24
|
+
def test_to_dict_includes_computed(self):
|
|
25
|
+
story = Story(
|
|
26
|
+
id=123,
|
|
27
|
+
title="Test Story",
|
|
28
|
+
url="https://example.com/article",
|
|
29
|
+
score=42,
|
|
30
|
+
by="testuser",
|
|
31
|
+
time=0,
|
|
32
|
+
descendants=5,
|
|
33
|
+
)
|
|
34
|
+
d = story.to_dict()
|
|
35
|
+
assert d["id"] == 123
|
|
36
|
+
assert d["title"] == "Test Story"
|
|
37
|
+
assert d["domain"] == "example.com"
|
|
38
|
+
assert "age" in d
|
|
39
|
+
|
|
40
|
+
def test_domain_extraction(self):
|
|
41
|
+
story = Story(id=1, title="T", url="https://www.example.com/path")
|
|
42
|
+
assert story.domain == "example.com"
|
|
43
|
+
|
|
44
|
+
def test_domain_empty_when_no_url(self):
|
|
45
|
+
story = Story(id=1, title="T")
|
|
46
|
+
assert story.domain == ""
|
|
47
|
+
|
|
48
|
+
def test_age_empty_when_no_time(self):
|
|
49
|
+
story = Story(id=1, title="T")
|
|
50
|
+
assert story.age == ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestCommentModel:
|
|
54
|
+
def test_text_plain_strips_html(self):
|
|
55
|
+
comment = Comment(id=1, text="<p>Hello <b>world</b></p>")
|
|
56
|
+
assert comment.text_plain == "Hello world"
|
|
57
|
+
|
|
58
|
+
def test_text_plain_unescapes_entities(self):
|
|
59
|
+
comment = Comment(id=1, text="& <tag>")
|
|
60
|
+
assert comment.text_plain == "& <tag>"
|
|
61
|
+
|
|
62
|
+
def test_text_plain_empty(self):
|
|
63
|
+
comment = Comment(id=1)
|
|
64
|
+
assert comment.text_plain == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestUserModel:
|
|
68
|
+
def test_to_dict_trims_submitted(self):
|
|
69
|
+
user = User(id="test", submitted=list(range(100)))
|
|
70
|
+
d = user.to_dict()
|
|
71
|
+
assert len(d["submitted"]) == 20
|
|
72
|
+
assert d["total_submissions"] == 100
|
|
73
|
+
|
|
74
|
+
def test_about_plain(self):
|
|
75
|
+
user = User(id="test", about="<p>Hello & welcome</p>")
|
|
76
|
+
assert user.about_plain == "Hello & welcome"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestSearchResultModel:
|
|
80
|
+
def test_to_dict(self):
|
|
81
|
+
result = SearchResult(
|
|
82
|
+
objectID="123",
|
|
83
|
+
title="Test",
|
|
84
|
+
author="user",
|
|
85
|
+
points=42,
|
|
86
|
+
num_comments=10,
|
|
87
|
+
)
|
|
88
|
+
d = result.to_dict()
|
|
89
|
+
assert d["objectID"] == "123"
|
|
90
|
+
assert d["points"] == 42
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ─── Client HTTP error handling tests ───────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestClientHTTPErrors:
|
|
97
|
+
def _mock_response(self, status_code: int, headers: dict | None = None, json_data=None):
|
|
98
|
+
resp = MagicMock()
|
|
99
|
+
resp.status_code = status_code
|
|
100
|
+
resp.headers = headers or {}
|
|
101
|
+
resp.json.return_value = json_data
|
|
102
|
+
return resp
|
|
103
|
+
|
|
104
|
+
def test_rate_limit_raises(self):
|
|
105
|
+
client = HackerNewsClient()
|
|
106
|
+
client._client = MagicMock()
|
|
107
|
+
client._client.get.return_value = self._mock_response(429, headers={"retry-after": "30"})
|
|
108
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
109
|
+
client._get_json("https://hacker-news.firebaseio.com/v0/topstories.json")
|
|
110
|
+
assert exc_info.value.retry_after == 30
|
|
111
|
+
|
|
112
|
+
def test_server_error_raises(self):
|
|
113
|
+
client = HackerNewsClient()
|
|
114
|
+
client._client = MagicMock()
|
|
115
|
+
client._client.get.return_value = self._mock_response(503)
|
|
116
|
+
with pytest.raises(ServerError):
|
|
117
|
+
client._get_json("https://hacker-news.firebaseio.com/v0/topstories.json")
|
|
118
|
+
|
|
119
|
+
def test_404_raises_not_found(self):
|
|
120
|
+
client = HackerNewsClient()
|
|
121
|
+
client._client = MagicMock()
|
|
122
|
+
client._client.get.return_value = self._mock_response(404)
|
|
123
|
+
with pytest.raises(NotFoundError):
|
|
124
|
+
client._get_json("https://hacker-news.firebaseio.com/v0/item/999999999.json")
|
|
125
|
+
|
|
126
|
+
def test_network_error_raises(self):
|
|
127
|
+
client = HackerNewsClient()
|
|
128
|
+
client._client = MagicMock()
|
|
129
|
+
client._client.get.side_effect = httpx.ConnectError("Connection refused")
|
|
130
|
+
with pytest.raises(NetworkError):
|
|
131
|
+
client._get_json("https://hacker-news.firebaseio.com/v0/topstories.json")
|
|
132
|
+
|
|
133
|
+
def test_timeout_raises_network_error(self):
|
|
134
|
+
client = HackerNewsClient()
|
|
135
|
+
client._client = MagicMock()
|
|
136
|
+
client._client.get.side_effect = httpx.TimeoutException("Timed out")
|
|
137
|
+
with pytest.raises(NetworkError):
|
|
138
|
+
client._get_json("https://hacker-news.firebaseio.com/v0/topstories.json")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ─── Client data parsing tests ──────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestClientParsing:
|
|
145
|
+
def test_get_story_builds_model(self):
|
|
146
|
+
mock_data = {
|
|
147
|
+
"id": 123,
|
|
148
|
+
"title": "Test Story",
|
|
149
|
+
"url": "https://example.com",
|
|
150
|
+
"score": 42,
|
|
151
|
+
"by": "user",
|
|
152
|
+
"time": 1700000000,
|
|
153
|
+
"descendants": 5,
|
|
154
|
+
"type": "story",
|
|
155
|
+
}
|
|
156
|
+
with patch.object(HackerNewsClient, "get_item", return_value=mock_data):
|
|
157
|
+
client = HackerNewsClient()
|
|
158
|
+
story = client.get_story(123)
|
|
159
|
+
assert isinstance(story, Story)
|
|
160
|
+
assert story.id == 123
|
|
161
|
+
assert story.title == "Test Story"
|
|
162
|
+
assert story.score == 42
|
|
163
|
+
|
|
164
|
+
def test_get_user_builds_model(self):
|
|
165
|
+
mock_data = {
|
|
166
|
+
"id": "testuser",
|
|
167
|
+
"karma": 1000,
|
|
168
|
+
"created": 1187454947,
|
|
169
|
+
"about": "Hi",
|
|
170
|
+
"submitted": [1, 2, 3],
|
|
171
|
+
}
|
|
172
|
+
with patch.object(HackerNewsClient, "_get_json", return_value=mock_data):
|
|
173
|
+
client = HackerNewsClient()
|
|
174
|
+
user = client.get_user("testuser")
|
|
175
|
+
assert isinstance(user, User)
|
|
176
|
+
assert user.id == "testuser"
|
|
177
|
+
assert user.karma == 1000
|
|
178
|
+
|
|
179
|
+
def test_get_user_not_found(self):
|
|
180
|
+
with patch.object(HackerNewsClient, "_get_json", return_value=None):
|
|
181
|
+
client = HackerNewsClient()
|
|
182
|
+
with pytest.raises(NotFoundError):
|
|
183
|
+
client.get_user("nonexistent")
|
|
184
|
+
|
|
185
|
+
def test_search_builds_results(self):
|
|
186
|
+
mock_data = {
|
|
187
|
+
"hits": [
|
|
188
|
+
{
|
|
189
|
+
"objectID": "123",
|
|
190
|
+
"title": "Result 1",
|
|
191
|
+
"url": "https://example.com",
|
|
192
|
+
"author": "user",
|
|
193
|
+
"points": 42,
|
|
194
|
+
"num_comments": 10,
|
|
195
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
with patch.object(HackerNewsClient, "_get_json", return_value=mock_data):
|
|
200
|
+
client = HackerNewsClient()
|
|
201
|
+
results = client.search("test")
|
|
202
|
+
assert len(results) == 1
|
|
203
|
+
assert results[0].objectID == "123"
|
|
204
|
+
assert results[0].points == 42
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ─── Exception serialization tests ──────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TestExceptionsToDicts:
|
|
211
|
+
def test_app_error_to_dict(self):
|
|
212
|
+
exc = AppError("something broke", "TEST_ERROR")
|
|
213
|
+
d = exc.to_dict()
|
|
214
|
+
assert d["error"] is True
|
|
215
|
+
assert d["code"] == "TEST_ERROR"
|
|
216
|
+
assert "something broke" in d["message"]
|
|
217
|
+
|
|
218
|
+
def test_rate_limit_error_to_dict(self):
|
|
219
|
+
exc = RateLimitError(60)
|
|
220
|
+
d = exc.to_dict()
|
|
221
|
+
assert d["code"] == "RATE_LIMITED"
|
|
222
|
+
assert d["retry_after"] == 60
|
|
223
|
+
|
|
224
|
+
def test_server_error_to_dict(self):
|
|
225
|
+
exc = ServerError(503)
|
|
226
|
+
d = exc.to_dict()
|
|
227
|
+
assert d["code"] == "SERVER_ERROR"
|
|
228
|
+
assert "503" in d["message"]
|
|
229
|
+
|
|
230
|
+
def test_not_found_to_dict(self):
|
|
231
|
+
exc = NotFoundError("Item 123")
|
|
232
|
+
d = exc.to_dict()
|
|
233
|
+
assert d["code"] == "NOT_FOUND"
|
|
234
|
+
assert "Item 123" in d["message"]
|
|
235
|
+
|
|
236
|
+
def test_auth_error_to_dict(self):
|
|
237
|
+
exc = AuthError()
|
|
238
|
+
d = exc.to_dict()
|
|
239
|
+
assert d["error"] is True
|
|
240
|
+
assert d["code"] == "AUTH_EXPIRED"
|
|
241
|
+
assert "login" in d["message"].lower()
|
|
242
|
+
|
|
243
|
+
def test_auth_error_recoverable(self):
|
|
244
|
+
exc = AuthError("token expired", recoverable=True)
|
|
245
|
+
assert exc.recoverable is True
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ─── Auth module tests ───────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TestAuthModule:
|
|
252
|
+
def test_require_auth_raises_without_cookie(self):
|
|
253
|
+
client = HackerNewsClient()
|
|
254
|
+
with pytest.raises(AuthError):
|
|
255
|
+
client._require_auth()
|
|
256
|
+
|
|
257
|
+
def test_require_auth_returns_cookie(self):
|
|
258
|
+
client = HackerNewsClient(user_cookie="test_cookie")
|
|
259
|
+
assert client._require_auth() == "test_cookie"
|
|
260
|
+
|
|
261
|
+
def test_extract_auth_token_from_html(self):
|
|
262
|
+
html = '<a id="up_12345" href="vote?id=12345&how=up&auth=abc123def456"></a>'
|
|
263
|
+
client = HackerNewsClient(user_cookie="test")
|
|
264
|
+
token = client._extract_auth_token(html, 12345)
|
|
265
|
+
assert token == "abc123def456"
|
|
266
|
+
|
|
267
|
+
def test_extract_auth_token_missing_raises(self):
|
|
268
|
+
html = "<html><body>No tokens here</body></html>"
|
|
269
|
+
client = HackerNewsClient(user_cookie="test")
|
|
270
|
+
with pytest.raises(AuthError):
|
|
271
|
+
client._extract_auth_token(html, 99999)
|
|
272
|
+
|
|
273
|
+
def test_parse_stories_from_html_extracts_ids(self):
|
|
274
|
+
html = """
|
|
275
|
+
<tr class="athing submission" id="12345"><td></td></tr>
|
|
276
|
+
<tr class="athing submission" id="67890"><td></td></tr>
|
|
277
|
+
"""
|
|
278
|
+
client = HackerNewsClient(user_cookie="test")
|
|
279
|
+
# Mock _fetch_items_parallel to return stories based on IDs
|
|
280
|
+
with patch.object(
|
|
281
|
+
client,
|
|
282
|
+
"_fetch_items_parallel",
|
|
283
|
+
return_value=[
|
|
284
|
+
Story(id=12345, title="Story 1"),
|
|
285
|
+
Story(id=67890, title="Story 2"),
|
|
286
|
+
],
|
|
287
|
+
):
|
|
288
|
+
stories = client._parse_stories_from_html(html)
|
|
289
|
+
assert len(stories) == 2
|
|
290
|
+
|
|
291
|
+
def test_authenticated_get_html_403_raises_auth_error(self):
|
|
292
|
+
client = HackerNewsClient(user_cookie="expired_cookie")
|
|
293
|
+
resp = MagicMock()
|
|
294
|
+
resp.status_code = 403
|
|
295
|
+
client._web_client = MagicMock()
|
|
296
|
+
client._web_client.request.return_value = resp
|
|
297
|
+
with (
|
|
298
|
+
patch(
|
|
299
|
+
"cli_web.hackernews.core.client.load_auth",
|
|
300
|
+
side_effect=AuthError("not logged in"),
|
|
301
|
+
),
|
|
302
|
+
patch(
|
|
303
|
+
"cli_web.hackernews.core.client.refresh_auth",
|
|
304
|
+
side_effect=AuthError("session expired"),
|
|
305
|
+
),
|
|
306
|
+
):
|
|
307
|
+
with pytest.raises(AuthError):
|
|
308
|
+
client._get_html("https://news.ycombinator.com/item?id=1")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ─── Auth retry tests (3-attempt contract, CONVENTIONS.md §Auth Rules) ───────
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TestAuthRetry:
|
|
315
|
+
"""401/403 → reload auth.json → headless refresh_auth() → AuthError. Never >3."""
|
|
316
|
+
|
|
317
|
+
def _response(self, status_code: int) -> MagicMock:
|
|
318
|
+
resp = MagicMock()
|
|
319
|
+
resp.status_code = status_code
|
|
320
|
+
resp.text = "<html></html>"
|
|
321
|
+
return resp
|
|
322
|
+
|
|
323
|
+
def test_three_attempts_then_auth_error(self):
|
|
324
|
+
client = HackerNewsClient(user_cookie="alice&stale")
|
|
325
|
+
client._web_client = MagicMock()
|
|
326
|
+
client._web_client.request.return_value = self._response(403)
|
|
327
|
+
|
|
328
|
+
with (
|
|
329
|
+
patch(
|
|
330
|
+
"cli_web.hackernews.core.client.load_auth",
|
|
331
|
+
return_value={"user_cookie": "alice&disk", "username": "alice"},
|
|
332
|
+
) as mock_load,
|
|
333
|
+
patch(
|
|
334
|
+
"cli_web.hackernews.core.client.refresh_auth",
|
|
335
|
+
return_value={"user_cookie": "alice&fresh", "username": "alice"},
|
|
336
|
+
) as mock_refresh,
|
|
337
|
+
):
|
|
338
|
+
with pytest.raises(AuthError):
|
|
339
|
+
client._web_request("GET", "https://news.ycombinator.com/submit")
|
|
340
|
+
|
|
341
|
+
assert client._web_client.request.call_count == 3
|
|
342
|
+
assert mock_load.call_count == 1
|
|
343
|
+
assert mock_refresh.call_count == 1
|
|
344
|
+
|
|
345
|
+
def test_success_after_disk_reload_stops_retrying(self):
|
|
346
|
+
client = HackerNewsClient(user_cookie="alice&stale")
|
|
347
|
+
client._web_client = MagicMock()
|
|
348
|
+
client._web_client.request.side_effect = [
|
|
349
|
+
self._response(403),
|
|
350
|
+
self._response(200),
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
with (
|
|
354
|
+
patch(
|
|
355
|
+
"cli_web.hackernews.core.client.load_auth",
|
|
356
|
+
return_value={"user_cookie": "alice&disk", "username": "alice"},
|
|
357
|
+
),
|
|
358
|
+
patch("cli_web.hackernews.core.client.refresh_auth") as mock_refresh,
|
|
359
|
+
):
|
|
360
|
+
response = client._web_request("GET", "https://news.ycombinator.com/submit")
|
|
361
|
+
|
|
362
|
+
assert response.status_code == 200
|
|
363
|
+
assert client._web_client.request.call_count == 2
|
|
364
|
+
mock_refresh.assert_not_called()
|
|
365
|
+
assert client._user_cookie == "alice&disk"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""E2E tests for cli-web-hackernews (live API, no mocks) + subprocess tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from cli_web.hackernews.core import auth
|
|
13
|
+
from cli_web.hackernews.core.client import HackerNewsClient
|
|
14
|
+
from cli_web.hackernews.core.exceptions import NotFoundError
|
|
15
|
+
from cli_web.hackernews.core.models import Comment, SearchResult, Story, User
|
|
16
|
+
|
|
17
|
+
# ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def client():
|
|
22
|
+
return HackerNewsClient()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_cli(name: str) -> str:
|
|
26
|
+
"""Find the CLI binary path for subprocess tests."""
|
|
27
|
+
if os.environ.get("CLI_WEB_FORCE_INSTALLED"):
|
|
28
|
+
path = shutil.which(name)
|
|
29
|
+
if path:
|
|
30
|
+
return path
|
|
31
|
+
raise FileNotFoundError(f"{name} not found in PATH")
|
|
32
|
+
path = shutil.which(name)
|
|
33
|
+
if path:
|
|
34
|
+
return path
|
|
35
|
+
return f"{sys.executable} -m cli_web.hackernews"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ─── E2E: Stories Feed Tests ─────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestStoriesFeedE2E:
|
|
42
|
+
def test_top_stories_returns_list(self, client):
|
|
43
|
+
stories = client.get_stories("top", limit=5)
|
|
44
|
+
assert len(stories) >= 1
|
|
45
|
+
assert all(isinstance(s, Story) for s in stories)
|
|
46
|
+
|
|
47
|
+
def test_new_stories_returns_list(self, client):
|
|
48
|
+
stories = client.get_stories("new", limit=5)
|
|
49
|
+
assert len(stories) >= 1
|
|
50
|
+
|
|
51
|
+
def test_best_stories_returns_list(self, client):
|
|
52
|
+
stories = client.get_stories("best", limit=5)
|
|
53
|
+
assert len(stories) >= 1
|
|
54
|
+
|
|
55
|
+
def test_ask_stories_returns_list(self, client):
|
|
56
|
+
stories = client.get_stories("ask", limit=5)
|
|
57
|
+
assert len(stories) >= 1
|
|
58
|
+
|
|
59
|
+
def test_show_stories_returns_list(self, client):
|
|
60
|
+
stories = client.get_stories("show", limit=5)
|
|
61
|
+
assert len(stories) >= 1
|
|
62
|
+
|
|
63
|
+
def test_job_stories_returns_list(self, client):
|
|
64
|
+
stories = client.get_stories("job", limit=5)
|
|
65
|
+
assert len(stories) >= 1
|
|
66
|
+
|
|
67
|
+
def test_story_has_required_fields(self, client):
|
|
68
|
+
stories = client.get_stories("top", limit=1)
|
|
69
|
+
story = stories[0]
|
|
70
|
+
assert story.id > 0
|
|
71
|
+
assert story.title
|
|
72
|
+
assert story.by
|
|
73
|
+
assert story.score >= 0
|
|
74
|
+
|
|
75
|
+
def test_story_ids_returns_ints(self, client):
|
|
76
|
+
ids = client.get_story_ids("top")
|
|
77
|
+
assert len(ids) > 0
|
|
78
|
+
assert all(isinstance(i, int) for i in ids[:10])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ─── E2E: Story View + Comments ──────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestStoryViewE2E:
|
|
85
|
+
def test_get_story_by_id(self, client):
|
|
86
|
+
# Get a real story ID from top stories
|
|
87
|
+
ids = client.get_story_ids("top")
|
|
88
|
+
story = client.get_story(ids[0])
|
|
89
|
+
assert isinstance(story, Story)
|
|
90
|
+
assert story.id == ids[0]
|
|
91
|
+
assert story.title
|
|
92
|
+
|
|
93
|
+
def test_get_comments_for_story(self, client):
|
|
94
|
+
# Find a story with comments
|
|
95
|
+
stories = client.get_stories("top", limit=5)
|
|
96
|
+
story_with_comments = None
|
|
97
|
+
for s in stories:
|
|
98
|
+
if s.descendants > 0:
|
|
99
|
+
story_with_comments = s
|
|
100
|
+
break
|
|
101
|
+
if story_with_comments is None:
|
|
102
|
+
pytest.skip("No stories with comments found")
|
|
103
|
+
comments = client.get_comments(story_with_comments.id, limit=3)
|
|
104
|
+
assert len(comments) >= 1
|
|
105
|
+
assert all(isinstance(c, Comment) for c in comments)
|
|
106
|
+
assert comments[0].by # Has author
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── E2E: User Profile ──────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestUserE2E:
|
|
113
|
+
def test_get_user_profile(self, client):
|
|
114
|
+
user = client.get_user("dang")
|
|
115
|
+
assert isinstance(user, User)
|
|
116
|
+
assert user.id == "dang"
|
|
117
|
+
assert user.karma > 0
|
|
118
|
+
assert user.created > 0
|
|
119
|
+
|
|
120
|
+
def test_get_nonexistent_user_raises(self, client):
|
|
121
|
+
with pytest.raises(NotFoundError):
|
|
122
|
+
client.get_user("thisisanonexistentuser999999")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ─── E2E: Search ────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestSearchE2E:
|
|
129
|
+
def test_search_stories(self, client):
|
|
130
|
+
results = client.search("python", tags="story", hits_per_page=5)
|
|
131
|
+
assert len(results) >= 1
|
|
132
|
+
assert all(isinstance(r, SearchResult) for r in results)
|
|
133
|
+
assert results[0].title
|
|
134
|
+
|
|
135
|
+
def test_search_by_date(self, client):
|
|
136
|
+
results = client.search("javascript", sort_by_date=True, hits_per_page=3)
|
|
137
|
+
assert len(results) >= 1
|
|
138
|
+
|
|
139
|
+
def test_search_comments(self, client):
|
|
140
|
+
results = client.search("react", tags="comment", hits_per_page=3)
|
|
141
|
+
assert len(results) >= 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ─── Subprocess Tests ───────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestSubprocess:
|
|
148
|
+
def _run(self, args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
|
149
|
+
cli_path = _resolve_cli("cli-web-hackernews")
|
|
150
|
+
cmd = f"{cli_path} {args}"
|
|
151
|
+
return subprocess.run(
|
|
152
|
+
cmd,
|
|
153
|
+
shell=True,
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
timeout=timeout,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def test_version(self):
|
|
160
|
+
result = self._run("--version")
|
|
161
|
+
assert result.returncode == 0
|
|
162
|
+
assert "0.2.0" in result.stdout
|
|
163
|
+
|
|
164
|
+
def test_help(self):
|
|
165
|
+
result = self._run("--help")
|
|
166
|
+
assert result.returncode == 0
|
|
167
|
+
assert "hackernews" in result.stdout.lower()
|
|
168
|
+
|
|
169
|
+
def test_stories_top_json(self):
|
|
170
|
+
result = self._run("stories top -n 3 --json")
|
|
171
|
+
assert result.returncode == 0
|
|
172
|
+
data = json.loads(result.stdout)
|
|
173
|
+
assert isinstance(data, list)
|
|
174
|
+
assert len(data) >= 1
|
|
175
|
+
assert "title" in data[0]
|
|
176
|
+
assert "score" in data[0]
|
|
177
|
+
|
|
178
|
+
def test_search_stories_json(self):
|
|
179
|
+
result = self._run('search stories "python" -n 3 --json')
|
|
180
|
+
assert result.returncode == 0
|
|
181
|
+
data = json.loads(result.stdout)
|
|
182
|
+
assert isinstance(data, list)
|
|
183
|
+
assert len(data) >= 1
|
|
184
|
+
|
|
185
|
+
def test_user_view_json(self):
|
|
186
|
+
result = self._run("user view dang --json")
|
|
187
|
+
assert result.returncode == 0
|
|
188
|
+
data = json.loads(result.stdout)
|
|
189
|
+
assert data["id"] == "dang"
|
|
190
|
+
assert data["karma"] > 0
|
|
191
|
+
|
|
192
|
+
def test_stories_view_json(self):
|
|
193
|
+
# Get a story ID first
|
|
194
|
+
result = self._run("stories top -n 1 --json")
|
|
195
|
+
assert result.returncode == 0
|
|
196
|
+
stories = json.loads(result.stdout)
|
|
197
|
+
story_id = stories[0]["id"]
|
|
198
|
+
|
|
199
|
+
result = self._run(f"stories view {story_id} --no-comments --json")
|
|
200
|
+
assert result.returncode == 0
|
|
201
|
+
data = json.loads(result.stdout)
|
|
202
|
+
assert data["id"] == story_id
|
|
203
|
+
|
|
204
|
+
def test_auth_status_json(self):
|
|
205
|
+
result = self._run("auth status --json")
|
|
206
|
+
assert result.returncode == 0
|
|
207
|
+
data = json.loads(result.stdout)
|
|
208
|
+
assert "logged_in" in data
|
|
209
|
+
|
|
210
|
+
def test_auth_help(self):
|
|
211
|
+
result = self._run("auth --help")
|
|
212
|
+
assert result.returncode == 0
|
|
213
|
+
assert "login" in result.stdout.lower()
|
|
214
|
+
|
|
215
|
+
def test_upvote_help(self):
|
|
216
|
+
result = self._run("upvote --help")
|
|
217
|
+
assert result.returncode == 0
|
|
218
|
+
assert "item_id" in result.stdout.lower()
|
|
219
|
+
|
|
220
|
+
def test_submit_help(self):
|
|
221
|
+
result = self._run("submit --help")
|
|
222
|
+
assert result.returncode == 0
|
|
223
|
+
assert "title" in result.stdout.lower()
|
|
224
|
+
|
|
225
|
+
def test_comment_help(self):
|
|
226
|
+
result = self._run("comment --help")
|
|
227
|
+
assert result.returncode == 0
|
|
228
|
+
assert "parent_id" in result.stdout.lower()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ─── E2E: Auth-Enabled Tests (require auth cookie) ─────────────────────────
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.fixture
|
|
235
|
+
def auth_client():
|
|
236
|
+
"""Create an authenticated client. Fails if not logged in."""
|
|
237
|
+
cookie = auth.get_user_cookie()
|
|
238
|
+
return HackerNewsClient(user_cookie=cookie)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TestAuthActionsE2E:
|
|
242
|
+
def test_upvote_story(self, auth_client):
|
|
243
|
+
"""Test upvoting a top story."""
|
|
244
|
+
# Get a real story to upvote
|
|
245
|
+
ids = auth_client.get_story_ids("top")
|
|
246
|
+
result = auth_client.upvote(ids[0])
|
|
247
|
+
assert result["success"] is True
|
|
248
|
+
assert result["action"] == "upvoted"
|
|
249
|
+
|
|
250
|
+
def test_get_submissions(self, auth_client):
|
|
251
|
+
"""Test fetching dang's submissions (well-known user)."""
|
|
252
|
+
stories = auth_client.get_submissions("dang", limit=3)
|
|
253
|
+
assert len(stories) >= 1
|
|
254
|
+
assert all(isinstance(s, Story) for s in stories)
|
|
255
|
+
|
|
256
|
+
def test_favorite_and_list(self, auth_client):
|
|
257
|
+
"""Test favoriting a story."""
|
|
258
|
+
ids = auth_client.get_story_ids("top")
|
|
259
|
+
result = auth_client.favorite(ids[0])
|
|
260
|
+
assert result["success"] is True
|
|
261
|
+
assert result["action"] == "favorited"
|
|
262
|
+
|
|
263
|
+
def test_auth_validate(self):
|
|
264
|
+
"""Test auth validation works."""
|
|
265
|
+
result = auth.validate_auth()
|
|
266
|
+
assert result["valid"] is True
|
|
267
|
+
assert result["username"]
|
|
File without changes
|