#!/usr/bin/env python3 """ Tests for GitConfigRepo class (git repository operations) """ import json from pathlib import Path from unittest.mock import MagicMock, patch import pytest from git.exc import GitCommandError 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")