cli-web-codewiki 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/codewiki/__init__.py +3 -0
- cli_web/codewiki/__main__.py +6 -0
- cli_web/codewiki/codewiki_cli.py +142 -0
- cli_web/codewiki/commands/__init__.py +0 -0
- cli_web/codewiki/commands/chat.py +46 -0
- cli_web/codewiki/commands/repos.py +90 -0
- cli_web/codewiki/commands/wiki.py +267 -0
- cli_web/codewiki/core/__init__.py +0 -0
- cli_web/codewiki/core/client.py +224 -0
- cli_web/codewiki/core/exceptions.py +74 -0
- cli_web/codewiki/core/models.py +91 -0
- cli_web/codewiki/core/rpc/__init__.py +0 -0
- cli_web/codewiki/core/rpc/decoder.py +86 -0
- cli_web/codewiki/core/rpc/encoder.py +32 -0
- cli_web/codewiki/core/rpc/types.py +27 -0
- cli_web/codewiki/tests/__init__.py +0 -0
- cli_web/codewiki/tests/test_core.py +725 -0
- cli_web/codewiki/tests/test_e2e.py +411 -0
- cli_web/codewiki/utils/__init__.py +0 -0
- cli_web/codewiki/utils/config.py +14 -0
- cli_web/codewiki/utils/doctor.py +188 -0
- cli_web/codewiki/utils/helpers.py +67 -0
- cli_web/codewiki/utils/mcp_server.py +290 -0
- cli_web/codewiki/utils/output.py +11 -0
- cli_web/codewiki/utils/repl_skin.py +486 -0
- cli_web_codewiki-0.1.0.dist-info/METADATA +14 -0
- cli_web_codewiki-0.1.0.dist-info/RECORD +30 -0
- cli_web_codewiki-0.1.0.dist-info/WHEEL +5 -0
- cli_web_codewiki-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_codewiki-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
"""Unit tests for cli-web-codewiki — no network calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Helpers: build realistic batchexecute wire responses from Python objects
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_batchexecute(rpc_id: str, inner_obj) -> str:
|
|
16
|
+
"""Encode inner_obj as the double-JSON-encoded batchexecute wire format.
|
|
17
|
+
|
|
18
|
+
The wire format is:
|
|
19
|
+
)]}'
|
|
20
|
+
<length hint>
|
|
21
|
+
[["wrb.fr", rpc_id, "<json-encoded inner>", null, null, null, "generic"]]
|
|
22
|
+
|
|
23
|
+
The decoder iterates chunks → entries and checks entry[0] == "wrb.fr".
|
|
24
|
+
"""
|
|
25
|
+
inner_json_str = json.dumps(inner_obj) # first serialisation (double-encoded)
|
|
26
|
+
entry = ["wrb.fr", rpc_id, inner_json_str, None, None, None, "generic"]
|
|
27
|
+
# One chunk array containing the single entry directly
|
|
28
|
+
outer = json.dumps([entry])
|
|
29
|
+
return ")]}'\n\n100\n" + outer
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Featured repos: [[[slug, null, null, [null, github_url], null, [desc, avatar, stars]]]]
|
|
33
|
+
FEATURED_RAW = _make_batchexecute(
|
|
34
|
+
"nm8Fsb",
|
|
35
|
+
[
|
|
36
|
+
[
|
|
37
|
+
[
|
|
38
|
+
"test-org/test-repo",
|
|
39
|
+
None,
|
|
40
|
+
None,
|
|
41
|
+
[None, "https://github.com/test-org/test-repo"],
|
|
42
|
+
None,
|
|
43
|
+
["A test repository", "https://avatars.githubusercontent.com/u/123?v=4", 42000],
|
|
44
|
+
]
|
|
45
|
+
]
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Search repos: [[[slug, null, rank, [null, github_url], [ts_sec, ts_ns], [desc, avatar, stars, slug]]]]
|
|
50
|
+
SEARCH_RAW = _make_batchexecute(
|
|
51
|
+
"vyWDAf",
|
|
52
|
+
[
|
|
53
|
+
[
|
|
54
|
+
[
|
|
55
|
+
"found-org/found-repo",
|
|
56
|
+
None,
|
|
57
|
+
3,
|
|
58
|
+
[None, "https://github.com/found-org/found-repo"],
|
|
59
|
+
[1773125120, 745316000],
|
|
60
|
+
[
|
|
61
|
+
"Found repo description",
|
|
62
|
+
"https://avatars.githubusercontent.com/u/456?v=4",
|
|
63
|
+
5000,
|
|
64
|
+
"found-org/found-repo",
|
|
65
|
+
],
|
|
66
|
+
]
|
|
67
|
+
]
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Wiki page: [[[slug, commit], [[title, level, desc, null, content], ...], null, null, [ts_sec, ts_ns]], [[null, url], has_wiki, n]]
|
|
72
|
+
WIKI_RAW = _make_batchexecute(
|
|
73
|
+
"VSX6ub",
|
|
74
|
+
[
|
|
75
|
+
[
|
|
76
|
+
["test-org/test-repo", "abc123"],
|
|
77
|
+
[
|
|
78
|
+
[
|
|
79
|
+
"/test-org/test-repo Overview",
|
|
80
|
+
1,
|
|
81
|
+
"Overview of test repo",
|
|
82
|
+
None,
|
|
83
|
+
"This is the overview content.",
|
|
84
|
+
],
|
|
85
|
+
["/Section A", 2, "Section A description", None, "Section A content."],
|
|
86
|
+
],
|
|
87
|
+
None,
|
|
88
|
+
None,
|
|
89
|
+
[1773125120, 745316000],
|
|
90
|
+
],
|
|
91
|
+
[[None, "https://github.com/test-org/test-repo"], True, 3],
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Chat response: ["answer text"]
|
|
96
|
+
CHAT_RAW = _make_batchexecute("EgIxfe", ["The answer is that this repo uses React for rendering."])
|
|
97
|
+
|
|
98
|
+
# Error entry (er tag) — entry[0] == "er" triggers RPCError in the decoder
|
|
99
|
+
ERROR_RAW = ")]}'\n\n100\n" + json.dumps(
|
|
100
|
+
[["er", {"code": 403, "message": "Forbidden"}, None, None, None, "generic"]]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Empty/null result
|
|
104
|
+
EMPTY_RAW = _make_batchexecute("nm8Fsb", None)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ===========================================================================
|
|
108
|
+
# 1. RPC Encoder tests
|
|
109
|
+
# ===========================================================================
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestRPCEncoder:
|
|
113
|
+
def test_build_url_contains_rpcid(self):
|
|
114
|
+
from cli_web.codewiki.core.rpc.encoder import build_url
|
|
115
|
+
|
|
116
|
+
url = build_url("nm8Fsb")
|
|
117
|
+
assert "rpcids=nm8Fsb" in url
|
|
118
|
+
|
|
119
|
+
def test_build_url_contains_base(self):
|
|
120
|
+
from cli_web.codewiki.core.rpc.encoder import build_url
|
|
121
|
+
from cli_web.codewiki.core.rpc.types import BATCHEXECUTE_URL
|
|
122
|
+
|
|
123
|
+
url = build_url("nm8Fsb")
|
|
124
|
+
assert url.startswith(BATCHEXECUTE_URL)
|
|
125
|
+
|
|
126
|
+
def test_encode_request_featured_empty_params(self):
|
|
127
|
+
from cli_web.codewiki.core.rpc.encoder import encode_request
|
|
128
|
+
|
|
129
|
+
body = encode_request("nm8Fsb", [])
|
|
130
|
+
# Must be form-encoded and contain f.req
|
|
131
|
+
assert "f.req=" in body
|
|
132
|
+
# Decode the f.req value and check structure
|
|
133
|
+
from urllib.parse import parse_qs
|
|
134
|
+
|
|
135
|
+
parsed = parse_qs(body)
|
|
136
|
+
freq = json.loads(parsed["f.req"][0])
|
|
137
|
+
# [[["nm8Fsb", "[]", null, "generic"]]]
|
|
138
|
+
assert freq[0][0][0] == "nm8Fsb"
|
|
139
|
+
assert freq[0][0][3] == "generic"
|
|
140
|
+
inner_params = json.loads(freq[0][0][1])
|
|
141
|
+
assert inner_params == []
|
|
142
|
+
|
|
143
|
+
def test_encode_request_search_with_query_params(self):
|
|
144
|
+
from urllib.parse import parse_qs
|
|
145
|
+
|
|
146
|
+
from cli_web.codewiki.core.rpc.encoder import encode_request
|
|
147
|
+
|
|
148
|
+
body = encode_request("vyWDAf", ["react", 25, "react", 0])
|
|
149
|
+
parsed = parse_qs(body)
|
|
150
|
+
freq = json.loads(parsed["f.req"][0])
|
|
151
|
+
assert freq[0][0][0] == "vyWDAf"
|
|
152
|
+
inner_params = json.loads(freq[0][0][1])
|
|
153
|
+
assert inner_params == ["react", 25, "react", 0]
|
|
154
|
+
|
|
155
|
+
def test_encode_request_produces_form_encoded(self):
|
|
156
|
+
from cli_web.codewiki.core.rpc.encoder import encode_request
|
|
157
|
+
|
|
158
|
+
body = encode_request("EgIxfe", [["question"]])
|
|
159
|
+
# Form-encoded: no raw brackets allowed
|
|
160
|
+
assert "f.req=" in body
|
|
161
|
+
assert "&" not in body.split("f.req=")[0] # no leading param
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ===========================================================================
|
|
165
|
+
# 2. RPC Decoder tests
|
|
166
|
+
# ===========================================================================
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestRPCDecoder:
|
|
170
|
+
def test_strip_prefix_removes_xssi(self):
|
|
171
|
+
from cli_web.codewiki.core.rpc.decoder import _strip_prefix
|
|
172
|
+
|
|
173
|
+
raw = ")]}'\nhello"
|
|
174
|
+
result = _strip_prefix(raw)
|
|
175
|
+
assert result == "hello"
|
|
176
|
+
|
|
177
|
+
def test_strip_prefix_handles_bytes(self):
|
|
178
|
+
from cli_web.codewiki.core.rpc.decoder import _strip_prefix
|
|
179
|
+
|
|
180
|
+
raw = b")]}'\ndata"
|
|
181
|
+
result = _strip_prefix(raw)
|
|
182
|
+
assert result == "data"
|
|
183
|
+
|
|
184
|
+
def test_strip_prefix_no_prefix_unchanged(self):
|
|
185
|
+
from cli_web.codewiki.core.rpc.decoder import _strip_prefix
|
|
186
|
+
|
|
187
|
+
raw = "plain text"
|
|
188
|
+
assert _strip_prefix(raw) == "plain text"
|
|
189
|
+
|
|
190
|
+
def test_decode_featured_response(self):
|
|
191
|
+
from cli_web.codewiki.core.rpc.decoder import decode_response
|
|
192
|
+
|
|
193
|
+
result = decode_response(FEATURED_RAW, "nm8Fsb")
|
|
194
|
+
assert result is not None
|
|
195
|
+
# Should be a list with one inner list of repos
|
|
196
|
+
assert isinstance(result, list)
|
|
197
|
+
repos = result[0]
|
|
198
|
+
assert isinstance(repos, list)
|
|
199
|
+
assert len(repos) == 1
|
|
200
|
+
assert repos[0][0] == "test-org/test-repo"
|
|
201
|
+
|
|
202
|
+
def test_decode_returns_none_for_empty(self):
|
|
203
|
+
from cli_web.codewiki.core.rpc.decoder import decode_response
|
|
204
|
+
|
|
205
|
+
result = decode_response(EMPTY_RAW, "nm8Fsb")
|
|
206
|
+
assert result is None
|
|
207
|
+
|
|
208
|
+
def test_decode_rpc_error_raises(self):
|
|
209
|
+
from cli_web.codewiki.core.exceptions import RPCError
|
|
210
|
+
from cli_web.codewiki.core.rpc.decoder import decode_response
|
|
211
|
+
|
|
212
|
+
with pytest.raises(RPCError):
|
|
213
|
+
decode_response(ERROR_RAW, "nm8Fsb")
|
|
214
|
+
|
|
215
|
+
def test_decode_search_response(self):
|
|
216
|
+
from cli_web.codewiki.core.rpc.decoder import decode_response
|
|
217
|
+
|
|
218
|
+
result = decode_response(SEARCH_RAW, "vyWDAf")
|
|
219
|
+
assert result is not None
|
|
220
|
+
repos = result[0]
|
|
221
|
+
assert repos[0][0] == "found-org/found-repo"
|
|
222
|
+
|
|
223
|
+
def test_decode_chat_response(self):
|
|
224
|
+
from cli_web.codewiki.core.rpc.decoder import decode_response
|
|
225
|
+
|
|
226
|
+
result = decode_response(CHAT_RAW, "EgIxfe")
|
|
227
|
+
assert isinstance(result, list)
|
|
228
|
+
assert result[0] == "The answer is that this repo uses React for rendering."
|
|
229
|
+
|
|
230
|
+
def test_decode_wrong_rpc_id_returns_none(self):
|
|
231
|
+
from cli_web.codewiki.core.rpc.decoder import decode_response
|
|
232
|
+
|
|
233
|
+
# Asking for a different RPC ID than what's in the response
|
|
234
|
+
result = decode_response(FEATURED_RAW, "xxxxxx")
|
|
235
|
+
assert result is None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ===========================================================================
|
|
239
|
+
# 3. Client tests (mocked httpx)
|
|
240
|
+
# ===========================================================================
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _make_mock_response(body: str, status_code: int = 200, headers: dict | None = None):
|
|
244
|
+
"""Build a MagicMock that mimics httpx.Response."""
|
|
245
|
+
resp = MagicMock()
|
|
246
|
+
resp.status_code = status_code
|
|
247
|
+
resp.content = body.encode("utf-8")
|
|
248
|
+
resp.text = body
|
|
249
|
+
resp.headers = headers or {}
|
|
250
|
+
return resp
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TestCodeWikiClientFeaturedRepos:
|
|
254
|
+
def test_featured_repos_parses_correctly(self):
|
|
255
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
256
|
+
|
|
257
|
+
client = CodeWikiClient()
|
|
258
|
+
with patch.object(client._http, "post", return_value=_make_mock_response(FEATURED_RAW)):
|
|
259
|
+
repos = client.featured_repos()
|
|
260
|
+
assert len(repos) == 1
|
|
261
|
+
repo = repos[0]
|
|
262
|
+
assert repo.slug == "test-org/test-repo"
|
|
263
|
+
assert repo.github_url == "https://github.com/test-org/test-repo"
|
|
264
|
+
assert repo.description == "A test repository"
|
|
265
|
+
assert repo.stars == 42000
|
|
266
|
+
assert repo.org == "test-org"
|
|
267
|
+
assert repo.name == "test-repo"
|
|
268
|
+
client.close()
|
|
269
|
+
|
|
270
|
+
def test_featured_repos_empty_returns_list(self):
|
|
271
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
272
|
+
|
|
273
|
+
client = CodeWikiClient()
|
|
274
|
+
with patch.object(client._http, "post", return_value=_make_mock_response(EMPTY_RAW)):
|
|
275
|
+
repos = client.featured_repos()
|
|
276
|
+
assert repos == []
|
|
277
|
+
client.close()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class TestCodeWikiClientSearchRepos:
|
|
281
|
+
def test_search_repos_parses_correctly(self):
|
|
282
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
283
|
+
|
|
284
|
+
client = CodeWikiClient()
|
|
285
|
+
with patch.object(client._http, "post", return_value=_make_mock_response(SEARCH_RAW)):
|
|
286
|
+
repos = client.search_repos("found")
|
|
287
|
+
assert len(repos) == 1
|
|
288
|
+
repo = repos[0]
|
|
289
|
+
assert repo.slug == "found-org/found-repo"
|
|
290
|
+
assert repo.github_url == "https://github.com/found-org/found-repo"
|
|
291
|
+
assert repo.description == "Found repo description"
|
|
292
|
+
assert repo.stars == 5000
|
|
293
|
+
assert repo.updated_at is not None
|
|
294
|
+
client.close()
|
|
295
|
+
|
|
296
|
+
def test_search_repos_passes_query_to_rpc(self):
|
|
297
|
+
from urllib.parse import parse_qs
|
|
298
|
+
|
|
299
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
300
|
+
|
|
301
|
+
client = CodeWikiClient()
|
|
302
|
+
mock_post = MagicMock(return_value=_make_mock_response(SEARCH_RAW))
|
|
303
|
+
with patch.object(client._http, "post", mock_post):
|
|
304
|
+
client.search_repos("react", limit=10, offset=5)
|
|
305
|
+
call_kwargs = mock_post.call_args
|
|
306
|
+
body_bytes = (
|
|
307
|
+
call_kwargs[1]["content"]
|
|
308
|
+
if "content" in call_kwargs[1]
|
|
309
|
+
else call_kwargs.kwargs["content"]
|
|
310
|
+
)
|
|
311
|
+
body = body_bytes.decode("utf-8")
|
|
312
|
+
parsed = parse_qs(body)
|
|
313
|
+
freq = json.loads(parsed["f.req"][0])
|
|
314
|
+
inner_params = json.loads(freq[0][0][1])
|
|
315
|
+
assert inner_params[0] == "react"
|
|
316
|
+
assert inner_params[1] == 10
|
|
317
|
+
assert inner_params[3] == 5
|
|
318
|
+
client.close()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class TestCodeWikiClientWikiPage:
|
|
322
|
+
def test_wiki_page_parses_sections(self):
|
|
323
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
324
|
+
|
|
325
|
+
client = CodeWikiClient()
|
|
326
|
+
with patch.object(client._http, "post", return_value=_make_mock_response(WIKI_RAW)):
|
|
327
|
+
page = client.get_wiki("test-org/test-repo")
|
|
328
|
+
assert page.repo.slug == "test-org/test-repo"
|
|
329
|
+
assert page.repo.commit_hash == "abc123"
|
|
330
|
+
assert len(page.sections) == 2
|
|
331
|
+
# First section: level 1, title contains "Overview"
|
|
332
|
+
s0 = page.sections[0]
|
|
333
|
+
assert s0.level == 1
|
|
334
|
+
assert "Overview" in s0.title
|
|
335
|
+
assert s0.content == "This is the overview content."
|
|
336
|
+
# Second section: level 2
|
|
337
|
+
s1 = page.sections[1]
|
|
338
|
+
assert s1.level == 2
|
|
339
|
+
assert "Section A" in s1.title
|
|
340
|
+
assert s1.content == "Section A content."
|
|
341
|
+
assert page.has_wiki is True
|
|
342
|
+
client.close()
|
|
343
|
+
|
|
344
|
+
def test_wiki_page_not_found_raises(self):
|
|
345
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
346
|
+
from cli_web.codewiki.core.exceptions import NotFoundError
|
|
347
|
+
|
|
348
|
+
client = CodeWikiClient()
|
|
349
|
+
with patch.object(client._http, "post", return_value=_make_mock_response(EMPTY_RAW)):
|
|
350
|
+
with pytest.raises(NotFoundError):
|
|
351
|
+
client.get_wiki("missing/repo")
|
|
352
|
+
client.close()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class TestCodeWikiClientChat:
|
|
356
|
+
def test_chat_returns_answer(self):
|
|
357
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
358
|
+
|
|
359
|
+
client = CodeWikiClient()
|
|
360
|
+
with patch.object(client._http, "post", return_value=_make_mock_response(CHAT_RAW)):
|
|
361
|
+
resp = client.chat("What does this repo do?", "test-org/test-repo")
|
|
362
|
+
assert isinstance(resp.answer, str)
|
|
363
|
+
assert "React" in resp.answer
|
|
364
|
+
assert resp.repo_slug == "test-org/test-repo"
|
|
365
|
+
client.close()
|
|
366
|
+
|
|
367
|
+
def test_chat_with_history(self):
|
|
368
|
+
from urllib.parse import parse_qs
|
|
369
|
+
|
|
370
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
371
|
+
|
|
372
|
+
client = CodeWikiClient()
|
|
373
|
+
mock_post = MagicMock(return_value=_make_mock_response(CHAT_RAW))
|
|
374
|
+
history = [("previous question", "user"), ("previous answer", "model")]
|
|
375
|
+
with patch.object(client._http, "post", mock_post):
|
|
376
|
+
client.chat("follow-up", "test-org/test-repo", history=history)
|
|
377
|
+
call_kwargs = mock_post.call_args
|
|
378
|
+
body_bytes = (
|
|
379
|
+
call_kwargs[1]["content"]
|
|
380
|
+
if "content" in call_kwargs[1]
|
|
381
|
+
else call_kwargs.kwargs["content"]
|
|
382
|
+
)
|
|
383
|
+
body = body_bytes.decode("utf-8")
|
|
384
|
+
parsed = parse_qs(body)
|
|
385
|
+
freq = json.loads(parsed["f.req"][0])
|
|
386
|
+
inner_params = json.loads(freq[0][0][1])
|
|
387
|
+
# messages list: history + current question
|
|
388
|
+
messages = inner_params[0]
|
|
389
|
+
assert len(messages) == 3 # 2 history + 1 current
|
|
390
|
+
assert messages[-1] == ["follow-up", "user"]
|
|
391
|
+
client.close()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ===========================================================================
|
|
395
|
+
# 4. HTTP error → exception mapping
|
|
396
|
+
# ===========================================================================
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class TestClientHTTPErrors:
|
|
400
|
+
def test_client_404_raises_not_found(self):
|
|
401
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
402
|
+
from cli_web.codewiki.core.exceptions import NotFoundError
|
|
403
|
+
|
|
404
|
+
client = CodeWikiClient()
|
|
405
|
+
with patch.object(
|
|
406
|
+
client._http, "post", return_value=_make_mock_response("", status_code=404)
|
|
407
|
+
):
|
|
408
|
+
with pytest.raises(NotFoundError):
|
|
409
|
+
client.featured_repos()
|
|
410
|
+
client.close()
|
|
411
|
+
|
|
412
|
+
def test_client_429_raises_rate_limit(self):
|
|
413
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
414
|
+
from cli_web.codewiki.core.exceptions import RateLimitError
|
|
415
|
+
|
|
416
|
+
client = CodeWikiClient()
|
|
417
|
+
resp = _make_mock_response("", status_code=429, headers={"Retry-After": "30"})
|
|
418
|
+
with patch.object(client._http, "post", return_value=resp):
|
|
419
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
420
|
+
client.featured_repos()
|
|
421
|
+
assert exc_info.value.retry_after == 30.0
|
|
422
|
+
client.close()
|
|
423
|
+
|
|
424
|
+
def test_client_429_no_retry_after_header(self):
|
|
425
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
426
|
+
from cli_web.codewiki.core.exceptions import RateLimitError
|
|
427
|
+
|
|
428
|
+
client = CodeWikiClient()
|
|
429
|
+
with patch.object(
|
|
430
|
+
client._http, "post", return_value=_make_mock_response("", status_code=429)
|
|
431
|
+
):
|
|
432
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
433
|
+
client.featured_repos()
|
|
434
|
+
assert exc_info.value.retry_after is None
|
|
435
|
+
client.close()
|
|
436
|
+
|
|
437
|
+
def test_client_500_raises_server_error(self):
|
|
438
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
439
|
+
from cli_web.codewiki.core.exceptions import ServerError
|
|
440
|
+
|
|
441
|
+
client = CodeWikiClient()
|
|
442
|
+
with patch.object(
|
|
443
|
+
client._http, "post", return_value=_make_mock_response("oops", status_code=500)
|
|
444
|
+
):
|
|
445
|
+
with pytest.raises(ServerError) as exc_info:
|
|
446
|
+
client.featured_repos()
|
|
447
|
+
assert exc_info.value.status_code == 500
|
|
448
|
+
client.close()
|
|
449
|
+
|
|
450
|
+
def test_client_503_raises_server_error(self):
|
|
451
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
452
|
+
from cli_web.codewiki.core.exceptions import ServerError
|
|
453
|
+
|
|
454
|
+
client = CodeWikiClient()
|
|
455
|
+
with patch.object(
|
|
456
|
+
client._http, "post", return_value=_make_mock_response("", status_code=503)
|
|
457
|
+
):
|
|
458
|
+
with pytest.raises(ServerError) as exc_info:
|
|
459
|
+
client.search_repos("anything")
|
|
460
|
+
assert exc_info.value.status_code == 503
|
|
461
|
+
client.close()
|
|
462
|
+
|
|
463
|
+
def test_client_network_error_connect(self):
|
|
464
|
+
import httpx
|
|
465
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
466
|
+
from cli_web.codewiki.core.exceptions import NetworkError
|
|
467
|
+
|
|
468
|
+
client = CodeWikiClient()
|
|
469
|
+
with patch.object(client._http, "post", side_effect=httpx.ConnectError("refused")):
|
|
470
|
+
with pytest.raises(NetworkError):
|
|
471
|
+
client.featured_repos()
|
|
472
|
+
client.close()
|
|
473
|
+
|
|
474
|
+
def test_client_network_error_timeout(self):
|
|
475
|
+
import httpx
|
|
476
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
477
|
+
from cli_web.codewiki.core.exceptions import NetworkError
|
|
478
|
+
|
|
479
|
+
client = CodeWikiClient()
|
|
480
|
+
with patch.object(client._http, "post", side_effect=httpx.TimeoutException("timed out")):
|
|
481
|
+
with pytest.raises(NetworkError):
|
|
482
|
+
client.featured_repos()
|
|
483
|
+
client.close()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ===========================================================================
|
|
487
|
+
# 5. Exception hierarchy tests
|
|
488
|
+
# ===========================================================================
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class TestExceptionHierarchy:
|
|
492
|
+
def test_all_errors_are_codewiki_errors(self):
|
|
493
|
+
from cli_web.codewiki.core.exceptions import (
|
|
494
|
+
AuthError,
|
|
495
|
+
CodeWikiError,
|
|
496
|
+
NetworkError,
|
|
497
|
+
NotFoundError,
|
|
498
|
+
RateLimitError,
|
|
499
|
+
RPCError,
|
|
500
|
+
ServerError,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
for cls in [AuthError, RateLimitError, NetworkError, ServerError, NotFoundError, RPCError]:
|
|
504
|
+
exc = cls("test message") if cls not in (ServerError,) else cls("test", 500)
|
|
505
|
+
assert isinstance(exc, CodeWikiError)
|
|
506
|
+
|
|
507
|
+
def test_error_code_mapping_auth(self):
|
|
508
|
+
from cli_web.codewiki.core.exceptions import AuthError, error_code_for
|
|
509
|
+
|
|
510
|
+
assert error_code_for(AuthError("expired")) == "AUTH_EXPIRED"
|
|
511
|
+
|
|
512
|
+
def test_error_code_mapping_rate_limit(self):
|
|
513
|
+
from cli_web.codewiki.core.exceptions import RateLimitError, error_code_for
|
|
514
|
+
|
|
515
|
+
assert error_code_for(RateLimitError("slow down")) == "RATE_LIMITED"
|
|
516
|
+
|
|
517
|
+
def test_error_code_mapping_not_found(self):
|
|
518
|
+
from cli_web.codewiki.core.exceptions import NotFoundError, error_code_for
|
|
519
|
+
|
|
520
|
+
assert error_code_for(NotFoundError("missing")) == "NOT_FOUND"
|
|
521
|
+
|
|
522
|
+
def test_error_code_mapping_server_error(self):
|
|
523
|
+
from cli_web.codewiki.core.exceptions import ServerError, error_code_for
|
|
524
|
+
|
|
525
|
+
assert error_code_for(ServerError("oops", 500)) == "SERVER_ERROR"
|
|
526
|
+
|
|
527
|
+
def test_error_code_mapping_network(self):
|
|
528
|
+
from cli_web.codewiki.core.exceptions import NetworkError, error_code_for
|
|
529
|
+
|
|
530
|
+
assert error_code_for(NetworkError("down")) == "NETWORK_ERROR"
|
|
531
|
+
|
|
532
|
+
def test_error_code_mapping_rpc(self):
|
|
533
|
+
from cli_web.codewiki.core.exceptions import RPCError, error_code_for
|
|
534
|
+
|
|
535
|
+
assert error_code_for(RPCError("bad rpc")) == "RPC_ERROR"
|
|
536
|
+
|
|
537
|
+
def test_error_code_mapping_unknown(self):
|
|
538
|
+
from cli_web.codewiki.core.exceptions import error_code_for
|
|
539
|
+
|
|
540
|
+
assert error_code_for(ValueError("unexpected")) == "INTERNAL_ERROR"
|
|
541
|
+
|
|
542
|
+
def test_auth_error_recoverable_default_true(self):
|
|
543
|
+
from cli_web.codewiki.core.exceptions import AuthError
|
|
544
|
+
|
|
545
|
+
exc = AuthError("session expired")
|
|
546
|
+
assert exc.recoverable is True
|
|
547
|
+
|
|
548
|
+
def test_auth_error_recoverable_can_be_false(self):
|
|
549
|
+
from cli_web.codewiki.core.exceptions import AuthError
|
|
550
|
+
|
|
551
|
+
exc = AuthError("hard fail", recoverable=False)
|
|
552
|
+
assert exc.recoverable is False
|
|
553
|
+
|
|
554
|
+
def test_rate_limit_error_stores_retry_after(self):
|
|
555
|
+
from cli_web.codewiki.core.exceptions import RateLimitError
|
|
556
|
+
|
|
557
|
+
exc = RateLimitError("slow down", retry_after=60.0)
|
|
558
|
+
assert exc.retry_after == 60.0
|
|
559
|
+
|
|
560
|
+
def test_server_error_stores_status_code(self):
|
|
561
|
+
from cli_web.codewiki.core.exceptions import ServerError
|
|
562
|
+
|
|
563
|
+
exc = ServerError("boom", status_code=503)
|
|
564
|
+
assert exc.status_code == 503
|
|
565
|
+
|
|
566
|
+
def test_rpc_error_stores_code(self):
|
|
567
|
+
from cli_web.codewiki.core.exceptions import RPCError
|
|
568
|
+
|
|
569
|
+
exc = RPCError("protocol err", code=403)
|
|
570
|
+
assert exc.code == 403
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ===========================================================================
|
|
574
|
+
# 6. handle_errors context manager tests
|
|
575
|
+
# ===========================================================================
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class TestHandleErrors:
|
|
579
|
+
def test_handle_errors_codewiki_error_exits_1(self):
|
|
580
|
+
from cli_web.codewiki.core.exceptions import NotFoundError
|
|
581
|
+
from cli_web.codewiki.utils.helpers import handle_errors
|
|
582
|
+
|
|
583
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
584
|
+
with handle_errors(json_mode=False):
|
|
585
|
+
raise NotFoundError("not found")
|
|
586
|
+
assert exc_info.value.code == 1
|
|
587
|
+
|
|
588
|
+
def test_handle_errors_codewiki_error_json_output(self, capsys):
|
|
589
|
+
from cli_web.codewiki.core.exceptions import RateLimitError
|
|
590
|
+
from cli_web.codewiki.utils.helpers import handle_errors
|
|
591
|
+
|
|
592
|
+
with pytest.raises(SystemExit):
|
|
593
|
+
with handle_errors(json_mode=True):
|
|
594
|
+
raise RateLimitError("too many requests")
|
|
595
|
+
captured = capsys.readouterr()
|
|
596
|
+
data = json.loads(captured.out)
|
|
597
|
+
assert data["error"] is True
|
|
598
|
+
assert data["code"] == "RATE_LIMITED"
|
|
599
|
+
assert "too many requests" in data["message"]
|
|
600
|
+
|
|
601
|
+
def test_handle_errors_unexpected_exits_2(self):
|
|
602
|
+
from cli_web.codewiki.utils.helpers import handle_errors
|
|
603
|
+
|
|
604
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
605
|
+
with handle_errors(json_mode=False):
|
|
606
|
+
raise RuntimeError("something unexpected")
|
|
607
|
+
assert exc_info.value.code == 2
|
|
608
|
+
|
|
609
|
+
def test_handle_errors_unexpected_json_output(self, capsys):
|
|
610
|
+
from cli_web.codewiki.utils.helpers import handle_errors
|
|
611
|
+
|
|
612
|
+
with pytest.raises(SystemExit):
|
|
613
|
+
with handle_errors(json_mode=True):
|
|
614
|
+
raise RuntimeError("boom")
|
|
615
|
+
captured = capsys.readouterr()
|
|
616
|
+
data = json.loads(captured.out)
|
|
617
|
+
assert data["error"] is True
|
|
618
|
+
assert data["code"] == "INTERNAL_ERROR"
|
|
619
|
+
|
|
620
|
+
def test_handle_errors_keyboard_interrupt_exits_130(self):
|
|
621
|
+
from cli_web.codewiki.utils.helpers import handle_errors
|
|
622
|
+
|
|
623
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
624
|
+
with handle_errors(json_mode=False):
|
|
625
|
+
raise KeyboardInterrupt
|
|
626
|
+
assert exc_info.value.code == 130
|
|
627
|
+
|
|
628
|
+
def test_handle_errors_no_error_yields(self):
|
|
629
|
+
from cli_web.codewiki.utils.helpers import handle_errors
|
|
630
|
+
|
|
631
|
+
result = []
|
|
632
|
+
with handle_errors(json_mode=False):
|
|
633
|
+
result.append(42)
|
|
634
|
+
assert result == [42]
|
|
635
|
+
|
|
636
|
+
def test_handle_errors_usage_error_propagates(self):
|
|
637
|
+
import click
|
|
638
|
+
from cli_web.codewiki.utils.helpers import handle_errors
|
|
639
|
+
|
|
640
|
+
with pytest.raises(click.UsageError):
|
|
641
|
+
with handle_errors(json_mode=False):
|
|
642
|
+
raise click.UsageError("bad args")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ===========================================================================
|
|
646
|
+
# 7. Models tests
|
|
647
|
+
# ===========================================================================
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
class TestModels:
|
|
651
|
+
def test_repository_org_and_name_properties(self):
|
|
652
|
+
from cli_web.codewiki.core.models import Repository
|
|
653
|
+
|
|
654
|
+
repo = Repository(slug="my-org/my-repo", github_url="https://github.com/my-org/my-repo")
|
|
655
|
+
assert repo.org == "my-org"
|
|
656
|
+
assert repo.name == "my-repo"
|
|
657
|
+
|
|
658
|
+
def test_repository_to_dict_keys(self):
|
|
659
|
+
from cli_web.codewiki.core.models import Repository
|
|
660
|
+
|
|
661
|
+
repo = Repository(
|
|
662
|
+
slug="a/b",
|
|
663
|
+
github_url="https://github.com/a/b",
|
|
664
|
+
description="desc",
|
|
665
|
+
stars=100,
|
|
666
|
+
)
|
|
667
|
+
d = repo.to_dict()
|
|
668
|
+
assert set(d.keys()) == {
|
|
669
|
+
"slug",
|
|
670
|
+
"github_url",
|
|
671
|
+
"description",
|
|
672
|
+
"avatar_url",
|
|
673
|
+
"stars",
|
|
674
|
+
"commit_hash",
|
|
675
|
+
"updated_at",
|
|
676
|
+
}
|
|
677
|
+
assert d["stars"] == 100
|
|
678
|
+
|
|
679
|
+
def test_wiki_section_to_dict(self):
|
|
680
|
+
from cli_web.codewiki.core.models import WikiSection
|
|
681
|
+
|
|
682
|
+
sec = WikiSection(title="Overview", level=1, content="some text")
|
|
683
|
+
d = sec.to_dict()
|
|
684
|
+
assert d["title"] == "Overview"
|
|
685
|
+
assert d["level"] == 1
|
|
686
|
+
assert d["content"] == "some text"
|
|
687
|
+
|
|
688
|
+
def test_wiki_page_to_dict_includes_section_count(self):
|
|
689
|
+
from cli_web.codewiki.core.models import Repository, WikiPage, WikiSection
|
|
690
|
+
|
|
691
|
+
repo = Repository(slug="a/b", github_url="https://github.com/a/b")
|
|
692
|
+
sections = [WikiSection(title=f"S{i}", level=1) for i in range(3)]
|
|
693
|
+
page = WikiPage(repo=repo, sections=sections, has_wiki=True)
|
|
694
|
+
d = page.to_dict()
|
|
695
|
+
assert d["section_count"] == 3
|
|
696
|
+
assert d["has_wiki"] is True
|
|
697
|
+
|
|
698
|
+
def test_chat_response_to_dict(self):
|
|
699
|
+
from cli_web.codewiki.core.models import ChatResponse
|
|
700
|
+
|
|
701
|
+
resp = ChatResponse(answer="42", repo_slug="a/b")
|
|
702
|
+
d = resp.to_dict()
|
|
703
|
+
assert d["answer"] == "42"
|
|
704
|
+
assert d["repo"] == "a/b"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ===========================================================================
|
|
708
|
+
# 8. RPC types constants
|
|
709
|
+
# ===========================================================================
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
class TestRPCTypes:
|
|
713
|
+
def test_rpc_method_ids(self):
|
|
714
|
+
from cli_web.codewiki.core.rpc.types import RPCMethod
|
|
715
|
+
|
|
716
|
+
assert RPCMethod.FEATURED_REPOS == "nm8Fsb"
|
|
717
|
+
assert RPCMethod.WIKI_PAGE == "VSX6ub"
|
|
718
|
+
assert RPCMethod.SEARCH_REPOS == "vyWDAf"
|
|
719
|
+
assert RPCMethod.CHAT == "EgIxfe"
|
|
720
|
+
|
|
721
|
+
def test_batchexecute_url_is_https(self):
|
|
722
|
+
from cli_web.codewiki.core.rpc.types import BATCHEXECUTE_URL
|
|
723
|
+
|
|
724
|
+
assert BATCHEXECUTE_URL.startswith("https://")
|
|
725
|
+
assert "batchexecute" in BATCHEXECUTE_URL
|