feat(A1.9): Add multi-source git repository support for config fetching

This major feature enables fetching configs from private/team git repositories
in addition to the public API, unlocking team collaboration and custom config
collections.

**New Components:**
- git_repo.py (283 lines): GitConfigRepo class for git operations
  - Shallow clone/pull with GitPython
  - Config discovery (recursive *.json search)
  - Token injection for private repos
  - Comprehensive error handling

- source_manager.py (260 lines): SourceManager class for registry
  - Add/list/remove config sources
  - Priority-based resolution
  - Atomic file I/O
  - Auto-detect token env vars

**MCP Integration:**
- Enhanced fetch_config: 3 modes (API, Git URL, Named Source)
- New tools: add_config_source, list_config_sources, remove_config_source
- Backward compatible: existing API mode unchanged

**Testing:**
- 83 tests (100% passing)
  - 35 tests for GitConfigRepo
  - 48 tests for SourceManager
  - Integration tests for MCP tools
- Comprehensive error scenarios covered

**Dependencies:**
- Added GitPython>=3.1.40

**Architecture:**
- Storage: ~/.skill-seekers/sources.json (registry)
- Cache: $SKILL_SEEKERS_CACHE_DIR (default: ~/.skill-seekers/cache/)
- Auth: Environment variables only (GITHUB_TOKEN, GITLAB_TOKEN, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
yusyus
2025-12-21 19:28:22 +03:00
parent df78aae51f
commit c910703913
7 changed files with 2613 additions and 70 deletions

429
tests/test_git_repo.py Normal file
View File

@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""
Tests for GitConfigRepo class (git repository operations)
"""
import json
import pytest
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch, Mock
from git.exc import GitCommandError, InvalidGitRepositoryError
from skill_seekers.mcp.git_repo import GitConfigRepo
@pytest.fixture
def temp_cache_dir(tmp_path):
"""Create temporary cache directory for tests."""
cache_dir = tmp_path / "test_cache"
cache_dir.mkdir()
return cache_dir
@pytest.fixture
def git_repo(temp_cache_dir):
"""Create GitConfigRepo instance with temp cache."""
return GitConfigRepo(cache_dir=str(temp_cache_dir))
class TestGitConfigRepoInit:
"""Test GitConfigRepo initialization."""
def test_init_with_custom_cache_dir(self, temp_cache_dir):
"""Test initialization with custom cache directory."""
repo = GitConfigRepo(cache_dir=str(temp_cache_dir))
assert repo.cache_dir == temp_cache_dir
assert temp_cache_dir.exists()
def test_init_with_env_var(self, tmp_path, monkeypatch):
"""Test initialization with environment variable."""
env_cache = tmp_path / "env_cache"
monkeypatch.setenv("SKILL_SEEKERS_CACHE_DIR", str(env_cache))
repo = GitConfigRepo()
assert repo.cache_dir == env_cache
assert env_cache.exists()
def test_init_with_default(self, monkeypatch):
"""Test initialization with default cache directory."""
monkeypatch.delenv("SKILL_SEEKERS_CACHE_DIR", raising=False)
repo = GitConfigRepo()
expected = Path.home() / ".skill-seekers" / "cache"
assert repo.cache_dir == expected
class TestValidateGitUrl:
"""Test git URL validation."""
def test_validate_https_url(self):
"""Test validation of HTTPS URLs."""
assert GitConfigRepo.validate_git_url("https://github.com/org/repo.git")
assert GitConfigRepo.validate_git_url("https://gitlab.com/org/repo.git")
def test_validate_http_url(self):
"""Test validation of HTTP URLs."""
assert GitConfigRepo.validate_git_url("http://example.com/repo.git")
def test_validate_ssh_url(self):
"""Test validation of SSH URLs."""
assert GitConfigRepo.validate_git_url("git@github.com:org/repo.git")
assert GitConfigRepo.validate_git_url("git@gitlab.com:group/project.git")
def test_validate_file_url(self):
"""Test validation of file:// URLs."""
assert GitConfigRepo.validate_git_url("file:///path/to/repo.git")
def test_invalid_empty_url(self):
"""Test validation rejects empty URLs."""
assert not GitConfigRepo.validate_git_url("")
assert not GitConfigRepo.validate_git_url(None)
def test_invalid_malformed_url(self):
"""Test validation rejects malformed URLs."""
assert not GitConfigRepo.validate_git_url("not-a-url")
assert not GitConfigRepo.validate_git_url("ftp://example.com/repo")
def test_invalid_ssh_without_colon(self):
"""Test validation rejects SSH URLs without colon."""
assert not GitConfigRepo.validate_git_url("git@github.com/org/repo.git")
class TestInjectToken:
"""Test token injection into git URLs."""
def test_inject_token_https(self):
"""Test token injection into HTTPS URL."""
url = "https://github.com/org/repo.git"
token = "ghp_testtoken123"
result = GitConfigRepo.inject_token(url, token)
assert result == "https://ghp_testtoken123@github.com/org/repo.git"
def test_inject_token_ssh_to_https(self):
"""Test SSH URL conversion to HTTPS with token."""
url = "git@github.com:org/repo.git"
token = "ghp_testtoken123"
result = GitConfigRepo.inject_token(url, token)
assert result == "https://ghp_testtoken123@github.com/org/repo.git"
def test_inject_token_with_port(self):
"""Test token injection with custom port."""
url = "https://gitlab.example.com:8443/org/repo.git"
token = "token123"
result = GitConfigRepo.inject_token(url, token)
assert result == "https://token123@gitlab.example.com:8443/org/repo.git"
def test_inject_token_gitlab_ssh(self):
"""Test GitLab SSH URL conversion."""
url = "git@gitlab.com:group/project.git"
token = "glpat-token123"
result = GitConfigRepo.inject_token(url, token)
assert result == "https://glpat-token123@gitlab.com/group/project.git"
class TestCloneOrPull:
"""Test clone and pull operations."""
@patch('skill_seekers.mcp.git_repo.git.Repo.clone_from')
def test_clone_new_repo(self, mock_clone, git_repo):
"""Test cloning a new repository."""
mock_clone.return_value = MagicMock()
result = git_repo.clone_or_pull(
source_name="test-source",
git_url="https://github.com/org/repo.git"
)
assert result == git_repo.cache_dir / "test-source"
mock_clone.assert_called_once()
# Verify shallow clone parameters
call_kwargs = mock_clone.call_args[1]
assert call_kwargs['depth'] == 1
assert call_kwargs['single_branch'] is True
assert call_kwargs['branch'] == "main"
@patch('skill_seekers.mcp.git_repo.git.Repo')
def test_pull_existing_repo(self, mock_repo_class, git_repo, temp_cache_dir):
"""Test pulling updates to existing repository."""
# Create fake existing repo
repo_path = temp_cache_dir / "test-source"
repo_path.mkdir()
(repo_path / ".git").mkdir()
# Mock git.Repo
mock_repo = MagicMock()
mock_origin = MagicMock()
mock_repo.remotes.origin = mock_origin
mock_repo_class.return_value = mock_repo
result = git_repo.clone_or_pull(
source_name="test-source",
git_url="https://github.com/org/repo.git"
)
assert result == repo_path
mock_origin.pull.assert_called_once_with("main")
@patch('skill_seekers.mcp.git_repo.git.Repo')
def test_pull_with_token_update(self, mock_repo_class, git_repo, temp_cache_dir):
"""Test pulling with token updates remote URL."""
# Create fake existing repo
repo_path = temp_cache_dir / "test-source"
repo_path.mkdir()
(repo_path / ".git").mkdir()
# Mock git.Repo
mock_repo = MagicMock()
mock_origin = MagicMock()
mock_repo.remotes.origin = mock_origin
mock_repo_class.return_value = mock_repo
result = git_repo.clone_or_pull(
source_name="test-source",
git_url="https://github.com/org/repo.git",
token="ghp_token123"
)
# Verify URL was updated with token
mock_origin.set_url.assert_called_once()
updated_url = mock_origin.set_url.call_args[0][0]
assert "ghp_token123@github.com" in updated_url
@patch('skill_seekers.mcp.git_repo.git.Repo.clone_from')
def test_force_refresh_deletes_cache(self, mock_clone, git_repo, temp_cache_dir):
"""Test force refresh deletes existing cache."""
# Create fake existing repo
repo_path = temp_cache_dir / "test-source"
repo_path.mkdir()
(repo_path / ".git").mkdir()
(repo_path / "config.json").write_text("{}")
mock_clone.return_value = MagicMock()
git_repo.clone_or_pull(
source_name="test-source",
git_url="https://github.com/org/repo.git",
force_refresh=True
)
# Verify clone was called (not pull)
mock_clone.assert_called_once()
@patch('skill_seekers.mcp.git_repo.git.Repo.clone_from')
def test_clone_with_custom_branch(self, mock_clone, git_repo):
"""Test cloning with custom branch."""
mock_clone.return_value = MagicMock()
git_repo.clone_or_pull(
source_name="test-source",
git_url="https://github.com/org/repo.git",
branch="develop"
)
call_kwargs = mock_clone.call_args[1]
assert call_kwargs['branch'] == "develop"
def test_clone_invalid_url_raises_error(self, git_repo):
"""Test cloning with invalid URL raises ValueError."""
with pytest.raises(ValueError, match="Invalid git URL"):
git_repo.clone_or_pull(
source_name="test-source",
git_url="not-a-valid-url"
)
@patch('skill_seekers.mcp.git_repo.git.Repo.clone_from')
def test_clone_auth_failure_error(self, mock_clone, git_repo):
"""Test authentication failure error handling."""
mock_clone.side_effect = GitCommandError(
"clone",
128,
stderr="fatal: Authentication failed"
)
with pytest.raises(GitCommandError, match="Authentication failed"):
git_repo.clone_or_pull(
source_name="test-source",
git_url="https://github.com/org/repo.git"
)
@patch('skill_seekers.mcp.git_repo.git.Repo.clone_from')
def test_clone_not_found_error(self, mock_clone, git_repo):
"""Test repository not found error handling."""
mock_clone.side_effect = GitCommandError(
"clone",
128,
stderr="fatal: repository not found"
)
with pytest.raises(GitCommandError, match="Repository not found"):
git_repo.clone_or_pull(
source_name="test-source",
git_url="https://github.com/org/nonexistent.git"
)
class TestFindConfigs:
"""Test config file discovery."""
def test_find_configs_in_root(self, git_repo, temp_cache_dir):
"""Test finding config files in repository root."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
(repo_path / "config1.json").write_text("{}")
(repo_path / "config2.json").write_text("{}")
(repo_path / "README.md").write_text("# Readme")
configs = git_repo.find_configs(repo_path)
assert len(configs) == 2
assert all(c.suffix == ".json" for c in configs)
assert sorted([c.name for c in configs]) == ["config1.json", "config2.json"]
def test_find_configs_in_subdirs(self, git_repo, temp_cache_dir):
"""Test finding config files in subdirectories."""
repo_path = temp_cache_dir / "test-repo"
configs_dir = repo_path / "configs"
configs_dir.mkdir(parents=True)
(repo_path / "root.json").write_text("{}")
(configs_dir / "sub1.json").write_text("{}")
(configs_dir / "sub2.json").write_text("{}")
configs = git_repo.find_configs(repo_path)
assert len(configs) == 3
def test_find_configs_excludes_git_dir(self, git_repo, temp_cache_dir):
"""Test that .git directory is excluded from config search."""
repo_path = temp_cache_dir / "test-repo"
git_dir = repo_path / ".git" / "config"
git_dir.mkdir(parents=True)
(repo_path / "config.json").write_text("{}")
(git_dir / "internal.json").write_text("{}")
configs = git_repo.find_configs(repo_path)
assert len(configs) == 1
assert configs[0].name == "config.json"
def test_find_configs_empty_repo(self, git_repo, temp_cache_dir):
"""Test finding configs in empty repository."""
repo_path = temp_cache_dir / "empty-repo"
repo_path.mkdir()
configs = git_repo.find_configs(repo_path)
assert configs == []
def test_find_configs_nonexistent_repo(self, git_repo, temp_cache_dir):
"""Test finding configs in non-existent repository."""
repo_path = temp_cache_dir / "nonexistent"
configs = git_repo.find_configs(repo_path)
assert configs == []
def test_find_configs_sorted_by_name(self, git_repo, temp_cache_dir):
"""Test that configs are sorted by filename."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
(repo_path / "zebra.json").write_text("{}")
(repo_path / "alpha.json").write_text("{}")
(repo_path / "beta.json").write_text("{}")
configs = git_repo.find_configs(repo_path)
assert [c.name for c in configs] == ["alpha.json", "beta.json", "zebra.json"]
class TestGetConfig:
"""Test config file loading."""
def test_get_config_exact_match(self, git_repo, temp_cache_dir):
"""Test loading config with exact filename match."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
config_data = {"name": "react", "version": "1.0"}
(repo_path / "react.json").write_text(json.dumps(config_data))
result = git_repo.get_config(repo_path, "react")
assert result == config_data
def test_get_config_with_json_extension(self, git_repo, temp_cache_dir):
"""Test loading config when .json extension is provided."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
config_data = {"name": "vue"}
(repo_path / "vue.json").write_text(json.dumps(config_data))
result = git_repo.get_config(repo_path, "vue.json")
assert result == config_data
def test_get_config_case_insensitive(self, git_repo, temp_cache_dir):
"""Test loading config with case-insensitive match."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
config_data = {"name": "Django"}
(repo_path / "Django.json").write_text(json.dumps(config_data))
result = git_repo.get_config(repo_path, "django")
assert result == config_data
def test_get_config_in_subdir(self, git_repo, temp_cache_dir):
"""Test loading config from subdirectory."""
repo_path = temp_cache_dir / "test-repo"
configs_dir = repo_path / "configs"
configs_dir.mkdir(parents=True)
config_data = {"name": "nestjs"}
(configs_dir / "nestjs.json").write_text(json.dumps(config_data))
result = git_repo.get_config(repo_path, "nestjs")
assert result == config_data
def test_get_config_not_found(self, git_repo, temp_cache_dir):
"""Test error when config not found."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
(repo_path / "react.json").write_text("{}")
with pytest.raises(FileNotFoundError, match="Config 'vue.json' not found"):
git_repo.get_config(repo_path, "vue")
def test_get_config_not_found_shows_available(self, git_repo, temp_cache_dir):
"""Test error message shows available configs."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
(repo_path / "react.json").write_text("{}")
(repo_path / "vue.json").write_text("{}")
with pytest.raises(FileNotFoundError, match="Available configs: react, vue"):
git_repo.get_config(repo_path, "django")
def test_get_config_invalid_json(self, git_repo, temp_cache_dir):
"""Test error handling for invalid JSON."""
repo_path = temp_cache_dir / "test-repo"
repo_path.mkdir()
(repo_path / "broken.json").write_text("{ invalid json }")
with pytest.raises(ValueError, match="Invalid JSON"):
git_repo.get_config(repo_path, "broken")

View File

@@ -0,0 +1,584 @@
#!/usr/bin/env python3
"""
MCP Integration Tests for Git Config Sources
Tests the complete MCP tool workflow for git-based config fetching
"""
import json
import pytest
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, Mock
from mcp.types import TextContent
# Test if MCP is available
try:
import mcp
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
@pytest.fixture
def temp_dirs(tmp_path):
"""Create temporary directories for testing."""
config_dir = tmp_path / "config"
cache_dir = tmp_path / "cache"
dest_dir = tmp_path / "dest"
config_dir.mkdir()
cache_dir.mkdir()
dest_dir.mkdir()
return {
"config": config_dir,
"cache": cache_dir,
"dest": dest_dir
}
@pytest.fixture
def mock_git_repo(temp_dirs):
"""Create a mock git repository with config files."""
repo_path = temp_dirs["cache"] / "test-source"
repo_path.mkdir()
(repo_path / ".git").mkdir()
# Create sample config files
react_config = {
"name": "react",
"description": "React framework",
"base_url": "https://react.dev/"
}
(repo_path / "react.json").write_text(json.dumps(react_config, indent=2))
vue_config = {
"name": "vue",
"description": "Vue framework",
"base_url": "https://vuejs.org/"
}
(repo_path / "vue.json").write_text(json.dumps(vue_config, indent=2))
return repo_path
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP not available")
@pytest.mark.asyncio
class TestFetchConfigModes:
"""Test fetch_config tool with different modes."""
async def test_fetch_config_api_mode_list(self):
"""Test API mode - listing available configs."""
from skill_seekers.mcp.server import fetch_config_tool
with patch('skill_seekers.mcp.server.httpx.AsyncClient') as mock_client:
# Mock API response
mock_response = MagicMock()
mock_response.json.return_value = {
"configs": [
{"name": "react", "category": "web-frameworks", "description": "React framework", "type": "single"},
{"name": "vue", "category": "web-frameworks", "description": "Vue framework", "type": "single"}
],
"total": 2
}
mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
args = {"list_available": True}
result = await fetch_config_tool(args)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert "react" in result[0].text
assert "vue" in result[0].text
async def test_fetch_config_api_mode_download(self, temp_dirs):
"""Test API mode - downloading specific config."""
from skill_seekers.mcp.server import fetch_config_tool
with patch('skill_seekers.mcp.server.httpx.AsyncClient') as mock_client:
# Mock API responses
mock_detail_response = MagicMock()
mock_detail_response.json.return_value = {
"name": "react",
"category": "web-frameworks",
"description": "React framework"
}
mock_download_response = MagicMock()
mock_download_response.json.return_value = {
"name": "react",
"base_url": "https://react.dev/"
}
mock_client_instance = mock_client.return_value.__aenter__.return_value
mock_client_instance.get.side_effect = [mock_detail_response, mock_download_response]
args = {
"config_name": "react",
"destination": str(temp_dirs["dest"])
}
result = await fetch_config_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "react" in result[0].text
# Verify file was created
config_file = temp_dirs["dest"] / "react.json"
assert config_file.exists()
@patch('skill_seekers.mcp.server.GitConfigRepo')
async def test_fetch_config_git_url_mode(self, mock_git_repo_class, temp_dirs):
"""Test Git URL mode - direct git clone."""
from skill_seekers.mcp.server import fetch_config_tool
# Mock GitConfigRepo
mock_repo_instance = MagicMock()
mock_repo_path = temp_dirs["cache"] / "temp_react"
mock_repo_path.mkdir()
# Create mock config file
react_config = {"name": "react", "base_url": "https://react.dev/"}
(mock_repo_path / "react.json").write_text(json.dumps(react_config))
mock_repo_instance.clone_or_pull.return_value = mock_repo_path
mock_repo_instance.get_config.return_value = react_config
mock_git_repo_class.return_value = mock_repo_instance
args = {
"config_name": "react",
"git_url": "https://github.com/myorg/configs.git",
"destination": str(temp_dirs["dest"])
}
result = await fetch_config_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "git URL" in result[0].text
assert "react" in result[0].text
# Verify clone was called
mock_repo_instance.clone_or_pull.assert_called_once()
# Verify file was created
config_file = temp_dirs["dest"] / "react.json"
assert config_file.exists()
@patch('skill_seekers.mcp.server.GitConfigRepo')
@patch('skill_seekers.mcp.server.SourceManager')
async def test_fetch_config_source_mode(self, mock_source_manager_class, mock_git_repo_class, temp_dirs):
"""Test Source mode - using named source from registry."""
from skill_seekers.mcp.server import fetch_config_tool
# Mock SourceManager
mock_source_manager = MagicMock()
mock_source_manager.get_source.return_value = {
"name": "team",
"git_url": "https://github.com/myorg/configs.git",
"branch": "main",
"token_env": "GITHUB_TOKEN"
}
mock_source_manager_class.return_value = mock_source_manager
# Mock GitConfigRepo
mock_repo_instance = MagicMock()
mock_repo_path = temp_dirs["cache"] / "team"
mock_repo_path.mkdir()
react_config = {"name": "react", "base_url": "https://react.dev/"}
(mock_repo_path / "react.json").write_text(json.dumps(react_config))
mock_repo_instance.clone_or_pull.return_value = mock_repo_path
mock_repo_instance.get_config.return_value = react_config
mock_git_repo_class.return_value = mock_repo_instance
args = {
"config_name": "react",
"source": "team",
"destination": str(temp_dirs["dest"])
}
result = await fetch_config_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "git source" in result[0].text
assert "team" in result[0].text
# Verify source was retrieved
mock_source_manager.get_source.assert_called_once_with("team")
# Verify file was created
config_file = temp_dirs["dest"] / "react.json"
assert config_file.exists()
async def test_fetch_config_source_not_found(self):
"""Test error when source doesn't exist."""
from skill_seekers.mcp.server import fetch_config_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.get_source.side_effect = KeyError("Source 'nonexistent' not found")
mock_sm_class.return_value = mock_sm
args = {
"config_name": "react",
"source": "nonexistent"
}
result = await fetch_config_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "not found" in result[0].text
@patch('skill_seekers.mcp.server.GitConfigRepo')
async def test_fetch_config_config_not_found_in_repo(self, mock_git_repo_class, temp_dirs):
"""Test error when config doesn't exist in repository."""
from skill_seekers.mcp.server import fetch_config_tool
# Mock GitConfigRepo
mock_repo_instance = MagicMock()
mock_repo_path = temp_dirs["cache"] / "temp_django"
mock_repo_path.mkdir()
mock_repo_instance.clone_or_pull.return_value = mock_repo_path
mock_repo_instance.get_config.side_effect = FileNotFoundError(
"Config 'django' not found in repository. Available configs: react, vue"
)
mock_git_repo_class.return_value = mock_repo_instance
args = {
"config_name": "django",
"git_url": "https://github.com/myorg/configs.git"
}
result = await fetch_config_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "not found" in result[0].text
assert "Available configs" in result[0].text
@patch('skill_seekers.mcp.server.GitConfigRepo')
async def test_fetch_config_invalid_git_url(self, mock_git_repo_class):
"""Test error handling for invalid git URL."""
from skill_seekers.mcp.server import fetch_config_tool
# Mock GitConfigRepo to raise ValueError
mock_repo_instance = MagicMock()
mock_repo_instance.clone_or_pull.side_effect = ValueError("Invalid git URL: not-a-url")
mock_git_repo_class.return_value = mock_repo_instance
args = {
"config_name": "react",
"git_url": "not-a-url"
}
result = await fetch_config_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "Invalid git URL" in result[0].text
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP not available")
@pytest.mark.asyncio
class TestSourceManagementTools:
"""Test add/list/remove config source tools."""
async def test_add_config_source(self, temp_dirs):
"""Test adding a new config source."""
from skill_seekers.mcp.server import add_config_source_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.add_source.return_value = {
"name": "team",
"git_url": "https://github.com/myorg/configs.git",
"type": "github",
"branch": "main",
"token_env": "GITHUB_TOKEN",
"priority": 100,
"enabled": True,
"added_at": "2025-12-21T10:00:00+00:00"
}
mock_sm_class.return_value = mock_sm
args = {
"name": "team",
"git_url": "https://github.com/myorg/configs.git"
}
result = await add_config_source_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "team" in result[0].text
assert "registered" in result[0].text
# Verify add_source was called
mock_sm.add_source.assert_called_once()
async def test_add_config_source_missing_name(self):
"""Test error when name is missing."""
from skill_seekers.mcp.server import add_config_source_tool
args = {"git_url": "https://github.com/myorg/configs.git"}
result = await add_config_source_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "name" in result[0].text.lower()
assert "required" in result[0].text.lower()
async def test_add_config_source_missing_git_url(self):
"""Test error when git_url is missing."""
from skill_seekers.mcp.server import add_config_source_tool
args = {"name": "team"}
result = await add_config_source_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "git_url" in result[0].text.lower()
assert "required" in result[0].text.lower()
async def test_add_config_source_invalid_name(self):
"""Test error when source name is invalid."""
from skill_seekers.mcp.server import add_config_source_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.add_source.side_effect = ValueError(
"Invalid source name 'team@company'. Must be alphanumeric with optional hyphens/underscores."
)
mock_sm_class.return_value = mock_sm
args = {
"name": "team@company",
"git_url": "https://github.com/myorg/configs.git"
}
result = await add_config_source_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "Validation Error" in result[0].text
async def test_list_config_sources(self):
"""Test listing config sources."""
from skill_seekers.mcp.server import list_config_sources_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.list_sources.return_value = [
{
"name": "team",
"git_url": "https://github.com/myorg/configs.git",
"type": "github",
"branch": "main",
"token_env": "GITHUB_TOKEN",
"priority": 1,
"enabled": True,
"added_at": "2025-12-21T10:00:00+00:00"
},
{
"name": "company",
"git_url": "https://gitlab.company.com/configs.git",
"type": "gitlab",
"branch": "develop",
"token_env": "GITLAB_TOKEN",
"priority": 2,
"enabled": True,
"added_at": "2025-12-21T11:00:00+00:00"
}
]
mock_sm_class.return_value = mock_sm
args = {}
result = await list_config_sources_tool(args)
assert len(result) == 1
assert "📋" in result[0].text
assert "team" in result[0].text
assert "company" in result[0].text
assert "2 total" in result[0].text
async def test_list_config_sources_empty(self):
"""Test listing when no sources registered."""
from skill_seekers.mcp.server import list_config_sources_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.list_sources.return_value = []
mock_sm_class.return_value = mock_sm
args = {}
result = await list_config_sources_tool(args)
assert len(result) == 1
assert "No config sources registered" in result[0].text
async def test_list_config_sources_enabled_only(self):
"""Test listing only enabled sources."""
from skill_seekers.mcp.server import list_config_sources_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.list_sources.return_value = [
{
"name": "team",
"git_url": "https://github.com/myorg/configs.git",
"type": "github",
"branch": "main",
"token_env": "GITHUB_TOKEN",
"priority": 1,
"enabled": True,
"added_at": "2025-12-21T10:00:00+00:00"
}
]
mock_sm_class.return_value = mock_sm
args = {"enabled_only": True}
result = await list_config_sources_tool(args)
assert len(result) == 1
assert "enabled only" in result[0].text
# Verify list_sources was called with correct parameter
mock_sm.list_sources.assert_called_once_with(enabled_only=True)
async def test_remove_config_source(self):
"""Test removing a config source."""
from skill_seekers.mcp.server import remove_config_source_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.remove_source.return_value = True
mock_sm_class.return_value = mock_sm
args = {"name": "team"}
result = await remove_config_source_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "removed" in result[0].text.lower()
assert "team" in result[0].text
# Verify remove_source was called
mock_sm.remove_source.assert_called_once_with("team")
async def test_remove_config_source_not_found(self):
"""Test removing non-existent source."""
from skill_seekers.mcp.server import remove_config_source_tool
with patch('skill_seekers.mcp.server.SourceManager') as mock_sm_class:
mock_sm = MagicMock()
mock_sm.remove_source.return_value = False
mock_sm.list_sources.return_value = [
{"name": "team", "git_url": "https://example.com/1.git"},
{"name": "company", "git_url": "https://example.com/2.git"}
]
mock_sm_class.return_value = mock_sm
args = {"name": "nonexistent"}
result = await remove_config_source_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "not found" in result[0].text
assert "Available sources" in result[0].text
async def test_remove_config_source_missing_name(self):
"""Test error when name is missing."""
from skill_seekers.mcp.server import remove_config_source_tool
args = {}
result = await remove_config_source_tool(args)
assert len(result) == 1
assert "" in result[0].text
assert "name" in result[0].text.lower()
assert "required" in result[0].text.lower()
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP not available")
@pytest.mark.asyncio
class TestCompleteWorkflow:
"""Test complete workflow of add → fetch → remove."""
@patch('skill_seekers.mcp.server.GitConfigRepo')
@patch('skill_seekers.mcp.server.SourceManager')
async def test_add_fetch_remove_workflow(self, mock_sm_class, mock_git_repo_class, temp_dirs):
"""Test complete workflow: add source → fetch config → remove source."""
from skill_seekers.mcp.server import (
add_config_source_tool,
fetch_config_tool,
list_config_sources_tool,
remove_config_source_tool
)
# Step 1: Add source
mock_sm = MagicMock()
mock_sm.add_source.return_value = {
"name": "team",
"git_url": "https://github.com/myorg/configs.git",
"type": "github",
"branch": "main",
"token_env": "GITHUB_TOKEN",
"priority": 100,
"enabled": True,
"added_at": "2025-12-21T10:00:00+00:00"
}
mock_sm_class.return_value = mock_sm
add_result = await add_config_source_tool({
"name": "team",
"git_url": "https://github.com/myorg/configs.git"
})
assert "" in add_result[0].text
# Step 2: Fetch config from source
mock_sm.get_source.return_value = {
"name": "team",
"git_url": "https://github.com/myorg/configs.git",
"branch": "main",
"token_env": "GITHUB_TOKEN"
}
mock_repo = MagicMock()
mock_repo_path = temp_dirs["cache"] / "team"
mock_repo_path.mkdir()
react_config = {"name": "react", "base_url": "https://react.dev/"}
(mock_repo_path / "react.json").write_text(json.dumps(react_config))
mock_repo.clone_or_pull.return_value = mock_repo_path
mock_repo.get_config.return_value = react_config
mock_git_repo_class.return_value = mock_repo
fetch_result = await fetch_config_tool({
"config_name": "react",
"source": "team",
"destination": str(temp_dirs["dest"])
})
assert "" in fetch_result[0].text
# Verify config file created
assert (temp_dirs["dest"] / "react.json").exists()
# Step 3: List sources
mock_sm.list_sources.return_value = [{
"name": "team",
"git_url": "https://github.com/myorg/configs.git",
"type": "github",
"branch": "main",
"token_env": "GITHUB_TOKEN",
"priority": 100,
"enabled": True,
"added_at": "2025-12-21T10:00:00+00:00"
}]
list_result = await list_config_sources_tool({})
assert "team" in list_result[0].text
# Step 4: Remove source
mock_sm.remove_source.return_value = True
remove_result = await remove_config_source_tool({"name": "team"})
assert "" in remove_result[0].text

View File

@@ -0,0 +1,551 @@
#!/usr/bin/env python3
"""
Tests for SourceManager class (config source registry management)
"""
import json
import pytest
from pathlib import Path
from datetime import datetime, timezone
from skill_seekers.mcp.source_manager import SourceManager
@pytest.fixture
def temp_config_dir(tmp_path):
"""Create temporary config directory for tests."""
config_dir = tmp_path / "test_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def source_manager(temp_config_dir):
"""Create SourceManager instance with temp config."""
return SourceManager(config_dir=str(temp_config_dir))
class TestSourceManagerInit:
"""Test SourceManager initialization."""
def test_init_creates_config_dir(self, tmp_path):
"""Test that initialization creates config directory."""
config_dir = tmp_path / "new_config"
manager = SourceManager(config_dir=str(config_dir))
assert config_dir.exists()
assert manager.config_dir == config_dir
def test_init_creates_registry_file(self, temp_config_dir):
"""Test that initialization creates registry file."""
manager = SourceManager(config_dir=str(temp_config_dir))
registry_file = temp_config_dir / "sources.json"
assert registry_file.exists()
# Verify initial structure
with open(registry_file, 'r') as f:
data = json.load(f)
assert data == {"version": "1.0", "sources": []}
def test_init_preserves_existing_registry(self, temp_config_dir):
"""Test that initialization doesn't overwrite existing registry."""
registry_file = temp_config_dir / "sources.json"
# Create existing registry
existing_data = {
"version": "1.0",
"sources": [{"name": "test", "git_url": "https://example.com/repo.git"}]
}
with open(registry_file, 'w') as f:
json.dump(existing_data, f)
# Initialize manager
manager = SourceManager(config_dir=str(temp_config_dir))
# Verify data preserved
with open(registry_file, 'r') as f:
data = json.load(f)
assert len(data["sources"]) == 1
def test_init_with_default_config_dir(self):
"""Test initialization with default config directory."""
manager = SourceManager()
expected = Path.home() / ".skill-seekers"
assert manager.config_dir == expected
class TestAddSource:
"""Test adding config sources."""
def test_add_source_minimal(self, source_manager):
"""Test adding source with minimal parameters."""
source = source_manager.add_source(
name="team",
git_url="https://github.com/myorg/configs.git"
)
assert source["name"] == "team"
assert source["git_url"] == "https://github.com/myorg/configs.git"
assert source["type"] == "github"
assert source["token_env"] == "GITHUB_TOKEN"
assert source["branch"] == "main"
assert source["enabled"] is True
assert source["priority"] == 100
assert "added_at" in source
assert "updated_at" in source
def test_add_source_full_parameters(self, source_manager):
"""Test adding source with all parameters."""
source = source_manager.add_source(
name="company",
git_url="https://gitlab.company.com/platform/configs.git",
source_type="gitlab",
token_env="CUSTOM_TOKEN",
branch="develop",
priority=1,
enabled=False
)
assert source["name"] == "company"
assert source["type"] == "gitlab"
assert source["token_env"] == "CUSTOM_TOKEN"
assert source["branch"] == "develop"
assert source["priority"] == 1
assert source["enabled"] is False
def test_add_source_normalizes_name(self, source_manager):
"""Test that source names are normalized to lowercase."""
source = source_manager.add_source(
name="MyTeam",
git_url="https://github.com/org/repo.git"
)
assert source["name"] == "myteam"
def test_add_source_invalid_name_empty(self, source_manager):
"""Test that empty source names are rejected."""
with pytest.raises(ValueError, match="Invalid source name"):
source_manager.add_source(
name="",
git_url="https://github.com/org/repo.git"
)
def test_add_source_invalid_name_special_chars(self, source_manager):
"""Test that source names with special characters are rejected."""
with pytest.raises(ValueError, match="Invalid source name"):
source_manager.add_source(
name="team@company",
git_url="https://github.com/org/repo.git"
)
def test_add_source_valid_name_with_hyphens(self, source_manager):
"""Test that source names with hyphens are allowed."""
source = source_manager.add_source(
name="team-alpha",
git_url="https://github.com/org/repo.git"
)
assert source["name"] == "team-alpha"
def test_add_source_valid_name_with_underscores(self, source_manager):
"""Test that source names with underscores are allowed."""
source = source_manager.add_source(
name="team_alpha",
git_url="https://github.com/org/repo.git"
)
assert source["name"] == "team_alpha"
def test_add_source_empty_git_url(self, source_manager):
"""Test that empty git URLs are rejected."""
with pytest.raises(ValueError, match="git_url cannot be empty"):
source_manager.add_source(name="team", git_url="")
def test_add_source_strips_git_url(self, source_manager):
"""Test that git URLs are stripped of whitespace."""
source = source_manager.add_source(
name="team",
git_url=" https://github.com/org/repo.git "
)
assert source["git_url"] == "https://github.com/org/repo.git"
def test_add_source_updates_existing(self, source_manager):
"""Test that adding existing source updates it."""
# Add initial source
source1 = source_manager.add_source(
name="team",
git_url="https://github.com/org/repo1.git"
)
# Update source
source2 = source_manager.add_source(
name="team",
git_url="https://github.com/org/repo2.git"
)
# Verify updated
assert source2["git_url"] == "https://github.com/org/repo2.git"
assert source2["added_at"] == source1["added_at"] # Preserved
assert source2["updated_at"] > source1["added_at"] # Updated
# Verify only one source exists
sources = source_manager.list_sources()
assert len(sources) == 1
def test_add_source_persists_to_file(self, source_manager, temp_config_dir):
"""Test that added sources are persisted to file."""
source_manager.add_source(
name="team",
git_url="https://github.com/org/repo.git"
)
# Read file directly
registry_file = temp_config_dir / "sources.json"
with open(registry_file, 'r') as f:
data = json.load(f)
assert len(data["sources"]) == 1
assert data["sources"][0]["name"] == "team"
def test_add_multiple_sources_sorted_by_priority(self, source_manager):
"""Test that multiple sources are sorted by priority."""
source_manager.add_source(name="low", git_url="https://example.com/1.git", priority=100)
source_manager.add_source(name="high", git_url="https://example.com/2.git", priority=1)
source_manager.add_source(name="medium", git_url="https://example.com/3.git", priority=50)
sources = source_manager.list_sources()
assert [s["name"] for s in sources] == ["high", "medium", "low"]
assert [s["priority"] for s in sources] == [1, 50, 100]
class TestGetSource:
"""Test retrieving config sources."""
def test_get_source_exact_match(self, source_manager):
"""Test getting source with exact name match."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git")
source = source_manager.get_source("team")
assert source["name"] == "team"
def test_get_source_case_insensitive(self, source_manager):
"""Test getting source is case-insensitive."""
source_manager.add_source(name="MyTeam", git_url="https://github.com/org/repo.git")
source = source_manager.get_source("myteam")
assert source["name"] == "myteam"
def test_get_source_not_found(self, source_manager):
"""Test error when source not found."""
with pytest.raises(KeyError, match="Source 'nonexistent' not found"):
source_manager.get_source("nonexistent")
def test_get_source_not_found_shows_available(self, source_manager):
"""Test error message shows available sources."""
source_manager.add_source(name="team1", git_url="https://example.com/1.git")
source_manager.add_source(name="team2", git_url="https://example.com/2.git")
with pytest.raises(KeyError, match="Available sources: team1, team2"):
source_manager.get_source("team3")
def test_get_source_empty_registry(self, source_manager):
"""Test error when registry is empty."""
with pytest.raises(KeyError, match="Available sources: none"):
source_manager.get_source("team")
class TestListSources:
"""Test listing config sources."""
def test_list_sources_empty(self, source_manager):
"""Test listing sources when registry is empty."""
sources = source_manager.list_sources()
assert sources == []
def test_list_sources_multiple(self, source_manager):
"""Test listing multiple sources."""
source_manager.add_source(name="team1", git_url="https://example.com/1.git")
source_manager.add_source(name="team2", git_url="https://example.com/2.git")
source_manager.add_source(name="team3", git_url="https://example.com/3.git")
sources = source_manager.list_sources()
assert len(sources) == 3
def test_list_sources_sorted_by_priority(self, source_manager):
"""Test that sources are sorted by priority."""
source_manager.add_source(name="low", git_url="https://example.com/1.git", priority=100)
source_manager.add_source(name="high", git_url="https://example.com/2.git", priority=1)
sources = source_manager.list_sources()
assert sources[0]["name"] == "high"
assert sources[1]["name"] == "low"
def test_list_sources_enabled_only(self, source_manager):
"""Test listing only enabled sources."""
source_manager.add_source(name="enabled1", git_url="https://example.com/1.git", enabled=True)
source_manager.add_source(name="disabled", git_url="https://example.com/2.git", enabled=False)
source_manager.add_source(name="enabled2", git_url="https://example.com/3.git", enabled=True)
sources = source_manager.list_sources(enabled_only=True)
assert len(sources) == 2
assert all(s["enabled"] for s in sources)
assert sorted([s["name"] for s in sources]) == ["enabled1", "enabled2"]
def test_list_sources_all_when_some_disabled(self, source_manager):
"""Test listing all sources includes disabled ones."""
source_manager.add_source(name="enabled", git_url="https://example.com/1.git", enabled=True)
source_manager.add_source(name="disabled", git_url="https://example.com/2.git", enabled=False)
sources = source_manager.list_sources(enabled_only=False)
assert len(sources) == 2
class TestRemoveSource:
"""Test removing config sources."""
def test_remove_source_exists(self, source_manager):
"""Test removing existing source."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git")
result = source_manager.remove_source("team")
assert result is True
assert len(source_manager.list_sources()) == 0
def test_remove_source_case_insensitive(self, source_manager):
"""Test removing source is case-insensitive."""
source_manager.add_source(name="MyTeam", git_url="https://github.com/org/repo.git")
result = source_manager.remove_source("myteam")
assert result is True
def test_remove_source_not_found(self, source_manager):
"""Test removing non-existent source returns False."""
result = source_manager.remove_source("nonexistent")
assert result is False
def test_remove_source_persists_to_file(self, source_manager, temp_config_dir):
"""Test that source removal is persisted to file."""
source_manager.add_source(name="team1", git_url="https://example.com/1.git")
source_manager.add_source(name="team2", git_url="https://example.com/2.git")
source_manager.remove_source("team1")
# Read file directly
registry_file = temp_config_dir / "sources.json"
with open(registry_file, 'r') as f:
data = json.load(f)
assert len(data["sources"]) == 1
assert data["sources"][0]["name"] == "team2"
def test_remove_source_from_multiple(self, source_manager):
"""Test removing one source from multiple."""
source_manager.add_source(name="team1", git_url="https://example.com/1.git")
source_manager.add_source(name="team2", git_url="https://example.com/2.git")
source_manager.add_source(name="team3", git_url="https://example.com/3.git")
source_manager.remove_source("team2")
sources = source_manager.list_sources()
assert len(sources) == 2
assert sorted([s["name"] for s in sources]) == ["team1", "team3"]
class TestUpdateSource:
"""Test updating config sources."""
def test_update_source_git_url(self, source_manager):
"""Test updating source git URL."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo1.git")
updated = source_manager.update_source(name="team", git_url="https://github.com/org/repo2.git")
assert updated["git_url"] == "https://github.com/org/repo2.git"
def test_update_source_branch(self, source_manager):
"""Test updating source branch."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git")
updated = source_manager.update_source(name="team", branch="develop")
assert updated["branch"] == "develop"
def test_update_source_enabled(self, source_manager):
"""Test updating source enabled status."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git", enabled=True)
updated = source_manager.update_source(name="team", enabled=False)
assert updated["enabled"] is False
def test_update_source_priority(self, source_manager):
"""Test updating source priority."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git", priority=100)
updated = source_manager.update_source(name="team", priority=1)
assert updated["priority"] == 1
def test_update_source_multiple_fields(self, source_manager):
"""Test updating multiple fields at once."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git")
updated = source_manager.update_source(
name="team",
git_url="https://gitlab.com/org/repo.git",
type="gitlab",
branch="develop",
priority=1
)
assert updated["git_url"] == "https://gitlab.com/org/repo.git"
assert updated["type"] == "gitlab"
assert updated["branch"] == "develop"
assert updated["priority"] == 1
def test_update_source_updates_timestamp(self, source_manager):
"""Test that update modifies updated_at timestamp."""
source = source_manager.add_source(name="team", git_url="https://github.com/org/repo.git")
original_updated = source["updated_at"]
updated = source_manager.update_source(name="team", branch="develop")
assert updated["updated_at"] > original_updated
def test_update_source_not_found(self, source_manager):
"""Test error when updating non-existent source."""
with pytest.raises(KeyError, match="Source 'nonexistent' not found"):
source_manager.update_source(name="nonexistent", branch="main")
def test_update_source_resorts_by_priority(self, source_manager):
"""Test that updating priority re-sorts sources."""
source_manager.add_source(name="team1", git_url="https://example.com/1.git", priority=1)
source_manager.add_source(name="team2", git_url="https://example.com/2.git", priority=2)
# Change team2 to higher priority
source_manager.update_source(name="team2", priority=0)
sources = source_manager.list_sources()
assert sources[0]["name"] == "team2"
assert sources[1]["name"] == "team1"
class TestDefaultTokenEnv:
"""Test default token environment variable detection."""
def test_default_token_env_github(self, source_manager):
"""Test GitHub sources get GITHUB_TOKEN."""
source = source_manager.add_source(
name="team",
git_url="https://github.com/org/repo.git",
source_type="github"
)
assert source["token_env"] == "GITHUB_TOKEN"
def test_default_token_env_gitlab(self, source_manager):
"""Test GitLab sources get GITLAB_TOKEN."""
source = source_manager.add_source(
name="team",
git_url="https://gitlab.com/org/repo.git",
source_type="gitlab"
)
assert source["token_env"] == "GITLAB_TOKEN"
def test_default_token_env_gitea(self, source_manager):
"""Test Gitea sources get GITEA_TOKEN."""
source = source_manager.add_source(
name="team",
git_url="https://gitea.example.com/org/repo.git",
source_type="gitea"
)
assert source["token_env"] == "GITEA_TOKEN"
def test_default_token_env_bitbucket(self, source_manager):
"""Test Bitbucket sources get BITBUCKET_TOKEN."""
source = source_manager.add_source(
name="team",
git_url="https://bitbucket.org/org/repo.git",
source_type="bitbucket"
)
assert source["token_env"] == "BITBUCKET_TOKEN"
def test_default_token_env_custom(self, source_manager):
"""Test custom sources get GIT_TOKEN."""
source = source_manager.add_source(
name="team",
git_url="https://git.example.com/org/repo.git",
source_type="custom"
)
assert source["token_env"] == "GIT_TOKEN"
def test_override_token_env(self, source_manager):
"""Test that custom token_env overrides default."""
source = source_manager.add_source(
name="team",
git_url="https://github.com/org/repo.git",
source_type="github",
token_env="MY_CUSTOM_TOKEN"
)
assert source["token_env"] == "MY_CUSTOM_TOKEN"
class TestRegistryPersistence:
"""Test registry file I/O."""
def test_registry_atomic_write(self, source_manager, temp_config_dir):
"""Test that registry writes are atomic (temp file + rename)."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git")
# Verify no .tmp file left behind
temp_files = list(temp_config_dir.glob("*.tmp"))
assert len(temp_files) == 0
def test_registry_json_formatting(self, source_manager, temp_config_dir):
"""Test that registry JSON is properly formatted."""
source_manager.add_source(name="team", git_url="https://github.com/org/repo.git")
registry_file = temp_config_dir / "sources.json"
content = registry_file.read_text()
# Verify it's pretty-printed
assert " " in content # Indentation
data = json.loads(content)
assert "version" in data
assert "sources" in data
def test_registry_corrupted_file(self, temp_config_dir):
"""Test error handling for corrupted registry file."""
registry_file = temp_config_dir / "sources.json"
registry_file.write_text("{ invalid json }")
# The constructor will fail when trying to read the corrupted file
# during initialization, but it actually creates a new valid registry
# So we need to test reading a corrupted file after construction
manager = SourceManager(config_dir=str(temp_config_dir))
# Corrupt the file after initialization
registry_file.write_text("{ invalid json }")
# Now _read_registry should fail
with pytest.raises(ValueError, match="Corrupted registry file"):
manager._read_registry()