Skip to main content
Glama
test_repo_client.py17.5 kB
"""Tests for RelaceRepoClient.""" from pathlib import Path from unittest.mock import MagicMock, patch import pytest from relace_mcp.clients.exceptions import RelaceAPIError from relace_mcp.clients.repo import RelaceRepoClient from relace_mcp.config import RelaceConfig @pytest.fixture def mock_config(tmp_path: Path) -> RelaceConfig: return RelaceConfig( api_key="rlc-test-api-key-12345", base_dir=str(tmp_path), ) @pytest.fixture def repo_client(mock_config: RelaceConfig) -> RelaceRepoClient: return RelaceRepoClient(mock_config) class TestRelaceRepoClientInit: """Test RelaceRepoClient initialization.""" def test_init_with_config(self, mock_config: RelaceConfig) -> None: """Should initialize with config.""" client = RelaceRepoClient(mock_config) assert client._config == mock_config assert "api.relace.run" in client._base_url def test_get_repo_name_from_base_dir(self, mock_config: RelaceConfig) -> None: """Should derive repo name from base_dir.""" client = RelaceRepoClient(mock_config) repo_name = client.get_repo_name_from_base_dir() # tmp_path typically has a random name like pytest-xxx assert repo_name == Path(mock_config.base_dir).name class TestRelaceRepoClientListRepos: """Test list_repos method.""" def test_list_repos_returns_list(self, repo_client: RelaceRepoClient) -> None: """Should return list of repos.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = { "items": [ {"repo_id": "repo-1", "metadata": {"name": "test-repo"}}, {"repo_id": "repo-2", "metadata": {"name": "another-repo"}}, ] } with patch.object(repo_client, "_request_with_retry", return_value=mock_response): repos = repo_client.list_repos() assert len(repos) == 2 assert repos[0]["metadata"]["name"] == "test-repo" def test_list_repos_handles_direct_list_response(self, repo_client: RelaceRepoClient) -> None: """Should handle API returning list directly.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = [ {"id": "repo-1", "name": "test-repo"}, ] with patch.object(repo_client, "_request_with_retry", return_value=mock_response): repos = repo_client.list_repos() assert len(repos) == 1 def test_list_repos_paginates_multiple_pages(self, repo_client: RelaceRepoClient) -> None: """Should fetch all pages using page_start/next_page cursor pagination.""" # Page 1: 100 items with next_page cursor page1_response = MagicMock() page1_response.status_code = 200 page1_response.is_success = True page1_response.json.return_value = { "items": [{"repo_id": f"repo-{i}"} for i in range(100)], "next_page": 100, # Cursor to next page "total_items": 150, } # Page 2: 50 items, no next_page = last page page2_response = MagicMock() page2_response.status_code = 200 page2_response.is_success = True page2_response.json.return_value = { "items": [{"repo_id": f"repo-{i}"} for i in range(100, 150)], # No next_page = last page "total_items": 150, } with patch.object( repo_client, "_request_with_retry", side_effect=[page1_response, page2_response] ) as mock_request: repos = repo_client.list_repos() assert len(repos) == 150 assert mock_request.call_count == 2 # Verify page_start parameter usage call_args_list = mock_request.call_args_list # First call: no page_start (or page_start=0) assert call_args_list[0].kwargs["params"].get("page_start", 0) == 0 # Second call: page_start=100 (from next_page) assert call_args_list[1].kwargs["params"]["page_start"] == 100 def test_list_repos_stops_when_no_next_page(self, repo_client: RelaceRepoClient) -> None: """Should stop pagination when next_page is omitted (no more pages).""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = { "items": [{"repo_id": "repo-1"}], # No next_page = only one page "total_items": 1, } with patch.object(repo_client, "_request_with_retry", return_value=mock_response): repos = repo_client.list_repos() assert len(repos) == 1 def test_list_repos_stops_on_empty_page(self, repo_client: RelaceRepoClient) -> None: """Should stop pagination when receiving empty response.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = {"items": [], "total_items": 0} with patch.object(repo_client, "_request_with_retry", return_value=mock_response): repos = repo_client.list_repos() assert len(repos) == 0 def test_list_repos_respects_page_limit(self, repo_client: RelaceRepoClient) -> None: """Should stop at 100 pages safety limit to prevent infinite loops.""" call_count = 0 def mock_paginated_response(*args, **kwargs): nonlocal call_count page_start = kwargs.get("params", {}).get("page_start", 0) call_count += 1 mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.is_success = True mock_resp.json.return_value = { "items": [{"repo_id": f"repo-{page_start + i}"} for i in range(100)], "next_page": page_start + 100, # Always return next_page "total_items": 999999, # Pretend there are many repos } return mock_resp with patch.object(repo_client, "_request_with_retry", side_effect=mock_paginated_response): repos = repo_client.list_repos() # Should stop at 100 pages = 10,000 repos assert len(repos) == 10000 assert call_count == 100 class TestRelaceRepoClientCreateRepo: """Test create_repo method.""" def test_create_repo_basic(self, repo_client: RelaceRepoClient) -> None: """Should create repo with basic metadata.""" mock_response = MagicMock() mock_response.status_code = 201 mock_response.is_success = True mock_response.json.return_value = { "repo_id": "new-repo-id", "repo_head": "abc123", } with patch.object( repo_client, "_request_with_retry", return_value=mock_response ) as mock_request: result = repo_client.create_repo(name="test-repo", auto_index=True) call_args = mock_request.call_args payload = call_args.kwargs.get("json") assert payload is not None assert payload["metadata"]["name"] == "test-repo" assert payload["auto_index"] is True assert "source" not in payload assert result["repo_id"] == "new-repo-id" def test_create_repo_with_source_files(self, repo_client: RelaceRepoClient) -> None: """Should create repo with source files.""" mock_response = MagicMock() mock_response.status_code = 201 mock_response.is_success = True mock_response.json.return_value = { "repo_id": "new-repo-id", "repo_head": "abc123", } source = { "type": "files", "files": [ {"filename": "src/main.py", "content": "print('hello')"}, ], } with patch.object( repo_client, "_request_with_retry", return_value=mock_response ) as mock_request: result = repo_client.create_repo(name="test-repo", auto_index=True, source=source) call_args = mock_request.call_args payload = call_args.kwargs.get("json") assert payload is not None assert payload["source"] == source assert payload["source"]["type"] == "files" assert len(payload["source"]["files"]) == 1 assert result["repo_id"] == "new-repo-id" def test_create_repo_with_source_git(self, repo_client: RelaceRepoClient) -> None: """Should create repo from git URL.""" mock_response = MagicMock() mock_response.status_code = 201 mock_response.is_success = True mock_response.json.return_value = { "repo_id": "new-repo-id", "repo_head": "abc123", } source = { "type": "git", "url": "https://github.com/example/repo.git", "branch": "main", } with patch.object( repo_client, "_request_with_retry", return_value=mock_response ) as mock_request: result = repo_client.create_repo(name="cloned-repo", auto_index=False, source=source) call_args = mock_request.call_args payload = call_args.kwargs.get("json") assert payload is not None assert payload["source"]["type"] == "git" assert payload["source"]["url"] == "https://github.com/example/repo.git" assert payload["auto_index"] is False assert result["repo_id"] == "new-repo-id" class TestRelaceRepoClientEnsureRepo: """Test ensure_repo method.""" def test_ensure_repo_uses_cached_id(self, mock_config: RelaceConfig) -> None: """Should return cached repo_id if available.""" with patch("relace_mcp.clients.repo.RELACE_REPO_ID", "cached-repo-id"): client = RelaceRepoClient(mock_config) repo_id = client.ensure_repo("test-repo") assert repo_id == "cached-repo-id" def test_ensure_repo_finds_existing(self, repo_client: RelaceRepoClient) -> None: """Should find and return existing repo.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = { "items": [ {"repo_id": "existing-repo-id", "metadata": {"name": "test-repo"}}, ] } with patch.object(repo_client, "_request_with_retry", return_value=mock_response): repo_id = repo_client.ensure_repo("test-repo") assert repo_id == "existing-repo-id" def test_ensure_repo_creates_new(self, repo_client: RelaceRepoClient) -> None: """Should create new repo if not found.""" list_response = MagicMock() list_response.status_code = 200 list_response.is_success = True list_response.json.return_value = {"items": []} create_response = MagicMock() create_response.status_code = 201 create_response.is_success = True create_response.json.return_value = {"repo_id": "new-repo-id"} with patch.object( repo_client, "_request_with_retry", side_effect=[list_response, create_response] ): repo_id = repo_client.ensure_repo("test-repo") assert repo_id == "new-repo-id" def test_ensure_repo_caches_per_name(self, repo_client: RelaceRepoClient) -> None: """Should cache repo IDs per repo name (not globally).""" with patch.object( repo_client, "list_repos", side_effect=[ [{"repo_id": "id-a", "metadata": {"name": "repo-a"}}], [{"repo_id": "id-b", "metadata": {"name": "repo-b"}}], ], ) as mock_list: repo_id_a = repo_client.ensure_repo("repo-a") repo_id_b = repo_client.ensure_repo("repo-b") repo_id_a_again = repo_client.ensure_repo("repo-a") assert repo_id_a == "id-a" assert repo_id_b == "id-b" assert repo_id_a_again == "id-a" assert mock_list.call_count == 2 class TestRelaceRepoClientRetrieve: """Test retrieve (semantic search) method.""" def test_retrieve_sends_correct_payload(self, repo_client: RelaceRepoClient) -> None: """Should send correct payload for semantic search.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.is_success = True mock_response.json.return_value = { "results": [ {"filename": "src/auth.py", "content": "def login(): ..."}, ] } with patch.object( repo_client, "_request_with_retry", return_value=mock_response ) as mock_request: result = repo_client.retrieve( repo_id="test-repo-id", query="user authentication", score_threshold=0.5, token_limit=10000, ) # Verify payload call_args = mock_request.call_args payload = call_args.kwargs.get("json") assert payload is not None assert payload["query"] == "user authentication" assert payload["score_threshold"] == 0.5 assert payload["token_limit"] == 10000 assert payload["include_content"] is True assert len(result["results"]) == 1 class TestRelaceRepoClientRetry: """Test retry behavior.""" def test_retries_on_429(self, repo_client: RelaceRepoClient) -> None: """Should retry on rate limit (429) error.""" call_count = 0 def mock_request(*args, **kwargs): nonlocal call_count call_count += 1 if call_count < 3: mock_resp = MagicMock() mock_resp.status_code = 429 mock_resp.is_success = False mock_resp.text = '{"code": "rate_limit", "message": "Too many requests"}' mock_resp.headers = {"retry-after": "0.1"} return mock_resp else: mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.is_success = True mock_resp.json.return_value = {"items": []} return mock_resp with patch("relace_mcp.clients.repo.httpx.Client") as mock_client_class: mock_instance = MagicMock() mock_instance.__enter__.return_value = mock_instance mock_instance.__exit__.return_value = None mock_instance.request = MagicMock(side_effect=mock_request) mock_client_class.return_value = mock_instance with patch("relace_mcp.clients.repo.time.sleep"): repos = repo_client.list_repos() assert call_count == 3 assert repos == [] def test_raises_on_non_retryable_error(self, repo_client: RelaceRepoClient) -> None: """Should raise immediately on non-retryable error (401).""" mock_response = MagicMock() mock_response.status_code = 401 mock_response.is_success = False mock_response.text = '{"code": "invalid_api_key", "message": "Invalid API key"}' mock_response.headers = {} with patch("relace_mcp.clients.repo.httpx.Client") as mock_client_class: mock_instance = MagicMock() mock_instance.__enter__.return_value = mock_instance mock_instance.__exit__.return_value = None mock_instance.request = MagicMock(return_value=mock_response) mock_client_class.return_value = mock_instance with pytest.raises(RuntimeError, match="invalid_api_key"): repo_client.list_repos() class TestRelaceRepoClientDeleteRepo: """Test delete_repo behavior.""" def test_delete_repo_treats_404_as_success(self, repo_client: RelaceRepoClient) -> None: """Should treat 404 as idempotent success and clear cached repo_id.""" repo_client._cached_repo_ids["test-repo"] = "test-repo-id" api_error = RelaceAPIError( status_code=404, code="not_found", message="Repo not found", retryable=False, ) def raise_not_found(*args, **kwargs): raise RuntimeError("Repos API error (not_found): Repo not found") from api_error with patch.object(repo_client, "_request_with_retry", side_effect=raise_not_found): assert repo_client.delete_repo("test-repo-id") is True assert "test-repo" not in repo_client._cached_repo_ids def test_delete_repo_returns_false_on_other_errors(self, repo_client: RelaceRepoClient) -> None: """Should return False for non-404 API errors.""" repo_client._cached_repo_ids["test-repo"] = "test-repo-id" api_error = RelaceAPIError( status_code=403, code="forbidden", message="Forbidden", retryable=False, ) def raise_forbidden(*args, **kwargs): raise RuntimeError("Repos API error (forbidden): Forbidden") from api_error with patch.object(repo_client, "_request_with_retry", side_effect=raise_forbidden): assert repo_client.delete_repo("test-repo-id") is False # Cached ID should remain unchanged on failure assert repo_client._cached_repo_ids["test-repo"] == "test-repo-id"

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/possible055/relace-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server