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