Skip to content

Commit 4c1bdd1

Browse files
Merge pull request #85 from GoodbyePlanet/test/tools-and-index-service-coverage-78
test: add coverage for server/tools/* and index_service pipeline flow
2 parents 5856895 + 37e0dfa commit 4c1bdd1

7 files changed

Lines changed: 918 additions & 0 deletions

File tree

tests/test_pipeline.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,213 @@ async def test_parser_failure_preserves_existing_index_entries() -> None:
180180
store.delete_by_file.assert_not_called()
181181
store.upsert_chunks.assert_not_called()
182182
assert result["files"] == 0
183+
184+
185+
async def test_unchanged_file_is_skipped_without_fetching_content() -> None:
186+
svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[])
187+
github_file = GitHubFile(rel_path="unchanged.py", blob_sha="same-sha")
188+
189+
store = AsyncMock()
190+
store.get_indexed_file_hashes.return_value = {"svc/unchanged.py": "same-sha"}
191+
192+
pipeline = _make_pipeline(store)
193+
194+
with (
195+
patch.object(
196+
type(pipeline_module.settings), "load_services", return_value=[svc]
197+
),
198+
patch.object(
199+
pipeline_module, "list_github_files", AsyncMock(return_value=[github_file])
200+
),
201+
patch.object(pipeline_module, "fetch_blob_content", AsyncMock()) as mock_fetch,
202+
):
203+
result = await pipeline.index_service("svc", force=False)
204+
205+
mock_fetch.assert_not_called()
206+
store.delete_by_file.assert_not_called()
207+
assert result == {"files": 0, "chunks": 0, "skipped": 1}
208+
209+
210+
async def test_force_reindexes_even_when_hash_unchanged() -> None:
211+
svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[])
212+
github_file = GitHubFile(rel_path="unchanged.py", blob_sha="same-sha")
213+
symbol = CodeSymbol(
214+
name="fn",
215+
symbol_type="function",
216+
language="python",
217+
source="def fn(): pass",
218+
file_path="svc/unchanged.py",
219+
start_line=1,
220+
end_line=1,
221+
)
222+
223+
store = AsyncMock()
224+
store.get_indexed_file_hashes.return_value = {"svc/unchanged.py": "same-sha"}
225+
226+
pipeline = _make_pipeline(store)
227+
228+
with (
229+
patch.object(
230+
type(pipeline_module.settings), "load_services", return_value=[svc]
231+
),
232+
patch.object(
233+
pipeline_module, "list_github_files", AsyncMock(return_value=[github_file])
234+
),
235+
patch.object(
236+
pipeline_module,
237+
"fetch_blob_content",
238+
AsyncMock(return_value=b"def fn(): pass"),
239+
),
240+
patch.object(pipeline_module, "parse_file", return_value=[symbol]),
241+
):
242+
result = await pipeline.index_service("svc", force=True)
243+
244+
store.delete_by_file.assert_called_once_with("svc", "svc/unchanged.py")
245+
store.upsert_chunks.assert_called_once()
246+
assert result == {"files": 1, "chunks": 1, "skipped": 0}
247+
248+
249+
async def test_stale_index_entries_removed_when_file_no_longer_in_repo() -> None:
250+
svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[])
251+
252+
store = AsyncMock()
253+
store.get_indexed_file_hashes.return_value = {
254+
"svc/deleted.py": "old-sha",
255+
"svc/still-there.py": "sha",
256+
}
257+
258+
pipeline = _make_pipeline(store)
259+
260+
with (
261+
patch.object(
262+
type(pipeline_module.settings), "load_services", return_value=[svc]
263+
),
264+
patch.object(
265+
pipeline_module,
266+
"list_github_files",
267+
AsyncMock(
268+
return_value=[GitHubFile(rel_path="still-there.py", blob_sha="sha")]
269+
),
270+
),
271+
):
272+
result = await pipeline.index_service("svc", force=False)
273+
274+
store.delete_by_file.assert_called_once_with("svc", "svc/deleted.py")
275+
assert result["skipped"] == 1
276+
277+
278+
async def test_file_with_no_symbols_deletes_stale_entries_and_skips_upsert() -> None:
279+
svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[])
280+
github_file = GitHubFile(rel_path="empty.py", blob_sha="sha1")
281+
282+
store = AsyncMock()
283+
store.get_indexed_file_hashes.return_value = {}
284+
285+
pipeline = _make_pipeline(store)
286+
287+
with (
288+
patch.object(
289+
type(pipeline_module.settings), "load_services", return_value=[svc]
290+
),
291+
patch.object(
292+
pipeline_module, "list_github_files", AsyncMock(return_value=[github_file])
293+
),
294+
patch.object(
295+
pipeline_module, "fetch_blob_content", AsyncMock(return_value=b"")
296+
),
297+
patch.object(pipeline_module, "parse_file", return_value=[]),
298+
):
299+
result = await pipeline.index_service("svc", force=True)
300+
301+
store.delete_by_file.assert_called_once_with("svc", "svc/empty.py")
302+
store.upsert_chunks.assert_not_called()
303+
assert result["files"] == 0
304+
305+
306+
async def test_embedding_failure_preserves_existing_index_entries() -> None:
307+
svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[])
308+
github_file = GitHubFile(rel_path="broken.py", blob_sha="sha1")
309+
symbol = CodeSymbol(
310+
name="fn",
311+
symbol_type="function",
312+
language="python",
313+
source="def fn(): pass",
314+
file_path="svc/broken.py",
315+
start_line=1,
316+
end_line=1,
317+
)
318+
319+
store = AsyncMock()
320+
store.get_indexed_file_hashes.return_value = {"svc/broken.py": "old-sha"}
321+
322+
pipeline = _make_pipeline(store)
323+
pipeline._embedder.embed_batch = AsyncMock(side_effect=RuntimeError("503"))
324+
325+
with (
326+
patch.object(
327+
type(pipeline_module.settings), "load_services", return_value=[svc]
328+
),
329+
patch.object(
330+
pipeline_module, "list_github_files", AsyncMock(return_value=[github_file])
331+
),
332+
patch.object(
333+
pipeline_module,
334+
"fetch_blob_content",
335+
AsyncMock(return_value=b"def fn(): pass"),
336+
),
337+
patch.object(pipeline_module, "parse_file", return_value=[symbol]),
338+
):
339+
result = await pipeline.index_service("svc", force=True)
340+
341+
store.delete_by_file.assert_not_called()
342+
store.upsert_chunks.assert_not_called()
343+
assert result["files"] == 0
344+
345+
346+
async def test_delete_by_file_called_before_upsert_for_changed_file() -> None:
347+
svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[])
348+
github_file = GitHubFile(rel_path="mod.py", blob_sha="new-sha")
349+
symbol = CodeSymbol(
350+
name="fn",
351+
symbol_type="function",
352+
language="python",
353+
source="def fn(): pass",
354+
file_path="svc/mod.py",
355+
start_line=1,
356+
end_line=1,
357+
)
358+
359+
call_order: list[str] = []
360+
store = AsyncMock()
361+
store.get_indexed_file_hashes.return_value = {"svc/mod.py": "old-sha"}
362+
store.delete_by_file.side_effect = lambda *a, **k: call_order.append("delete")
363+
store.upsert_chunks.side_effect = lambda *a, **k: call_order.append("upsert")
364+
365+
pipeline = _make_pipeline(store)
366+
367+
with (
368+
patch.object(
369+
type(pipeline_module.settings), "load_services", return_value=[svc]
370+
),
371+
patch.object(
372+
pipeline_module, "list_github_files", AsyncMock(return_value=[github_file])
373+
),
374+
patch.object(
375+
pipeline_module,
376+
"fetch_blob_content",
377+
AsyncMock(return_value=b"def fn(): pass"),
378+
),
379+
patch.object(pipeline_module, "parse_file", return_value=[symbol]),
380+
):
381+
await pipeline.index_service("svc", force=False)
382+
383+
assert call_order == ["delete", "upsert"]
384+
385+
386+
async def test_unknown_service_returns_error_without_touching_github() -> None:
387+
pipeline = _make_pipeline(AsyncMock())
388+
389+
with patch.object(type(pipeline_module.settings), "load_services", return_value=[]):
390+
result = await pipeline.index_service("missing")
391+
392+
assert result == {"error": 1, "files": 0, "chunks": 0}

tests/tools/__init__.py

Whitespace-only changes.

tests/tools/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
from dataclasses import dataclass, field
5+
from typing import Any
6+
7+
from mcp.server.fastmcp import FastMCP
8+
9+
10+
def get_tool(register: Callable[[FastMCP], None], name: str) -> Callable:
11+
"""Register a tool module against a throwaway FastMCP instance and return
12+
the underlying async function, bypassing MCP's request/response plumbing."""
13+
mcp = FastMCP("test")
14+
register(mcp)
15+
return mcp._tool_manager._tools[name].fn
16+
17+
18+
@dataclass
19+
class StubHit:
20+
payload: dict[str, Any] = field(default_factory=dict)
21+
score: float = 1.0

tests/tools/test_admin.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import AsyncMock, patch
4+
5+
from server.config import ServiceConfig
6+
from server.tools.admin import register_admin_tools
7+
from tests.tools.conftest import get_tool
8+
9+
10+
def _tool(name: str):
11+
return get_tool(register_admin_tools, name)
12+
13+
14+
async def test_list_indexed_services_reports_when_empty() -> None:
15+
list_indexed_services = _tool("list_indexed_services")
16+
store = AsyncMock()
17+
store.get_service_stats.return_value = []
18+
19+
with patch("server.tools.admin.get_store", return_value=store):
20+
result = await list_indexed_services()
21+
22+
assert "No services indexed yet" in result
23+
24+
25+
async def test_list_indexed_services_formats_stats_sorted_by_name() -> None:
26+
list_indexed_services = _tool("list_indexed_services")
27+
store = AsyncMock()
28+
store.get_service_stats.return_value = [
29+
{
30+
"service": "zebra",
31+
"chunk_count": 5,
32+
"file_count": 2,
33+
"languages": ["python"],
34+
"last_indexed": "2026-01-01",
35+
},
36+
{
37+
"service": "alpha",
38+
"chunk_count": 10,
39+
"file_count": 4,
40+
"languages": ["java", "python"],
41+
"last_indexed": "2026-01-02",
42+
},
43+
]
44+
45+
with patch("server.tools.admin.get_store", return_value=store):
46+
result = await list_indexed_services()
47+
48+
assert result.index("alpha") < result.index("zebra")
49+
assert "Chunks: 10" in result
50+
51+
52+
async def test_index_stats_reports_qdrant_error() -> None:
53+
index_stats = _tool("index_stats")
54+
store = AsyncMock()
55+
store.collection_info.side_effect = RuntimeError("connection refused")
56+
57+
with (
58+
patch("server.tools.admin.get_store", return_value=store),
59+
patch("server.tools.admin.settings") as mock_settings,
60+
):
61+
mock_settings.load_services.return_value = []
62+
result = await index_stats()
63+
64+
assert "Could not reach Qdrant" in result
65+
assert "connection refused" in result
66+
67+
68+
async def test_index_stats_includes_provider_endpoint() -> None:
69+
index_stats = _tool("index_stats")
70+
store = AsyncMock()
71+
store.collection_info.return_value = {
72+
"collection": "code_symbols",
73+
"total_vectors": 100,
74+
"vector_size": 768,
75+
"status": "green",
76+
}
77+
svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[])
78+
79+
with (
80+
patch("server.tools.admin.get_store", return_value=store),
81+
patch("server.tools.admin.settings") as mock_settings,
82+
):
83+
mock_settings.load_services.return_value = [svc]
84+
mock_settings.embeddings_provider = "voyage"
85+
result = await index_stats()
86+
87+
assert "**Embeddings provider**: voyage" in result
88+
assert "https://api.voyageai.com/v1/embeddings" in result
89+
assert "`orders` — `org/orders@main`" in result

0 commit comments

Comments
 (0)