# #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 contextlib import os from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from git.exc import InvalidGitRepositoryError 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=contextlib.nullcontext()): 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 # Source returns early when removed_files=True and no DB record result = await svc.delete_repo(1) assert result is None 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=contextlib.nullcontext()): 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=contextlib.nullcontext()): # First run_blocking call is Path(repo_path).parent.mkdir, then Repo() raises mock_blocking.side_effect = [None, InvalidGitRepositoryError("Invalid repo"), None, MagicMock()] 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=contextlib.nullcontext()), \ patch("pathlib.Path.exists", return_value=True): mock_blocking.side_effect = [ None, # Path(repo_path).parent.mkdir InvalidGitRepositoryError("Invalid repo"), # 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 class TestResolveBasePathEdge: """_resolve_base_path — DB config path with non-absolute root and absolute repo_path.""" def test_non_absolute_root_with_absolute_repo_path(self): """root_path relative, repo_path absolute → uses resolved root + absolute repo_path.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch("src.services.git._base.SessionLocal") as mock_sl_cls: mock_session = MagicMock() mock_sl_cls.return_value = mock_session config_row = MagicMock() config_row.payload = { "settings": { "storage": { "root_path": "relative/storage", "repo_path": "/absolute/repo/path", } } } mock_session.query.return_value.filter.return_value.first.return_value = config_row svc = GitServiceBase(base_path="git_repos") assert svc.base_path is not None assert "/absolute/repo/path" in svc.base_path def test_absolute_root_with_relative_repo(self): """root_path absolute, repo_path relative → combined path.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch("src.services.git._base.SessionLocal") as mock_sl_cls: mock_session = MagicMock() mock_sl_cls.return_value = mock_session config_row = MagicMock() config_row.payload = { "settings": { "storage": { "root_path": "/absolute/storage", "repo_path": "repositories", } } } mock_session.query.return_value.filter.return_value.first.return_value = config_row svc = GitServiceBase(base_path="git_repos") assert svc.base_path is not None assert "repositories" in svc.base_path def test_config_with_empty_payload_storage(self): """Payload exists but storage settings empty → fallback.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch("src.services.git._base.SessionLocal") as mock_sl_cls: mock_session = MagicMock() mock_sl_cls.return_value = mock_session config_row = MagicMock() config_row.payload = {"settings": {"storage": {}}} mock_session.query.return_value.filter.return_value.first.return_value = config_row svc = GitServiceBase(base_path="git_repos") assert svc.base_path is not None def test_no_config_row(self): """No config row in DB → fallback path.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch("src.services.git._base.SessionLocal") as mock_sl_cls: mock_session = MagicMock() mock_sl_cls.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None svc = GitServiceBase(base_path="git_repos") assert svc.base_path is not None def test_custom_base_path_uses_fallback(self): """Custom base_path (not 'git_repos') → returns fallback_path directly, no DB query.""" # Don't patch _resolve_base_path — test it directly # Patch _ensure_base_path_exists so it doesn't create directories actual_msg = "git_repos" # We need to verify no DB call, so we pass a custom path with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch("src.services.git._base.SessionLocal") as mock_sl: # For custom base path, _resolve_base_path returns fallback without DB query svc = GitServiceBase(base_path="/custom/path") # Should use fallback, not DB assert "custom" in svc.base_path mock_sl.assert_not_called() # No DB query for custom paths class TestGetRepoPathEdge: """_get_repo_path — DB migration path and edge cases.""" @pytest.mark.asyncio async def test_db_path_with_migration_condition(self): """DB path starts with legacy base and base_path != legacy → 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_cls: mock_db = MagicMock() mock_sl_cls.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/dashboard"): result = await svc._get_repo_path(1) assert result == "/tmp/new-base/dashboard" @pytest.mark.asyncio async def test_db_path_not_starting_with_legacy(self): """DB path exists but does not start with legacy_base → returned directly.""" 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_cls: mock_db = MagicMock() mock_sl_cls.return_value = mock_db mock_db_repo = MagicMock() mock_db_repo.local_path = "/tmp/custom/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/custom/repo" @pytest.mark.asyncio async def test_legacy_path_does_not_exist_no_migration(self): """Legacy ID path does not exist → no migration, target path returned.""" 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_cls: mock_db = MagicMock() mock_sl_cls.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: False), \ patch.object(svc, '_update_repo_local_path') as mock_update: result = await svc._get_repo_path(1, "my-key") assert result == "/tmp/new-base/my-key" # target_path doesn't exist, legacy_id_path doesn't exist # → returns target_path without update mock_update.assert_not_called() @pytest.mark.asyncio async def test_legacy_path_exists_new_base_differs(self): """Legacy path exists, target doesn't → 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 # No DB record with patch("src.services.git._base.SessionLocal") as mock_sl_cls: mock_db = MagicMock() mock_sl_cls.return_value = mock_db mock_db.query.return_value.filter.return_value.first.return_value = None # legacy path exists, target path does not exist legacy_path = "/tmp/legacy/1" target_path = "/tmp/new-base/dashboard" with patch("os.path.exists", side_effect=lambda p: p == legacy_path), \ patch.object(svc, '_migrate_repo_directory', new_callable=AsyncMock, return_value=target_path): result = await svc._get_repo_path(1) assert result == target_path class TestInitRepoEdgePaths: """init_repo — additional edge paths.""" @pytest.mark.asyncio async def test_existing_repo_opens_successfully(self): """Existing repo path → opens directly.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") mock_repo = MagicMock() 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", return_value=mock_repo) as mock_blocking, \ patch.object(svc, "_ensure_gitflow_branches", create=True), \ patch.object(svc, '_locked', return_value=contextlib.nullcontext()): mock_blocking.side_effect = [None, mock_repo] # mkdir then Repo 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_exception(self): """stale_path.unlink fails → caught, clone proceeds.""" with patch.object(GitServiceBase, '_ensure_base_path_exists'), \ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") mock_repo = MagicMock() 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, return_value=mock_repo), \ patch.object(svc, "_ensure_gitflow_branches", create=True), \ patch.object(svc, '_locked', return_value=contextlib.nullcontext()), \ patch("pathlib.Path.exists", return_value=True): # mkdir → InvalidGitRepositoryError → rmtree → unlink fails → clone mock_blocking.side_effect = [ None, # Path(repo_path).parent.mkdir InvalidGitRepositoryError("invalid"), # Repo() call None, # shutil.rmtree Exception("unlink failed"), # stale_path.unlink ] result = await svc.init_repo(1, "https://remote.com/repo.git", "pat") assert result is not None class TestMigrateRepoDirectoryOSError: """_migrate_repo_directory — OSError → shutil.move fallback.""" @pytest.mark.asyncio async def test_os_replace_oserror_falls_back(self): """os.replace raises OSError → shutil.move used.""" 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: # mkdir succeeds, os.replace fails with OSError, shutil.move succeeds mock_blocking.side_effect = [None, OSError("rename failed"), None] result = await svc._migrate_repo_directory(1, "/source", "/target") assert result == "/target" # #endregion Test.Git.Base.Edge