# #region Test.Git.Base.Edge [C:3] [TYPE Module] [SEMANTICS test, git, base, repo, migrate, coverage] # @BRIEF Additional edge case tests for GitServiceBase — repo path resolution, migration, delete_repo edge paths. # @RELATION BINDS_TO -> [GitServiceBase] import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import os from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from src.services.git._base import GitServiceBase class TestResolveBasePath: def test_custom_base_path_returns_direct(self): """Custom base_path (not 'git_repos') returns path directly.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/custom/path"): svc = GitServiceBase(base_path="/custom/path") # Already resolved in __init__ assert svc.base_path == "/custom/path" def test_fallback_on_db_error(self): """DB error during resolve → fallback to default.""" with patch("src.services.git._base.SessionLocal", side_effect=Exception("DB error")): with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") assert svc.base_path is not None class TestGetRepoPathDBPath: @pytest.mark.asyncio async def test_db_path_exists_returned(self): """DB has local_path and it exists → return it.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") svc.base_path = "/tmp/base" svc.legacy_base_path = "/tmp/legacy" svc._uses_default_base_path = True with patch("src.services.git._base.SessionLocal") as mock_sl: mock_db = MagicMock() mock_sl.return_value = mock_db mock_db_repo = MagicMock() mock_db_repo.local_path = "/tmp/existing/repo" mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo with patch("os.path.exists", return_value=True), \ patch("os.path.abspath", side_effect=lambda x: x): result = await svc._get_repo_path(1, "my-key") assert result == "/tmp/existing/repo" @pytest.mark.asyncio async def test_db_path_legacy_migration(self): """DB path starts with legacy base → migrate.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") svc.base_path = "/tmp/new-base" svc.legacy_base_path = "/tmp/legacy" svc._uses_default_base_path = True with patch("src.services.git._base.SessionLocal") as mock_sl: mock_db = MagicMock() mock_sl.return_value = mock_db mock_db_repo = MagicMock() mock_db_repo.local_path = "/tmp/legacy/repo" mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo with patch("os.path.exists", return_value=True), \ patch("os.path.abspath", side_effect=lambda x: x), \ patch.object(svc, '_migrate_repo_directory', new_callable=AsyncMock, return_value="/tmp/new-base/my-key"): result = await svc._get_repo_path(1, "my-key") assert result == "/tmp/new-base/my-key" @pytest.mark.asyncio async def test_db_query_exception(self): """Exception in DB query → fallback to path resolution.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") svc.base_path = "/tmp/base" svc.legacy_base_path = "/tmp/legacy" svc._uses_default_base_path = True with patch("src.services.git._base.SessionLocal", side_effect=Exception("DB down")): result = await svc._get_repo_path(1, "my-key") assert result == "/tmp/base/my-key" @pytest.mark.asyncio async def test_legacy_id_path_migration(self): """Legacy ID path exists → migrate to new path.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") svc.base_path = "/tmp/base" svc.legacy_base_path = "/tmp/legacy" svc._uses_default_base_path = True with patch("src.services.git._base.SessionLocal") as mock_sl: mock_db = MagicMock() mock_sl.return_value = mock_db mock_db.query.return_value.filter.return_value.first.return_value = None with patch("os.path.exists", side_effect=lambda p: p == "/tmp/legacy/1"), \ patch.object(svc, '_migrate_repo_directory', new_callable=AsyncMock, return_value="/tmp/base/dashboard"): result = await svc._get_repo_path(1) assert result == "/tmp/base/dashboard" @pytest.mark.asyncio async def test_target_path_exists_updates_db(self): """Target path already exists → update DB record.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") svc.base_path = "/tmp/base" svc.legacy_base_path = "/tmp/legacy" svc._uses_default_base_path = True with patch("src.services.git._base.SessionLocal") as mock_sl: mock_db = MagicMock() mock_sl.return_value = mock_db mock_db.query.return_value.filter.return_value.first.return_value = None with patch("os.path.exists", return_value=True), \ patch.object(svc, '_update_repo_local_path') as mock_update: result = await svc._get_repo_path(1, "my-key") assert result == "/tmp/base/my-key" mock_update.assert_called_once() class TestDeleteRepoEdge: @pytest.mark.asyncio async def test_delete_repo_file_not_dir(self): """Repo path exists but is a file → use os.remove.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") svc.base_path = "/tmp/base" with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo-file"), \ patch("os.path.exists", return_value=True), \ patch("os.path.isdir", return_value=False), \ patch("os.remove", new_callable=MagicMock) as mock_remove, \ patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()): import src.services.git._base as gb_mod orig_sl = gb_mod.SessionLocal try: mock_db = MagicMock() gb_mod.SessionLocal = MagicMock(return_value=mock_db) mock_db.query.return_value.filter.return_value.first.return_value = None with pytest.raises(HTTPException, match="404"): await svc.delete_repo(1) mock_remove.assert_called_once_with("/tmp/repo-file") finally: gb_mod.SessionLocal = orig_sl @pytest.mark.asyncio async def test_delete_repo_db_error_rollback(self): """DB error during delete → rollback and HTTP 500.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'): svc = GitServiceBase(base_path="git_repos") svc.base_path = "/tmp/base" with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \ patch("os.path.exists", return_value=True), \ patch("os.path.isdir", return_value=True), \ patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()): import src.services.git._base as gb_mod orig_sl = gb_mod.SessionLocal try: mock_db = MagicMock() gb_mod.SessionLocal = MagicMock(return_value=mock_db) mock_db_repo = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo mock_db.delete.side_effect = Exception("DB error") gb_mod.run_blocking = AsyncMock() with pytest.raises(HTTPException, match="500"): await svc.delete_repo(1) mock_db.rollback.assert_called_once() finally: gb_mod.SessionLocal = orig_sl class TestMigrateRepoDirectoryEdges: @pytest.mark.asyncio async def test_os_replace_fallback_to_shutil(self): """OSError on os.replace → falls back to shutil.move.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") with patch("os.path.exists", return_value=False), \ patch.object(svc, '_update_repo_local_path'), \ patch("src.services.git._base.run_blocking", new_callable=AsyncMock) as mock_blocking: # Make first os.replace fail, second shutil.move succeed mock_blocking.side_effect = [None, OSError("rename failed"), None] result = await svc._migrate_repo_directory(1, "/source", "/target") assert result == "/target" @pytest.mark.asyncio async def test_migrate_existing_target_keeps_source(self): """Target already exists → return source.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") with patch("os.path.exists", return_value=True): result = await svc._migrate_repo_directory(1, "/source", "/target") assert result == "/source" class TestInitRepoEdge: @pytest.mark.asyncio async def test_existing_path_is_not_git_repo_recreates(self): """Path exists but is not a valid Git repo → recreate.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \ patch("os.path.exists", return_value=True), \ patch("src.services.git._base.run_blocking") as mock_blocking, \ patch.object(svc, "_clone_with_auth", new_callable=AsyncMock) as mock_clone, \ patch.object(svc, "_ensure_gitflow_branches", create=True), \ patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()): # First call (Repo(repo_path)) raises InvalidGitRepositoryError mock_blocking.side_effect = [Exception("InvalidGitRepositoryError"), None, None] mock_clone.return_value = MagicMock() result = await svc.init_repo(1, "https://remote.com/repo.git", "pat") assert result is not None @pytest.mark.asyncio async def test_stale_path_unlink_fallback(self): """stale_path.exists after rmtree → tries unlink.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \ patch("os.path.exists", return_value=True), \ patch("src.services.git._base.run_blocking") as mock_blocking, \ patch.object(svc, "_clone_with_auth", new_callable=AsyncMock) as mock_clone, \ patch.object(svc, "_ensure_gitflow_branches", create=True), \ patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()), \ patch("pathlib.Path.exists", return_value=True): mock_blocking.side_effect = [ Exception("InvalidGitRepositoryError"), # Repo() call None, # shutil.rmtree None, # stale_path.unlink MagicMock(), # Repo from clone ] mock_clone.return_value = MagicMock() result = await svc.init_repo(1, "https://remote.com/repo.git", "pat") assert result is not None class TestClosedService: def test_closed_service_blocks_locked(self): """Closed service raises RuntimeError in _locked.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") svc._closed = True with pytest.raises(RuntimeError, match="closed"): with svc._locked(1): pass # #endregion Test.Git.Base.Edge