# tests/test_github_tool.py import concurrent.futures from pathlib import Path from unittest.mock import MagicMock, patch import pytest from app.tools.github_tool import ( _get_dir_size_mb, clone_repository, delete_repository, get_changed_files, resolve_remote_head_sha, ) # ── _get_dir_size_mb ────────────────────────────────────────────────────────── def test_get_dir_size_mb_sums_file_sizes(tmp_path: Path) -> None: (tmp_path / "a.txt").write_bytes(b"x" * 1024) (tmp_path / "b.txt").write_bytes(b"x" * 1024) size = _get_dir_size_mb(tmp_path) assert size == pytest.approx(2 / 1024, rel=0.01) # ── get_changed_files ───────────────────────────────────────────────────────── def test_get_changed_files_returns_py_files(tmp_path: Path) -> None: (tmp_path / "changed.py").write_text("x = 1") mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "changed.py\nREADME.md\n" with patch("app.tools.github_tool.subprocess.run", return_value=mock_result): files = get_changed_files(str(tmp_path), "abc123", "def456") assert files == [str(tmp_path / "changed.py") if False else "changed.py"] # only .py files that exist on disk are returned assert all(f.endswith(".py") for f in files) def test_get_changed_files_returns_empty_on_git_error() -> None: mock_result = MagicMock() mock_result.returncode = 1 with patch("app.tools.github_tool.subprocess.run", return_value=mock_result): files = get_changed_files("/fake/path", "abc", "def") assert files == [] def test_get_changed_files_filters_nonexistent_files(tmp_path: Path) -> None: mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "ghost.py\n" # file does NOT exist on disk with patch("app.tools.github_tool.subprocess.run", return_value=mock_result): files = get_changed_files(str(tmp_path), "abc", "def") assert files == [] # ── clone_repository ────────────────────────────────────────────────────────── def test_clone_repository_rejects_invalid_url() -> None: with pytest.raises(ValueError, match="Invalid GitHub URL"): clone_repository("https://gitlab.com/owner/repo") def test_clone_repository_strips_dot_git_suffix(tmp_path: Path) -> None: with ( patch("app.tools.github_tool.settings") as mock_settings, patch("app.tools.github_tool.Repo.clone_from"), patch("app.tools.github_tool._get_dir_size_mb", return_value=1.0), ): mock_settings.repo_clone_dir = str(tmp_path) mock_settings.max_repo_size_mb = 300 result = clone_repository("https://github.com/owner/myrepo.git") assert "myrepo" in result assert ".git" not in Path(result).name def test_clone_repository_success(tmp_path: Path) -> None: with ( patch("app.tools.github_tool.settings") as mock_settings, patch("app.tools.github_tool.Repo.clone_from"), patch("app.tools.github_tool._get_dir_size_mb", return_value=10.0), ): mock_settings.repo_clone_dir = str(tmp_path) mock_settings.max_repo_size_mb = 300 result = clone_repository("https://github.com/owner/repo") assert "repo" in result def test_clone_repository_raises_on_timeout(tmp_path: Path) -> None: def slow_clone(*args: object, **kwargs: object) -> None: raise concurrent.futures.TimeoutError() with ( patch("app.tools.github_tool.settings") as mock_settings, patch("app.tools.github_tool.Repo.clone_from", side_effect=slow_clone), patch("app.tools.github_tool._get_dir_size_mb", return_value=1.0), ): mock_settings.repo_clone_dir = str(tmp_path) mock_settings.max_repo_size_mb = 300 with pytest.raises(TimeoutError, match="timed out"): clone_repository("https://github.com/owner/repo") def test_clone_repository_raises_on_clone_error(tmp_path: Path) -> None: with ( patch("app.tools.github_tool.settings") as mock_settings, patch( "app.tools.github_tool.Repo.clone_from", side_effect=RuntimeError("auth failed"), ), ): mock_settings.repo_clone_dir = str(tmp_path) mock_settings.max_repo_size_mb = 300 with pytest.raises(RuntimeError, match="auth failed"): clone_repository("https://github.com/owner/repo") def test_clone_repository_raises_when_too_large(tmp_path: Path) -> None: with ( patch("app.tools.github_tool.settings") as mock_settings, patch("app.tools.github_tool.Repo.clone_from"), patch("app.tools.github_tool._get_dir_size_mb", return_value=999.0), ): mock_settings.repo_clone_dir = str(tmp_path) mock_settings.max_repo_size_mb = 300 with pytest.raises(ValueError, match="exceeds limit"): clone_repository("https://github.com/owner/repo") # ── delete_repository ───────────────────────────────────────────────────────── def test_delete_repository_removes_existing_path(tmp_path: Path) -> None: target = tmp_path / "cloned_repo" target.mkdir() (target / "file.py").write_text("x = 1") delete_repository(str(target)) assert not target.exists() def test_delete_repository_is_noop_for_missing_path(tmp_path: Path) -> None: missing = str(tmp_path / "does_not_exist") delete_repository(missing) # must not raise # ── resolve_remote_head_sha ───────────────────────────────────────────────── def test_resolve_remote_head_sha_returns_sha_on_success() -> None: mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "abc123def456\tHEAD\n" with patch("app.tools.github_tool.subprocess.run", return_value=mock_result): sha = resolve_remote_head_sha("https://github.com/owner/repo") assert sha == "abc123def456" def test_resolve_remote_head_sha_returns_none_on_git_error() -> None: mock_result = MagicMock() mock_result.returncode = 1 mock_result.stdout = "" with patch("app.tools.github_tool.subprocess.run", return_value=mock_result): sha = resolve_remote_head_sha("https://github.com/owner/repo") assert sha is None def test_resolve_remote_head_sha_returns_none_on_exception() -> None: with patch("app.tools.github_tool.subprocess.run", side_effect=OSError("boom")): sha = resolve_remote_head_sha("https://github.com/owner/repo") assert sha is None