Navegador

navegador / tests / test_explorer.py
Blame History Raw 552 lines
1
# Copyright CONFLICT LLC 2026 (weareconflict.com)
2
"""
3
Tests for navegador.explorer — ExplorerServer, API endpoints, HTML template,
4
and the CLI `explore` command.
5
"""
6
7
from __future__ import annotations
8
9
import json
10
import urllib.error
11
import urllib.request
12
from unittest.mock import MagicMock, patch
13
14
import pytest
15
from click.testing import CliRunner
16
17
from navegador.cli.commands import main
18
from navegador.explorer import ExplorerServer
19
from navegador.explorer.templates import HTML_TEMPLATE
20
21
# ── Helpers ────────────────────────────────────────────────────────────────
22
23
24
def _mock_store(
25
*,
26
nodes: list | None = None,
27
edges: list | None = None,
28
node_count: int = 3,
29
edge_count: int = 2,
30
):
31
"""Return a minimal GraphStore mock suitable for explorer tests."""
32
store = MagicMock()
33
store.node_count.return_value = node_count
34
store.edge_count.return_value = edge_count
35
36
# Each query() call returns a result-set mock. We cycle through prebuilt
37
# responses so different Cypher patterns get appropriate data.
38
_node_rows = nodes or []
39
_edge_rows = edges or []
40
41
def _query_side_effect(cypher: str, params=None):
42
result = MagicMock()
43
cypher_lower = cypher.lower()
44
if "match (a)-[r]->(b)" in cypher_lower:
45
result.result_set = _edge_rows
46
elif "match (n)-[r]->(nb)" in cypher_lower or "match (nb)-[r]->(n)" in cypher_lower:
47
result.result_set = []
48
elif "match (n) where n.name" in cypher_lower and "properties" in cypher_lower:
49
# node detail: single node row
50
result.result_set = [["Function", {"name": "foo", "file_path": "app.py"}]]
51
elif "match (n)" in cypher_lower and "tolow" in cypher_lower:
52
result.result_set = [
53
["Function", "foo", "app.py", ""],
54
]
55
elif "labels(n)" in cypher_lower and "count" in cypher_lower:
56
result.result_set = [["Function", 2], ["Class", 1]]
57
elif "type(r)" in cypher_lower and "count" in cypher_lower:
58
result.result_set = [["CALLS", 2]]
59
else:
60
result.result_set = _node_rows
61
return result
62
63
store.query.side_effect = _query_side_effect
64
return store
65
66
67
def _free_port() -> int:
68
"""Return an available TCP port on localhost."""
69
import socket
70
with socket.socket() as s:
71
s.bind(("127.0.0.1", 0))
72
return s.getsockname()[1]
73
74
75
def _fetch(url: str, timeout: float = 5.0) -> tuple[int, str]:
76
"""GET *url* and return (status_code, response_body_str)."""
77
with urllib.request.urlopen(url, timeout=timeout) as resp:
78
return resp.status, resp.read().decode()
79
80
81
def _fetch_json(url: str, timeout: float = 5.0) -> tuple[int, dict | list]:
82
status, body = _fetch(url, timeout)
83
return status, json.loads(body)
84
85
86
# ── ExplorerServer creation ────────────────────────────────────────────────
87
88
89
class TestExplorerServerCreation:
90
def test_default_host_and_port(self):
91
store = _mock_store()
92
server = ExplorerServer(store)
93
assert server.host == "127.0.0.1"
94
assert server.port == 8080
95
assert server.store is store
96
97
def test_custom_host_and_port(self):
98
store = _mock_store()
99
server = ExplorerServer(store, host="0.0.0.0", port=9999)
100
assert server.host == "0.0.0.0"
101
assert server.port == 9999
102
103
def test_url_property(self):
104
server = ExplorerServer(_mock_store(), host="127.0.0.1", port=8080)
105
assert server.url == "http://127.0.0.1:8080"
106
107
def test_not_running_by_default(self):
108
server = ExplorerServer(_mock_store(), port=_free_port())
109
assert server._server is None
110
assert server._thread is None
111
112
def test_double_start_raises(self):
113
port = _free_port()
114
server = ExplorerServer(_mock_store(), port=port)
115
server.start()
116
try:
117
with pytest.raises(RuntimeError, match="already running"):
118
server.start()
119
finally:
120
server.stop()
121
122
def test_stop_when_not_started_is_noop(self):
123
server = ExplorerServer(_mock_store(), port=_free_port())
124
server.stop() # should not raise
125
126
def test_context_manager(self):
127
port = _free_port()
128
store = _mock_store()
129
with ExplorerServer(store, port=port) as srv:
130
assert srv._server is not None
131
assert srv._server is None
132
133
134
# ── Start / stop lifecycle ─────────────────────────────────────────────────
135
136
137
class TestExplorerServerLifecycle:
138
def test_start_makes_server_accessible(self):
139
port = _free_port()
140
server = ExplorerServer(_mock_store(), port=port)
141
server.start()
142
try:
143
status, _ = _fetch(f"http://127.0.0.1:{port}/")
144
assert status == 200
145
finally:
146
server.stop()
147
148
def test_stop_takes_server_offline(self):
149
port = _free_port()
150
server = ExplorerServer(_mock_store(), port=port)
151
server.start()
152
server.stop()
153
with pytest.raises(Exception):
154
_fetch(f"http://127.0.0.1:{port}/", timeout=1.0)
155
156
def test_thread_is_daemon(self):
157
port = _free_port()
158
server = ExplorerServer(_mock_store(), port=port)
159
server.start()
160
try:
161
assert server._thread is not None
162
assert server._thread.daemon is True
163
finally:
164
server.stop()
165
166
167
# ── API endpoint: GET / ────────────────────────────────────────────────────
168
169
170
class TestRootEndpoint:
171
def test_returns_html(self):
172
port = _free_port()
173
with ExplorerServer(_mock_store(), port=port):
174
status, body = _fetch(f"http://127.0.0.1:{port}/")
175
assert status == 200
176
assert "<!DOCTYPE html>" in body or "<!doctype html>" in body.lower()
177
178
def test_html_contains_canvas(self):
179
port = _free_port()
180
with ExplorerServer(_mock_store(), port=port):
181
_, body = _fetch(f"http://127.0.0.1:{port}/")
182
assert "graph-canvas" in body
183
184
def test_html_contains_search_box(self):
185
port = _free_port()
186
with ExplorerServer(_mock_store(), port=port):
187
_, body = _fetch(f"http://127.0.0.1:{port}/")
188
assert "search-box" in body
189
190
def test_html_contains_api_calls(self):
191
port = _free_port()
192
with ExplorerServer(_mock_store(), port=port):
193
_, body = _fetch(f"http://127.0.0.1:{port}/")
194
assert "/api/graph" in body
195
196
197
# ── API endpoint: GET /api/graph ──────────────────────────────────────────
198
199
200
class TestGraphEndpoint:
201
def _make_node_rows(self):
202
# Rows returned for the full-node Cypher query
203
return [
204
[1, "Function", "foo", {"name": "foo", "file_path": "app.py"}],
205
[2, "Class", "Bar", {"name": "Bar", "file_path": "app.py"}],
206
]
207
208
def test_returns_nodes_and_edges_keys(self):
209
port = _free_port()
210
store = _mock_store(nodes=self._make_node_rows(), edges=[])
211
with ExplorerServer(store, port=port):
212
status, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
213
assert status == 200
214
assert "nodes" in data
215
assert "edges" in data
216
217
def test_nodes_have_required_fields(self):
218
port = _free_port()
219
store = _mock_store(nodes=self._make_node_rows())
220
with ExplorerServer(store, port=port):
221
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
222
for node in data["nodes"]:
223
assert "id" in node
224
assert "label" in node
225
assert "name" in node
226
227
def test_empty_graph(self):
228
port = _free_port()
229
store = _mock_store(nodes=[], edges=[], node_count=0, edge_count=0)
230
with ExplorerServer(store, port=port):
231
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
232
assert data["nodes"] == []
233
assert data["edges"] == []
234
235
def test_edges_have_required_fields(self):
236
port = _free_port()
237
edge_rows = [[1, 2, "CALLS"]]
238
store = _mock_store(nodes=self._make_node_rows(), edges=edge_rows)
239
with ExplorerServer(store, port=port):
240
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
241
for edge in data["edges"]:
242
assert "source" in edge
243
assert "target" in edge
244
assert "type" in edge
245
246
247
# ── API endpoint: GET /api/search ─────────────────────────────────────────
248
249
250
class TestSearchEndpoint:
251
def test_returns_nodes_key(self):
252
port = _free_port()
253
with ExplorerServer(_mock_store(), port=port):
254
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=foo")
255
assert "nodes" in data
256
257
def test_empty_query_returns_empty(self):
258
port = _free_port()
259
with ExplorerServer(_mock_store(), port=port):
260
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=")
261
assert data["nodes"] == []
262
263
def test_missing_q_returns_empty(self):
264
port = _free_port()
265
with ExplorerServer(_mock_store(), port=port):
266
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/search")
267
assert data["nodes"] == []
268
269
def test_result_nodes_have_name_and_label(self):
270
port = _free_port()
271
with ExplorerServer(_mock_store(), port=port):
272
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=foo")
273
for node in data["nodes"]:
274
assert "name" in node
275
assert "label" in node
276
277
278
# ── API endpoint: GET /api/node/<name> ────────────────────────────────────
279
280
281
class TestNodeDetailEndpoint:
282
def test_returns_name_label_props_neighbors(self):
283
port = _free_port()
284
with ExplorerServer(_mock_store(), port=port):
285
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/foo")
286
assert "name" in data
287
assert "label" in data
288
assert "props" in data
289
assert "neighbors" in data
290
291
def test_name_matches_request(self):
292
port = _free_port()
293
with ExplorerServer(_mock_store(), port=port):
294
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/foo")
295
assert data["name"] == "foo"
296
297
def test_url_encoded_name(self):
298
port = _free_port()
299
with ExplorerServer(_mock_store(), port=port):
300
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/my%20node")
301
assert data["name"] == "my node"
302
303
def test_unknown_node_returns_empty_detail(self):
304
port = _free_port()
305
store = _mock_store()
306
# Override query to return empty for the node-detail lookup
307
original_side_effect = store.query.side_effect
308
309
def _empty_node(cypher, params=None):
310
if "where n.name" in cypher.lower() and "properties" in cypher.lower():
311
r = MagicMock()
312
r.result_set = []
313
return r
314
return original_side_effect(cypher, params)
315
316
store.query.side_effect = _empty_node
317
with ExplorerServer(store, port=port):
318
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/nonexistent")
319
assert data["neighbors"] == []
320
321
322
# ── API endpoint: GET /api/stats ──────────────────────────────────────────
323
324
325
class TestStatsEndpoint:
326
def test_returns_nodes_and_edges_counts(self):
327
port = _free_port()
328
with ExplorerServer(_mock_store(node_count=5, edge_count=3), port=port):
329
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
330
assert data["nodes"] == 5
331
assert data["edges"] == 3
332
333
def test_returns_node_types_and_edge_types(self):
334
port = _free_port()
335
with ExplorerServer(_mock_store(), port=port):
336
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
337
assert "node_types" in data
338
assert "edge_types" in data
339
assert isinstance(data["node_types"], dict)
340
assert isinstance(data["edge_types"], dict)
341
342
def test_node_type_counts_sum(self):
343
port = _free_port()
344
with ExplorerServer(_mock_store(node_count=3), port=port):
345
_, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
346
total = sum(data["node_types"].values())
347
# The mock returns Function:2, Class:1 → total 3
348
assert total == 3
349
350
351
# ── 404 for unknown routes ─────────────────────────────────────────────────
352
353
354
class TestNotFound:
355
def test_unknown_path_returns_404(self):
356
port = _free_port()
357
with ExplorerServer(_mock_store(), port=port):
358
with pytest.raises(urllib.error.HTTPError) as exc_info:
359
urllib.request.urlopen(f"http://127.0.0.1:{port}/api/nonexistent")
360
assert exc_info.value.code == 404
361
362
363
# ── HTML template ─────────────────────────────────────────────────────────
364
365
366
class TestHtmlTemplate:
367
def test_is_string(self):
368
assert isinstance(HTML_TEMPLATE, str)
369
370
def test_contains_doctype(self):
371
assert "<!DOCTYPE html>" in HTML_TEMPLATE
372
373
def test_contains_canvas(self):
374
assert "graph-canvas" in HTML_TEMPLATE
375
376
def test_contains_search_box(self):
377
assert "search-box" in HTML_TEMPLATE
378
379
def test_contains_detail_panel(self):
380
assert "detail-panel" in HTML_TEMPLATE
381
382
def test_contains_api_graph_fetch(self):
383
assert "/api/graph" in HTML_TEMPLATE
384
385
def test_contains_api_search_fetch(self):
386
assert "/api/search" in HTML_TEMPLATE
387
388
def test_contains_api_node_fetch(self):
389
assert "/api/node/" in HTML_TEMPLATE
390
391
def test_contains_api_stats_fetch(self):
392
assert "/api/stats" in HTML_TEMPLATE
393
394
def test_no_external_deps(self):
395
"""No CDN or external URLs should appear in the template."""
396
import re
397
# Look for any http(s):// URLs — internal /api/ paths are fine
398
external = re.findall(r'https?://\S+', HTML_TEMPLATE)
399
assert external == [], f"External URLs found: {external}"
400
401
def test_contains_force_directed_physics(self):
402
lower = HTML_TEMPLATE.lower()
403
assert "REPEL" in HTML_TEMPLATE or "repulsion" in lower or "force" in lower
404
405
def test_colors_injected(self):
406
assert "Function" in HTML_TEMPLATE
407
assert "Class" in HTML_TEMPLATE
408
409
def test_self_contained_script_tag(self):
410
assert "<script>" in HTML_TEMPLATE
411
412
def test_self_contained_style_tag(self):
413
assert "<style>" in HTML_TEMPLATE
414
415
416
# ── CLI command: navegador explore ────────────────────────────────────────
417
418
419
class TestExploreCLI:
420
def test_help_text(self):
421
runner = CliRunner()
422
result = runner.invoke(main, ["explore", "--help"])
423
assert result.exit_code == 0
424
assert "explore" in result.output.lower() or "graph" in result.output.lower()
425
426
def test_explore_command_registered(self):
427
"""Verify the explore command is registered under the main group."""
428
from navegador.cli.commands import main as cli_main
429
assert "explore" in cli_main.commands
430
431
def test_explore_starts_and_stops(self):
432
"""CLI explore should start ExplorerServer and stop cleanly on KeyboardInterrupt."""
433
runner = CliRunner()
434
port = _free_port()
435
436
mock_srv = MagicMock()
437
mock_srv.url = f"http://127.0.0.1:{port}"
438
439
call_count = [0]
440
441
def _fake_sleep(seconds):
442
# Let the first call (browser delay) pass, raise on second (main loop)
443
call_count[0] += 1
444
if call_count[0] >= 2:
445
raise KeyboardInterrupt
446
447
# The explore command does local imports, so patch at the source modules.
448
with (
449
patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
450
patch("navegador.cli.commands._get_store", return_value=MagicMock()),
451
patch("time.sleep", side_effect=_fake_sleep),
452
patch("webbrowser.open"),
453
):
454
result = runner.invoke(main, ["explore", "--port", str(port)])
455
456
mock_srv.start.assert_called_once()
457
mock_srv.stop.assert_called_once()
458
assert result.exit_code == 0
459
460
def test_explore_no_browser_flag(self):
461
"""--no-browser should skip webbrowser.open."""
462
runner = CliRunner()
463
port = _free_port()
464
465
mock_srv = MagicMock()
466
mock_srv.url = f"http://127.0.0.1:{port}"
467
468
def _fake_sleep(seconds):
469
raise KeyboardInterrupt
470
471
with (
472
patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
473
patch("navegador.cli.commands._get_store", return_value=MagicMock()),
474
patch("time.sleep", side_effect=_fake_sleep),
475
patch("webbrowser.open") as mock_open,
476
):
477
result = runner.invoke(main, ["explore", "--no-browser", "--port", str(port)])
478
479
mock_open.assert_not_called()
480
assert result.exit_code == 0
481
482
def test_explore_custom_port(self):
483
"""--port option should be forwarded to ExplorerServer."""
484
runner = CliRunner()
485
port = _free_port()
486
487
captured = {}
488
489
def _fake_server(store, host, port): # noqa: A002
490
captured["port"] = port
491
srv = MagicMock()
492
srv.url = f"http://{host}:{port}"
493
return srv
494
495
def _fake_sleep(seconds):
496
raise KeyboardInterrupt
497
498
with (
499
patch("navegador.explorer.ExplorerServer", side_effect=_fake_server),
500
patch("navegador.cli.commands._get_store", return_value=MagicMock()),
501
patch("time.sleep", side_effect=_fake_sleep),
502
patch("webbrowser.open"),
503
):
504
runner.invoke(main, ["explore", "--port", str(port)])
505
506
assert captured.get("port") == port
507
508
def test_explore_custom_host(self):
509
"""--host option should be forwarded to ExplorerServer."""
510
runner = CliRunner()
511
captured = {}
512
513
def _fake_server(store, host, port): # noqa: A002
514
captured["host"] = host
515
srv = MagicMock()
516
srv.url = f"http://{host}:{port}"
517
return srv
518
519
def _fake_sleep(seconds):
520
raise KeyboardInterrupt
521
522
with (
523
patch("navegador.explorer.ExplorerServer", side_effect=_fake_server),
524
patch("navegador.cli.commands._get_store", return_value=MagicMock()),
525
patch("time.sleep", side_effect=_fake_sleep),
526
patch("webbrowser.open"),
527
):
528
runner.invoke(main, ["explore", "--host", "0.0.0.0"])
529
530
assert captured.get("host") == "0.0.0.0"
531
532
def test_explore_output_shows_url(self):
533
"""explore should print the server URL to stdout."""
534
runner = CliRunner()
535
port = _free_port()
536
537
mock_srv = MagicMock()
538
mock_srv.url = f"http://127.0.0.1:{port}"
539
540
def _fake_sleep(seconds):
541
raise KeyboardInterrupt
542
543
with (
544
patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
545
patch("navegador.cli.commands._get_store", return_value=MagicMock()),
546
patch("time.sleep", side_effect=_fake_sleep),
547
patch("webbrowser.open"),
548
):
549
result = runner.invoke(main, ["explore", "--port", str(port)])
550
551
assert str(port) in result.output or "127.0.0.1" in result.output
552

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button