Navegador

navegador / tests / test_enrichment_fastapi.py
Blame History Raw 468 lines
1
"""
2
Tests for navegador.enrichment.fastapi — FastAPIEnricher.
3
4
All tests use a mock GraphStore so no real database is required.
5
"""
6
7
from unittest.mock import MagicMock, call, patch
8
9
import pytest
10
11
from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher
12
from navegador.enrichment.fastapi import FastAPIEnricher
13
from navegador.graph.store import GraphStore
14
15
16
# ── Helpers ────────────────────────────────────────────────────────────────────
17
18
19
def _mock_store(result_set=None):
20
"""Return a GraphStore backed by a mock graph that always returns result_set."""
21
client = MagicMock()
22
graph = MagicMock()
23
graph.query.return_value = MagicMock(result_set=result_set or [])
24
client.select_graph.return_value = graph
25
return GraphStore(client)
26
27
28
def _mock_store_with_responses(responses):
29
"""
30
Return a GraphStore whose graph.query() returns successive mock results.
31
32
``responses`` is a list of result_set values in call order. Once
33
exhausted, subsequent calls return an empty result_set.
34
"""
35
client = MagicMock()
36
graph = MagicMock()
37
iter_resp = iter(responses)
38
39
def _side_effect(cypher, params=None):
40
rs = next(iter_resp, [])
41
return MagicMock(result_set=rs)
42
43
graph.query.side_effect = _side_effect
44
client.select_graph.return_value = graph
45
return GraphStore(client)
46
47
48
# ── Identity ───────────────────────────────────────────────────────────────────
49
50
51
class TestFastAPIEnricherIdentity:
52
def test_framework_name(self):
53
store = _mock_store()
54
enricher = FastAPIEnricher(store)
55
assert enricher.framework_name == "fastapi"
56
57
def test_detection_patterns_includes_fastapi_lowercase(self):
58
store = _mock_store()
59
assert "fastapi" in FastAPIEnricher(store).detection_patterns
60
61
def test_detection_patterns_is_list_of_strings(self):
62
store = _mock_store()
63
patterns = FastAPIEnricher(store).detection_patterns
64
assert isinstance(patterns, list)
65
assert all(isinstance(p, str) for p in patterns)
66
67
def test_detection_patterns_is_nonempty(self):
68
store = _mock_store()
69
assert len(FastAPIEnricher(store).detection_patterns) >= 1
70
71
def test_is_subclass_of_framework_enricher(self):
72
store = _mock_store()
73
assert isinstance(FastAPIEnricher(store), FrameworkEnricher)
74
75
76
# ── detect() ──────────────────────────────────────────────────────────────────
77
78
79
class TestFastAPIEnricherDetect:
80
def test_detect_returns_true_when_fastapi_import_found(self):
81
store = _mock_store(result_set=[[1]])
82
assert FastAPIEnricher(store).detect() is True
83
84
def test_detect_returns_false_when_no_match(self):
85
store = _mock_store(result_set=[[0]])
86
assert FastAPIEnricher(store).detect() is False
87
88
def test_detect_returns_false_on_empty_result_set(self):
89
store = _mock_store(result_set=[])
90
assert FastAPIEnricher(store).detect() is False
91
92
def test_detect_queries_all_patterns_when_no_match(self):
93
store = _mock_store(result_set=[[0]])
94
enricher = FastAPIEnricher(store)
95
enricher.detect()
96
# All detection_patterns queried when no match is found
97
expected = len(enricher.detection_patterns) + len(enricher.detection_files)
98
assert store._graph.query.call_count == expected
99
100
def test_detect_short_circuits_on_first_match(self):
101
store = _mock_store(result_set=[[7]])
102
FastAPIEnricher(store).detect()
103
assert store._graph.query.call_count == 1
104
105
106
# ── enrich() returns EnrichmentResult ─────────────────────────────────────────
107
108
109
class TestFastAPIEnricherEnrichReturnType:
110
def test_returns_enrichment_result(self):
111
store = _mock_store()
112
result = FastAPIEnricher(store).enrich()
113
assert isinstance(result, EnrichmentResult)
114
115
def test_result_has_expected_pattern_keys(self):
116
store = _mock_store()
117
result = FastAPIEnricher(store).enrich()
118
assert "routes" in result.patterns_found
119
assert "dependencies" in result.patterns_found
120
assert "pydantic_models" in result.patterns_found
121
assert "background_tasks" in result.patterns_found
122
123
def test_result_promoted_is_sum_of_patterns(self):
124
store = _mock_store()
125
result = FastAPIEnricher(store).enrich()
126
assert result.promoted == sum(result.patterns_found.values())
127
128
def test_no_matches_gives_zero_promoted(self):
129
store = _mock_store(result_set=[])
130
result = FastAPIEnricher(store).enrich()
131
assert result.promoted == 0
132
133
134
# ── Routes ────────────────────────────────────────────────────────────────────
135
136
137
class TestFastAPIEnricherRoutes:
138
def _enricher_with_route_hit(self, name="get_items", file_path="app/main.py"):
139
"""
140
Build a FastAPIEnricher whose store returns one matching row for the
141
*first* Decorator-based route query (app.get), then empty for the rest.
142
"""
143
client = MagicMock()
144
graph = MagicMock()
145
call_count = [0]
146
147
def _side_effect(cypher, params=None):
148
call_count[0] += 1
149
# Return a hit on the very first call only
150
if call_count[0] == 1:
151
return MagicMock(result_set=[[name, file_path]])
152
return MagicMock(result_set=[])
153
154
graph.query.side_effect = _side_effect
155
client.select_graph.return_value = graph
156
store = GraphStore(client)
157
return FastAPIEnricher(store)
158
159
def test_route_promoted_increments_count(self):
160
enricher = self._enricher_with_route_hit()
161
result = enricher.enrich()
162
assert result.patterns_found["routes"] >= 1
163
164
def test_route_calls_promote_node_with_route_semantic_type(self):
165
enricher = self._enricher_with_route_hit("list_users", "app/users.py")
166
with patch.object(enricher, "_promote_node") as mock_promote:
167
# Re-wire store so only the first query returns a hit
168
call_count = [0]
169
170
def _side_effect(cypher, params=None):
171
call_count[0] += 1
172
if call_count[0] == 1:
173
return MagicMock(result_set=[["list_users", "app/users.py"]])
174
return MagicMock(result_set=[])
175
176
enricher.store._graph.query.side_effect = _side_effect
177
enricher.enrich()
178
179
calls = [c for c in mock_promote.call_args_list if c[0][2] == "Route"]
180
assert len(calls) >= 1
181
assert calls[0][0][0] == "list_users"
182
assert calls[0][0][1] == "app/users.py"
183
184
def test_route_http_method_stored_as_prop(self):
185
"""The http_method kwarg should be passed to _promote_node for route nodes."""
186
store = _mock_store()
187
enricher = FastAPIEnricher(store)
188
189
call_count = [0]
190
191
def _side_effect(cypher, params=None):
192
call_count[0] += 1
193
if call_count[0] == 1:
194
return MagicMock(result_set=[["create_item", "app/main.py"]])
195
return MagicMock(result_set=[])
196
197
store._graph.query.side_effect = _side_effect
198
199
with patch.object(enricher, "_promote_node") as mock_promote:
200
enricher.enrich()
201
202
route_calls = [c for c in mock_promote.call_args_list if c[0][2] == "Route"]
203
assert route_calls, "Expected at least one _promote_node call with 'Route'"
204
_, kwargs = route_calls[0]
205
props = kwargs.get("props") or (route_calls[0][0][3] if len(route_calls[0][0]) > 3 else None)
206
assert props is not None
207
assert "http_method" in props
208
209
210
# ── Dependencies ──────────────────────────────────────────────────────────────
211
212
213
class TestFastAPIEnricherDependencies:
214
def test_dependency_promoted_for_depends_in_signature(self):
215
store = _mock_store()
216
enricher = FastAPIEnricher(store)
217
218
def _side_effect(cypher, params=None):
219
params = params or {}
220
if params.get("pattern") == "Depends(":
221
return MagicMock(result_set=[["get_db", "app/deps.py"]])
222
return MagicMock(result_set=[])
223
224
store._graph.query.side_effect = _side_effect
225
226
with patch.object(enricher, "_promote_node") as mock_promote:
227
enricher.enrich()
228
229
dep_calls = [c for c in mock_promote.call_args_list if c[0][2] == "Dependency"]
230
assert len(dep_calls) >= 1
231
232
def test_dependency_semantic_type_is_dependency(self):
233
store = _mock_store()
234
enricher = FastAPIEnricher(store)
235
236
def _side_effect(cypher, params=None):
237
params = params or {}
238
if "Depends" in params.get("pattern", ""):
239
return MagicMock(result_set=[["auth_user", "app/auth.py"]])
240
return MagicMock(result_set=[])
241
242
store._graph.query.side_effect = _side_effect
243
244
with patch.object(enricher, "_promote_node") as mock_promote:
245
enricher.enrich()
246
247
dep_calls = [c for c in mock_promote.call_args_list if c[0][2] == "Dependency"]
248
assert dep_calls
249
250
251
# ── Pydantic Models ───────────────────────────────────────────────────────────
252
253
254
class TestFastAPIEnricherPydanticModels:
255
def test_pydantic_model_promoted_via_inherits_edge(self):
256
store = _mock_store()
257
enricher = FastAPIEnricher(store)
258
259
def _side_effect(cypher, params=None):
260
params = params or {}
261
if params.get("base_name") == "BaseModel":
262
return MagicMock(result_set=[["UserSchema", "app/schemas.py"]])
263
return MagicMock(result_set=[])
264
265
store._graph.query.side_effect = _side_effect
266
267
with patch.object(enricher, "_promote_node") as mock_promote:
268
enricher.enrich()
269
270
pm_calls = [c for c in mock_promote.call_args_list if c[0][2] == "PydanticModel"]
271
assert len(pm_calls) >= 1
272
assert pm_calls[0][0][0] == "UserSchema"
273
assert pm_calls[0][0][1] == "app/schemas.py"
274
275
def test_pydantic_model_semantic_type_string(self):
276
store = _mock_store()
277
enricher = FastAPIEnricher(store)
278
279
def _side_effect(cypher, params=None):
280
params = params or {}
281
if params.get("base_name") == "BaseModel":
282
return MagicMock(result_set=[["ItemModel", "app/models.py"]])
283
return MagicMock(result_set=[])
284
285
store._graph.query.side_effect = _side_effect
286
287
with patch.object(enricher, "_promote_node") as mock_promote:
288
enricher.enrich()
289
290
types = {c[0][2] for c in mock_promote.call_args_list}
291
assert "PydanticModel" in types
292
293
def test_pydantic_model_fallback_via_docstring(self):
294
"""If no INHERITS edge, docstring containing 'BaseModel' is the fallback."""
295
store = _mock_store()
296
enricher = FastAPIEnricher(store)
297
298
call_count = [0]
299
300
def _side_effect(cypher, params=None):
301
call_count[0] += 1
302
params = params or {}
303
# Fail INHERITS query, succeed on docstring fallback
304
if params.get("base_name") == "BaseModel":
305
return MagicMock(result_set=[])
306
if "BaseModel" in params.get("pattern", "") and "docstring" in cypher:
307
return MagicMock(result_set=[["CreateUser", "app/schemas.py"]])
308
return MagicMock(result_set=[])
309
310
store._graph.query.side_effect = _side_effect
311
312
with patch.object(enricher, "_promote_node") as mock_promote:
313
enricher.enrich()
314
315
pm_calls = [c for c in mock_promote.call_args_list if c[0][2] == "PydanticModel"]
316
assert len(pm_calls) >= 1
317
318
319
# ── Background Tasks ──────────────────────────────────────────────────────────
320
321
322
class TestFastAPIEnricherBackgroundTasks:
323
def test_background_task_promoted_via_on_event_decorator(self):
324
store = _mock_store()
325
enricher = FastAPIEnricher(store)
326
327
def _side_effect(cypher, params=None):
328
params = params or {}
329
if "on_event" in params.get("pattern", ""):
330
return MagicMock(result_set=[["startup_handler", "app/events.py"]])
331
return MagicMock(result_set=[])
332
333
store._graph.query.side_effect = _side_effect
334
335
with patch.object(enricher, "_promote_node") as mock_promote:
336
enricher.enrich()
337
338
bt_calls = [c for c in mock_promote.call_args_list if c[0][2] == "BackgroundTask"]
339
assert len(bt_calls) >= 1
340
341
def test_background_task_promoted_via_background_tasks_in_signature(self):
342
store = _mock_store()
343
enricher = FastAPIEnricher(store)
344
345
def _side_effect(cypher, params=None):
346
params = params or {}
347
if "BackgroundTasks" in params.get("pattern", ""):
348
return MagicMock(result_set=[["send_email", "app/tasks.py"]])
349
return MagicMock(result_set=[])
350
351
store._graph.query.side_effect = _side_effect
352
353
with patch.object(enricher, "_promote_node") as mock_promote:
354
enricher.enrich()
355
356
bt_calls = [c for c in mock_promote.call_args_list if c[0][2] == "BackgroundTask"]
357
assert len(bt_calls) >= 1
358
359
def test_background_task_semantic_type_string(self):
360
store = _mock_store()
361
enricher = FastAPIEnricher(store)
362
363
def _side_effect(cypher, params=None):
364
params = params or {}
365
if "on_event" in params.get("pattern", ""):
366
return MagicMock(result_set=[["shutdown_handler", "app/main.py"]])
367
return MagicMock(result_set=[])
368
369
store._graph.query.side_effect = _side_effect
370
371
with patch.object(enricher, "_promote_node") as mock_promote:
372
enricher.enrich()
373
374
types = {c[0][2] for c in mock_promote.call_args_list}
375
assert "BackgroundTask" in types
376
377
378
# ── _promote_node integration ─────────────────────────────────────────────────
379
380
381
class TestFastAPIEnricherPromoteNodeIntegration:
382
def test_promote_node_called_for_each_matched_row(self):
383
"""Two rows returned → two _promote_node calls for that pattern."""
384
store = _mock_store()
385
enricher = FastAPIEnricher(store)
386
387
call_count = [0]
388
389
def _side_effect(cypher, params=None):
390
call_count[0] += 1
391
if call_count[0] == 1:
392
return MagicMock(result_set=[
393
["route_a", "app/a.py"],
394
["route_b", "app/b.py"],
395
])
396
return MagicMock(result_set=[])
397
398
store._graph.query.side_effect = _side_effect
399
400
with patch.object(enricher, "_promote_node") as mock_promote:
401
enricher.enrich()
402
403
route_calls = [c for c in mock_promote.call_args_list if c[0][2] == "Route"]
404
assert len(route_calls) == 2
405
406
def test_rows_with_none_name_are_skipped(self):
407
"""Rows where name is None must not call _promote_node."""
408
store = _mock_store()
409
enricher = FastAPIEnricher(store)
410
411
call_count = [0]
412
413
def _side_effect(cypher, params=None):
414
call_count[0] += 1
415
if call_count[0] == 1:
416
return MagicMock(result_set=[[None, "app/main.py"]])
417
return MagicMock(result_set=[])
418
419
store._graph.query.side_effect = _side_effect
420
421
with patch.object(enricher, "_promote_node") as mock_promote:
422
enricher.enrich()
423
424
route_calls = [c for c in mock_promote.call_args_list if c[0][2] == "Route"]
425
assert len(route_calls) == 0
426
427
def test_rows_with_none_file_path_are_skipped(self):
428
"""Rows where file_path is None must not call _promote_node."""
429
store = _mock_store()
430
enricher = FastAPIEnricher(store)
431
432
call_count = [0]
433
434
def _side_effect(cypher, params=None):
435
call_count[0] += 1
436
if call_count[0] == 1:
437
return MagicMock(result_set=[["get_items", None]])
438
return MagicMock(result_set=[])
439
440
store._graph.query.side_effect = _side_effect
441
442
with patch.object(enricher, "_promote_node") as mock_promote:
443
enricher.enrich()
444
445
route_calls = [c for c in mock_promote.call_args_list if c[0][2] == "Route"]
446
assert len(route_calls) == 0
447
448
449
# ── HTTP method coverage ──────────────────────────────────────────────────────
450
451
452
class TestFastAPIEnricherHTTPMethods:
453
def test_all_http_methods_are_queried(self):
454
"""The enricher must issue queries for all standard HTTP verbs."""
455
store = _mock_store(result_set=[])
456
FastAPIEnricher(store).enrich()
457
458
all_params = [
459
call_args[0][1] if len(call_args[0]) > 1 else {}
460
for call_args in store._graph.query.call_args_list
461
]
462
patterns_used = {p.get("pattern", "") for p in all_params}
463
464
for method in ("get", "post", "put", "delete", "patch"):
465
assert any(method in pat for pat in patterns_used), (
466
f"HTTP method '{method}' not found in any query pattern"
467
)
468

Keyboard Shortcuts

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