diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index e3ebd84..e61d82a 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -180,3 +180,213 @@ async def test_parser_failure_preserves_existing_index_entries() -> None: store.delete_by_file.assert_not_called() store.upsert_chunks.assert_not_called() assert result["files"] == 0 + + +async def test_unchanged_file_is_skipped_without_fetching_content() -> None: + svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[]) + github_file = GitHubFile(rel_path="unchanged.py", blob_sha="same-sha") + + store = AsyncMock() + store.get_indexed_file_hashes.return_value = {"svc/unchanged.py": "same-sha"} + + pipeline = _make_pipeline(store) + + with ( + patch.object( + type(pipeline_module.settings), "load_services", return_value=[svc] + ), + patch.object( + pipeline_module, "list_github_files", AsyncMock(return_value=[github_file]) + ), + patch.object(pipeline_module, "fetch_blob_content", AsyncMock()) as mock_fetch, + ): + result = await pipeline.index_service("svc", force=False) + + mock_fetch.assert_not_called() + store.delete_by_file.assert_not_called() + assert result == {"files": 0, "chunks": 0, "skipped": 1} + + +async def test_force_reindexes_even_when_hash_unchanged() -> None: + svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[]) + github_file = GitHubFile(rel_path="unchanged.py", blob_sha="same-sha") + symbol = CodeSymbol( + name="fn", + symbol_type="function", + language="python", + source="def fn(): pass", + file_path="svc/unchanged.py", + start_line=1, + end_line=1, + ) + + store = AsyncMock() + store.get_indexed_file_hashes.return_value = {"svc/unchanged.py": "same-sha"} + + pipeline = _make_pipeline(store) + + with ( + patch.object( + type(pipeline_module.settings), "load_services", return_value=[svc] + ), + patch.object( + pipeline_module, "list_github_files", AsyncMock(return_value=[github_file]) + ), + patch.object( + pipeline_module, + "fetch_blob_content", + AsyncMock(return_value=b"def fn(): pass"), + ), + patch.object(pipeline_module, "parse_file", return_value=[symbol]), + ): + result = await pipeline.index_service("svc", force=True) + + store.delete_by_file.assert_called_once_with("svc", "svc/unchanged.py") + store.upsert_chunks.assert_called_once() + assert result == {"files": 1, "chunks": 1, "skipped": 0} + + +async def test_stale_index_entries_removed_when_file_no_longer_in_repo() -> None: + svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[]) + + store = AsyncMock() + store.get_indexed_file_hashes.return_value = { + "svc/deleted.py": "old-sha", + "svc/still-there.py": "sha", + } + + pipeline = _make_pipeline(store) + + with ( + patch.object( + type(pipeline_module.settings), "load_services", return_value=[svc] + ), + patch.object( + pipeline_module, + "list_github_files", + AsyncMock( + return_value=[GitHubFile(rel_path="still-there.py", blob_sha="sha")] + ), + ), + ): + result = await pipeline.index_service("svc", force=False) + + store.delete_by_file.assert_called_once_with("svc", "svc/deleted.py") + assert result["skipped"] == 1 + + +async def test_file_with_no_symbols_deletes_stale_entries_and_skips_upsert() -> None: + svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[]) + github_file = GitHubFile(rel_path="empty.py", blob_sha="sha1") + + store = AsyncMock() + store.get_indexed_file_hashes.return_value = {} + + pipeline = _make_pipeline(store) + + with ( + patch.object( + type(pipeline_module.settings), "load_services", return_value=[svc] + ), + patch.object( + pipeline_module, "list_github_files", AsyncMock(return_value=[github_file]) + ), + patch.object( + pipeline_module, "fetch_blob_content", AsyncMock(return_value=b"") + ), + patch.object(pipeline_module, "parse_file", return_value=[]), + ): + result = await pipeline.index_service("svc", force=True) + + store.delete_by_file.assert_called_once_with("svc", "svc/empty.py") + store.upsert_chunks.assert_not_called() + assert result["files"] == 0 + + +async def test_embedding_failure_preserves_existing_index_entries() -> None: + svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[]) + github_file = GitHubFile(rel_path="broken.py", blob_sha="sha1") + symbol = CodeSymbol( + name="fn", + symbol_type="function", + language="python", + source="def fn(): pass", + file_path="svc/broken.py", + start_line=1, + end_line=1, + ) + + store = AsyncMock() + store.get_indexed_file_hashes.return_value = {"svc/broken.py": "old-sha"} + + pipeline = _make_pipeline(store) + pipeline._embedder.embed_batch = AsyncMock(side_effect=RuntimeError("503")) + + with ( + patch.object( + type(pipeline_module.settings), "load_services", return_value=[svc] + ), + patch.object( + pipeline_module, "list_github_files", AsyncMock(return_value=[github_file]) + ), + patch.object( + pipeline_module, + "fetch_blob_content", + AsyncMock(return_value=b"def fn(): pass"), + ), + patch.object(pipeline_module, "parse_file", return_value=[symbol]), + ): + result = await pipeline.index_service("svc", force=True) + + store.delete_by_file.assert_not_called() + store.upsert_chunks.assert_not_called() + assert result["files"] == 0 + + +async def test_delete_by_file_called_before_upsert_for_changed_file() -> None: + svc = ServiceConfig(name="svc", github_repo="org/repo", exclude=[]) + github_file = GitHubFile(rel_path="mod.py", blob_sha="new-sha") + symbol = CodeSymbol( + name="fn", + symbol_type="function", + language="python", + source="def fn(): pass", + file_path="svc/mod.py", + start_line=1, + end_line=1, + ) + + call_order: list[str] = [] + store = AsyncMock() + store.get_indexed_file_hashes.return_value = {"svc/mod.py": "old-sha"} + store.delete_by_file.side_effect = lambda *a, **k: call_order.append("delete") + store.upsert_chunks.side_effect = lambda *a, **k: call_order.append("upsert") + + pipeline = _make_pipeline(store) + + with ( + patch.object( + type(pipeline_module.settings), "load_services", return_value=[svc] + ), + patch.object( + pipeline_module, "list_github_files", AsyncMock(return_value=[github_file]) + ), + patch.object( + pipeline_module, + "fetch_blob_content", + AsyncMock(return_value=b"def fn(): pass"), + ), + patch.object(pipeline_module, "parse_file", return_value=[symbol]), + ): + await pipeline.index_service("svc", force=False) + + assert call_order == ["delete", "upsert"] + + +async def test_unknown_service_returns_error_without_touching_github() -> None: + pipeline = _make_pipeline(AsyncMock()) + + with patch.object(type(pipeline_module.settings), "load_services", return_value=[]): + result = await pipeline.index_service("missing") + + assert result == {"error": 1, "files": 0, "chunks": 0} diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py new file mode 100644 index 0000000..459c05e --- /dev/null +++ b/tests/tools/conftest.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from mcp.server.fastmcp import FastMCP + + +def get_tool(register: Callable[[FastMCP], None], name: str) -> Callable: + """Register a tool module against a throwaway FastMCP instance and return + the underlying async function, bypassing MCP's request/response plumbing.""" + mcp = FastMCP("test") + register(mcp) + return mcp._tool_manager._tools[name].fn + + +@dataclass +class StubHit: + payload: dict[str, Any] = field(default_factory=dict) + score: float = 1.0 diff --git a/tests/tools/test_admin.py b/tests/tools/test_admin.py new file mode 100644 index 0000000..3ca142b --- /dev/null +++ b/tests/tools/test_admin.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from server.config import ServiceConfig +from server.tools.admin import register_admin_tools +from tests.tools.conftest import get_tool + + +def _tool(name: str): + return get_tool(register_admin_tools, name) + + +async def test_list_indexed_services_reports_when_empty() -> None: + list_indexed_services = _tool("list_indexed_services") + store = AsyncMock() + store.get_service_stats.return_value = [] + + with patch("server.tools.admin.get_store", return_value=store): + result = await list_indexed_services() + + assert "No services indexed yet" in result + + +async def test_list_indexed_services_formats_stats_sorted_by_name() -> None: + list_indexed_services = _tool("list_indexed_services") + store = AsyncMock() + store.get_service_stats.return_value = [ + { + "service": "zebra", + "chunk_count": 5, + "file_count": 2, + "languages": ["python"], + "last_indexed": "2026-01-01", + }, + { + "service": "alpha", + "chunk_count": 10, + "file_count": 4, + "languages": ["java", "python"], + "last_indexed": "2026-01-02", + }, + ] + + with patch("server.tools.admin.get_store", return_value=store): + result = await list_indexed_services() + + assert result.index("alpha") < result.index("zebra") + assert "Chunks: 10" in result + + +async def test_index_stats_reports_qdrant_error() -> None: + index_stats = _tool("index_stats") + store = AsyncMock() + store.collection_info.side_effect = RuntimeError("connection refused") + + with ( + patch("server.tools.admin.get_store", return_value=store), + patch("server.tools.admin.settings") as mock_settings, + ): + mock_settings.load_services.return_value = [] + result = await index_stats() + + assert "Could not reach Qdrant" in result + assert "connection refused" in result + + +async def test_index_stats_includes_provider_endpoint() -> None: + index_stats = _tool("index_stats") + store = AsyncMock() + store.collection_info.return_value = { + "collection": "code_symbols", + "total_vectors": 100, + "vector_size": 768, + "status": "green", + } + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.admin.get_store", return_value=store), + patch("server.tools.admin.settings") as mock_settings, + ): + mock_settings.load_services.return_value = [svc] + mock_settings.embeddings_provider = "voyage" + result = await index_stats() + + assert "**Embeddings provider**: voyage" in result + assert "https://api.voyageai.com/v1/embeddings" in result + assert "`orders` — `org/orders@main`" in result diff --git a/tests/tools/test_history.py b/tests/tools/test_history.py new file mode 100644 index 0000000..1122aa4 --- /dev/null +++ b/tests/tools/test_history.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from server.tools.history import register_history_tools +from tests.tools.conftest import StubHit, get_tool + + +def _tool(name: str): + return get_tool(register_history_tools, name) + + +async def test_search_commits_reports_no_results() -> None: + search_commits = _tool("search_commits") + store = AsyncMock() + store.search.return_value = [] + + with ( + patch("server.tools.history.get_embedding_provider") as mock_embedder, + patch("server.tools.history.get_commit_store", return_value=store), + ): + mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1]) + result = await search_commits("fix the bug") + + assert result == "No commits found." + + +async def test_search_commits_formats_hits() -> None: + search_commits = _tool("search_commits") + hit = StubHit( + payload={ + "sha": "abcdef1234567890", + "service": "orders", + "author_name": "Ana", + "committed_at": "2026-01-01T00:00:00Z", + "message": "Fix order total calculation", + }, + score=0.5, + ) + store = AsyncMock() + store.search.return_value = [hit] + + with ( + patch("server.tools.history.get_embedding_provider") as mock_embedder, + patch("server.tools.history.get_commit_store", return_value=store), + ): + mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1]) + result = await search_commits("fix order total") + + assert "`abcdef12`" in result + assert "Fix order total calculation" in result + + +async def test_get_commit_not_found() -> None: + get_commit = _tool("get_commit") + store = AsyncMock() + store.get_commit_by_sha.return_value = None + + with patch("server.tools.history.get_commit_store", return_value=store): + result = await get_commit("deadbeef") + + assert "not found in index" in result + + +async def test_get_commit_reports_no_diff_data() -> None: + get_commit = _tool("get_commit") + store = AsyncMock() + store.get_commit_by_sha.return_value = { + "sha": "deadbeef" * 5, + "service": "orders", + "author_name": "Ana", + "author_email": "ana@example.com", + "committed_at": "2026-01-01", + "message": "Initial commit", + "files": [], + } + + with patch("server.tools.history.get_commit_store", return_value=store): + result = await get_commit("deadbeef") + + assert "No diff data available" in result + + +async def test_get_commit_formats_changed_files_and_truncation() -> None: + get_commit = _tool("get_commit") + store = AsyncMock() + store.get_commit_by_sha.return_value = { + "sha": "deadbeef" * 5, + "service": "orders", + "author_name": "Ana", + "author_email": "ana@example.com", + "committed_at": "2026-01-01", + "message": "Refactor order flow", + "files": [ + { + "filename": "Order.java", + "status": "modified", + "additions": 10, + "deletions": 2, + "patch": "@@ -1 +1 @@", + } + ], + "diff_truncated": True, + } + + with patch("server.tools.history.get_commit_store", return_value=store): + result = await get_commit("deadbeef") + + assert "Order.java" in result + assert "+10 -2" in result + assert "Diff truncated" in result + + +async def test_index_history_reports_unknown_service() -> None: + index_history = _tool("index_history") + pipeline = AsyncMock() + pipeline.index_service.return_value = {"error": 1} + + with ( + patch("server.tools.history.get_commit_store", return_value=AsyncMock()), + patch("server.tools.history.GitHistoryPipeline", return_value=pipeline), + ): + result = await index_history(service="unknown") + + assert result == "Service `unknown` not found in config.yaml." + + +async def test_index_history_single_service_reports_diff_updates() -> None: + index_history = _tool("index_history") + pipeline = AsyncMock() + pipeline.index_service.return_value = {"new": 3, "skipped": 1, "diff_updated": 2} + + with ( + patch("server.tools.history.get_commit_store", return_value=AsyncMock()), + patch("server.tools.history.GitHistoryPipeline", return_value=pipeline), + ): + result = await index_history(service="orders") + + assert "New commits: 3" in result + assert "Skipped (already indexed): 1" in result + assert "Diffs fetched for existing commits: 2" in result + + +async def test_index_history_all_services_aggregates_new_commits() -> None: + index_history = _tool("index_history") + pipeline = AsyncMock() + pipeline.index_all.return_value = { + "orders": {"new": 2, "skipped": 0}, + "catalog": {"new": 5, "skipped": 1, "diff_updated": 1}, + } + + with ( + patch("server.tools.history.get_commit_store", return_value=AsyncMock()), + patch("server.tools.history.GitHistoryPipeline", return_value=pipeline), + ): + result = await index_history() + + assert "**orders**: 2 new commits (0 skipped)" in result + assert "**catalog**: 5 new commits (1 skipped), 1 diffs updated" in result + assert "**Total**: 7 new commits" in result diff --git a/tests/tools/test_index.py b/tests/tools/test_index.py new file mode 100644 index 0000000..376ce2b --- /dev/null +++ b/tests/tools/test_index.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from server.tools.index import register_index_tools +from tests.tools.conftest import get_tool + + +def _tool(name: str): + return get_tool(register_index_tools, name) + + +async def test_reindex_reports_unknown_service() -> None: + reindex = _tool("reindex") + pipeline = AsyncMock() + pipeline.index_service.return_value = {"error": 1, "files": 0, "chunks": 0} + + with ( + patch("server.tools.index.get_store", return_value=AsyncMock()), + patch("server.tools.index.IndexPipeline", return_value=pipeline), + ): + result = await reindex(service="unknown") + + assert result == "Service `unknown` not found in config.yaml." + + +async def test_reindex_single_service_reports_counts() -> None: + reindex = _tool("reindex") + pipeline = AsyncMock() + pipeline.index_service.return_value = {"files": 3, "chunks": 12, "skipped": 5} + + with ( + patch("server.tools.index.get_store", return_value=AsyncMock()), + patch("server.tools.index.IndexPipeline", return_value=pipeline), + ): + result = await reindex(service="orders", force=True) + + pipeline.index_service.assert_awaited_once_with("orders", force=True) + assert "Files indexed: 3" in result + assert "Chunks created: 12" in result + assert "skipped (unchanged): 5" in result + + +async def test_reindex_all_services_aggregates_totals() -> None: + reindex = _tool("reindex") + pipeline = AsyncMock() + pipeline.index_all.return_value = { + "orders": {"files": 2, "chunks": 8, "skipped": 1}, + "catalog": {"files": 3, "chunks": 6, "skipped": 0}, + } + + with ( + patch("server.tools.index.get_store", return_value=AsyncMock()), + patch("server.tools.index.IndexPipeline", return_value=pipeline), + ): + result = await reindex() + + pipeline.index_all.assert_awaited_once_with(force=False) + assert "**orders**: 2 files, 8 chunks (1 skipped)" in result + assert "**catalog**: 3 files, 6 chunks (0 skipped)" in result + assert "**Total**: 5 files, 14 chunks" in result diff --git a/tests/tools/test_search.py b/tests/tools/test_search.py new file mode 100644 index 0000000..2a2bda6 --- /dev/null +++ b/tests/tools/test_search.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import httpx + +from server.config import ServiceConfig +from tests.tools.conftest import StubHit, get_tool + +from server.tools.search import register_search_tools + + +def _tool(name: str): + return get_tool(register_search_tools, name) + + +async def test_search_code_reports_no_results() -> None: + search_code = _tool("search_code") + store = AsyncMock() + store.search.return_value = [] + + with ( + patch("server.tools.search.get_embedding_provider") as mock_embedder, + patch("server.tools.search.get_sparse_provider") as mock_sparse, + patch("server.tools.search.get_store", return_value=store), + ): + mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1]) + mock_sparse.return_value.embed_query = AsyncMock(return_value={}) + result = await search_code("find the order service") + + assert result == "No results found." + + +async def test_search_code_formats_hits() -> None: + search_code = _tool("search_code") + hit = StubHit( + payload={ + "symbol_name": "PlaceOrder", + "symbol_type": "method", + "file_path": "orders/Order.java", + "start_line": 10, + "end_line": 20, + "service": "orders", + "language": "java", + "annotations": ["Transactional"], + "http_method": "POST", + "http_route": "/orders", + "signature": "void placeOrder()", + }, + score=0.87, + ) + store = AsyncMock() + store.search.return_value = [hit] + + with ( + patch("server.tools.search.get_embedding_provider") as mock_embedder, + patch("server.tools.search.get_sparse_provider") as mock_sparse, + patch("server.tools.search.get_store", return_value=store), + ): + mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1]) + mock_sparse.return_value.embed_query = AsyncMock(return_value={}) + result = await search_code("place an order") + + assert "PlaceOrder" in result + assert "orders/Order.java:10-20" in result + assert "POST /orders" in result + assert "Transactional" in result + assert "0.870" in result + + +async def test_find_symbol_reports_no_match() -> None: + find_symbol = _tool("find_symbol") + store = AsyncMock() + store.find_by_name.return_value = [] + + with patch("server.tools.search.get_store", return_value=store): + result = await find_symbol("Nope") + + assert result == "No symbol found matching `Nope`." + + +async def test_find_symbol_formats_match_with_parent() -> None: + find_symbol = _tool("find_symbol") + hit = StubHit( + payload={ + "symbol_name": "placeOrder", + "symbol_type": "method", + "file_path": "orders/OrderService.java", + "start_line": 5, + "end_line": 9, + "service": "orders", + "package": "com.example.orders", + "parent_name": "OrderService", + "language": "java", + "source": "void placeOrder() {}", + } + ) + store = AsyncMock() + store.find_by_name.return_value = [hit] + + with patch("server.tools.search.get_store", return_value=store): + result = await find_symbol("placeOrder", exact=True) + + assert "`placeOrder`" in result + assert "**Parent**: `OrderService`" in result + store.find_by_name.assert_awaited_once_with( + name="placeOrder", symbol_type=None, service=None, exact=True + ) + + +async def test_find_usages_excludes_hits_matching_symbol_itself() -> None: + find_usages = _tool("find_usages") + self_hit = StubHit(payload={"symbol_name": "OrderService", "source": ""}) + caller_hit = StubHit( + payload={ + "symbol_name": "placeOrder", + "file_path": "orders/Controller.java", + "start_line": 1, + "service": "orders", + "source": "OrderService svc = new OrderService();", + "language": "java", + } + ) + store = AsyncMock() + store.search.return_value = [self_hit, caller_hit] + + with ( + patch("server.tools.search.get_embedding_provider") as mock_embedder, + patch("server.tools.search.get_sparse_provider") as mock_sparse, + patch("server.tools.search.get_store", return_value=store), + ): + mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1]) + mock_sparse.return_value.embed_query = AsyncMock(return_value={}) + result = await find_usages("OrderService") + + assert "Found 1 usage(s)" in result + assert "Controller.java" in result + + +async def test_find_usages_can_undercount_when_all_fetched_hits_are_self_matches() -> ( + None +): + # Known limitation: `limit` bounds the initial fetch from the store, and + # self-matches are filtered out *after* that fetch rather than being + # excluded from the query itself. If every fetched hit is a self-match, + # zero usages are reported even though usages may exist beyond the + # fetched window. + find_usages = _tool("find_usages") + store = AsyncMock() + store.search.return_value = [ + StubHit(payload={"symbol_name": "OrderService", "source": ""}) + ] + + with ( + patch("server.tools.search.get_embedding_provider") as mock_embedder, + patch("server.tools.search.get_sparse_provider") as mock_sparse, + patch("server.tools.search.get_store", return_value=store), + ): + mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1]) + mock_sparse.return_value.embed_query = AsyncMock(return_value={}) + result = await find_usages("OrderService", limit=1) + + assert result == "No usages of `OrderService` found." + + +async def test_find_usages_snippet_windows_around_match() -> None: + find_usages = _tool("find_usages") + source = ("x" * 150) + "OrderService" + ("y" * 250) + hit = StubHit( + payload={ + "symbol_name": "placeOrder", + "file_path": "orders/Controller.java", + "start_line": 1, + "service": "orders", + "source": source, + "language": "java", + } + ) + store = AsyncMock() + store.search.return_value = [hit] + + with ( + patch("server.tools.search.get_embedding_provider") as mock_embedder, + patch("server.tools.search.get_sparse_provider") as mock_sparse, + patch("server.tools.search.get_store", return_value=store), + ): + mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1]) + mock_sparse.return_value.embed_query = AsyncMock(return_value={}) + result = await find_usages("OrderService") + + idx = source.find("OrderService") + expected_snippet = source[idx - 100 : idx + len("OrderService") + 200] + assert expected_snippet in result + + +async def test_get_code_context_file_not_in_index() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = None + + with patch("server.tools.search.get_store", return_value=store): + result = await get_code_context("orders/Order.java") + + assert result == "File not found in index: `orders/Order.java`" + + +async def test_get_code_context_service_removed_from_config() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.settings") as mock_settings, + ): + mock_settings.load_services.return_value = [] + result = await get_code_context("orders/Order.java") + + assert result == "Service `orders` is no longer in config.yaml." + + +async def test_get_code_context_reconstructs_path_with_root_prefix() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + store.find_by_name.return_value = [] + svc = ServiceConfig( + name="orders", + github_repo="org/orders", + exclude=[], + root="services/orders", + ) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.load_services.return_value = [svc] + mock_settings.github_token = "token" + mock_fetch.return_value = b"class Order {}" + + await get_code_context("orders/src/Order.java") + + mock_fetch.assert_awaited_once_with( + "token", "org/orders", "services/orders/src/Order.java", svc.github_ref + ) + + +async def test_get_code_context_returns_full_file_without_symbol_name() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.load_services.return_value = [svc] + mock_settings.github_token = "token" + mock_fetch.return_value = b"class Order {}" + + result = await get_code_context("orders/Order.java") + + assert result == "```\nclass Order {}\n```" + + +async def test_get_code_context_github_fetch_error_is_reported() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.load_services.return_value = [svc] + mock_settings.github_token = "token" + mock_fetch.side_effect = httpx.HTTPError("boom") + + result = await get_code_context("orders/Order.java") + + assert "Failed to fetch `orders/Order.java`" in result + + +async def test_get_code_context_uses_qdrant_line_numbers_for_symbol() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + store.find_by_name.return_value = [ + StubHit( + payload={ + "file_path": "orders/Order.java", + "symbol_type": "method", + "start_line": 2, + "end_line": 3, + "language": "java", + } + ) + ] + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + content = b"line1\nvoid placeOrder() {\n}\nline4" + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.load_services.return_value = [svc] + mock_settings.github_token = "token" + mock_fetch.return_value = content + + result = await get_code_context("orders/Order.java", symbol_name="placeOrder") + + assert "at `orders/Order.java`:2-3" in result + assert "void placeOrder() {\n}" in result + + +async def test_get_code_context_falls_back_to_text_search_when_symbol_not_in_index() -> ( + None +): + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + store.find_by_name.return_value = [] + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + content = b"line1\nvoid placeOrder() {}\nline3" + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.load_services.return_value = [svc] + mock_settings.github_token = "token" + mock_fetch.return_value = content + + result = await get_code_context("orders/Order.java", symbol_name="placeOrder") + + assert "Found `placeOrder` near line 2" in result + + +async def test_get_code_context_symbol_not_found_anywhere() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + store.find_by_name.return_value = [] + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.load_services.return_value = [svc] + mock_settings.github_token = "token" + mock_fetch.return_value = b"nothing relevant here" + + result = await get_code_context("orders/Order.java", symbol_name="placeOrder") + + assert result == "`placeOrder` not found in `orders/Order.java`."