# #region Test.Git.Sync.Edge [C:3] [TYPE Module] [SEMANTICS test, git, sync, push, pull, coverage] # @BRIEF Edge case tests for GitServiceSyncMixin — push/pull paths. # @RELATION BINDS_TO -> [GitServiceSyncMixin] import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import contextlib from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock import pytest from fastapi import HTTPException from git.exc import GitCommandError class TestPushChanges: @pytest.mark.asyncio async def test_no_branches(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.heads = [] with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo): await mixin.push_changes(1) # should not raise @pytest.mark.asyncio async def test_no_origin_remote(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.heads = [MagicMock()] repo.remote.side_effect = ValueError("no origin") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo): with pytest.raises(HTTPException, match="origin"): await mixin.push_changes(1) @pytest.mark.asyncio async def test_no_tracking_branch_sets_upstream(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() repo.remote.return_value = origin current_branch = MagicMock() current_branch.name = "feature" current_branch.tracking_branch.return_value = None repo.active_branch = current_branch with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo): with patch("src.services.git._sync.SessionLocal") as mock_sl_cls: mock_db = MagicMock() mock_sl_cls.return_value = mock_db mock_db_repo = MagicMock() mock_db_repo.remote_url = "https://git.com/org/repo.git" mock_db_repo.config_id = 1 mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo mock_db_config = MagicMock() mock_db_config.url = "https://git.com" mock_db.query.return_value.filter.return_value.first.return_value = mock_db_config await mixin.push_changes(1) repo.git.push.assert_called_with("--set-upstream", "origin", "feature:feature") @pytest.mark.asyncio async def test_tracking_branch_push(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() repo.remote.return_value = origin current_branch = MagicMock() current_branch.name = "main" current_branch.tracking_branch.return_value = "origin/main" repo.active_branch = current_branch with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo): with patch("src.services.git._sync.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 # no db_repo await mixin.push_changes(1) origin.push.assert_called_once() @pytest.mark.asyncio async def test_push_git_command_error_nonfastforward(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() repo.remote.return_value = origin current_branch = MagicMock() current_branch.name = "main" current_branch.tracking_branch.return_value = None repo.active_branch = current_branch repo.git.push.side_effect = GitCommandError("push", "non-fast-forward") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo): with patch("src.services.git._sync.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 pytest.raises(HTTPException, match="conflict|rejected|409"): await mixin.push_changes(1) @pytest.mark.asyncio async def test_push_generic_exception(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() repo.remote.return_value = origin current_branch = MagicMock() current_branch.name = "main" current_branch.tracking_branch.return_value = None repo.active_branch = current_branch repo.git.push.side_effect = GitCommandError("push", "some error") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo): with patch("src.services.git._sync.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 pytest.raises(HTTPException, match="500"): await mixin.push_changes(1) class TestPullChanges: @pytest.mark.asyncio async def test_unfinished_merge_detected(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.working_tree_dir = "/tmp/repo" repo.active_branch.name = "main" with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("os.path.exists", return_value=True), \ patch.object(mixin, '_build_unfinished_merge_payload', return_value={ "repository_path": "/tmp/repo", "git_dir": "/tmp/repo/.git", "current_branch": "main", "merge_head": "abc123", "merge_message_preview": "Merge branch", "conflicts_count": 2, }, create=True): with pytest.raises(HTTPException, match="409"): await mixin.pull_changes(1) @pytest.mark.asyncio async def test_remote_branch_not_found(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.working_tree_dir = "/tmp/repo" current_branch = MagicMock() current_branch.name = "feature" repo.active_branch = current_branch origin = MagicMock() repo.remote.return_value = origin repo.refs = [] with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("os.path.exists", return_value=False): with pytest.raises(HTTPException, match="409"): await mixin.pull_changes(1) @pytest.mark.asyncio async def test_conflict_during_pull(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.working_tree_dir = "/tmp/repo" current_branch = MagicMock() current_branch.name = "main" repo.active_branch = current_branch origin = MagicMock() repo.remote.return_value = origin # Simulate remote branch existence ref = MagicMock() ref.name = "origin/main" repo.refs = [ref] repo.git.pull.side_effect = GitCommandError("pull", "conflict") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("os.path.exists", return_value=False): with pytest.raises(HTTPException, match="409"): await mixin.pull_changes(1) @pytest.mark.asyncio async def test_origin_not_found(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.working_tree_dir = "/tmp/repo" repo.active_branch.name = "main" repo.remote.side_effect = ValueError with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("os.path.exists", return_value=False): with pytest.raises(HTTPException, match="origin"): await mixin.pull_changes(1) @pytest.mark.asyncio async def test_generic_pull_error(self): from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.working_tree_dir = "/tmp/repo" current_branch = MagicMock() current_branch.name = "main" repo.active_branch = current_branch origin = MagicMock() repo.remote.return_value = origin ref = MagicMock() ref.name = "origin/main" repo.refs = [ref] repo.git.pull.side_effect = Exception("unknown error") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("os.path.exists", return_value=False): with pytest.raises(HTTPException, match="500"): await mixin.pull_changes(1) class TestPushChangesEdge: """push_changes — diagnostic and origin.urls exception paths.""" @pytest.mark.asyncio async def test_origin_urls_exception(self): """list(origin.urls) raises → caught, handled.""" from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() # list(origin.urls) raises exception type(origin).urls = PropertyMock(side_effect=Exception("urls error")) repo.remote.return_value = origin branch = MagicMock() branch.name = "main" branch.tracking_branch.return_value = None repo.active_branch = branch with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("src.services.git._sync.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 await mixin.push_changes(1) repo.git.push.assert_called_with("--set-upstream", "origin", "main:main") @pytest.mark.asyncio async def test_tracking_branch_exception(self): """tracking_branch() raises → handled as None.""" from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() origin.urls = ["https://git.com/org/repo.git"] repo.remote.return_value = origin branch = MagicMock() branch.name = "main" branch.tracking_branch.side_effect = Exception("no tracking") repo.active_branch = branch with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("src.services.git._sync.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 await mixin.push_changes(1) repo.git.push.assert_called_with("--set-upstream", "origin", "main:main") @pytest.mark.asyncio async def test_push_generic_git_command_error_no_match(self): """GitCommandError that is not non-fast-forward → HTTPException 500.""" from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() origin.urls = ["https://git.com/org/repo.git"] repo.remote.return_value = origin branch = MagicMock() branch.name = "main" branch.tracking_branch.return_value = None repo.active_branch = branch repo.git.push.side_effect = GitCommandError("push", "unknown git error") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("src.services.git._sync.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 pytest.raises(HTTPException) as exc_info: await mixin.push_changes(1) assert exc_info.value.status_code == 500 @pytest.mark.asyncio async def test_push_push_info_raising_exception(self): """origin.push raises generic Exception → HTTPException 500.""" from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() repo.remote.return_value = origin branch = MagicMock() branch.name = "main" branch.tracking_branch.return_value = None repo.active_branch = branch # Force the else branch (tracking_branch is None → repo.git.push) repo.git.push.return_value = None # won't be called because tracking_branch is None repo.git.push.side_effect = Exception("unexpected failure") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("src.services.git._sync.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 pytest.raises(HTTPException) as exc_info: await mixin.push_changes(1) assert exc_info.value.status_code == 500 @pytest.mark.asyncio async def test_push_with_origin_push_generic_error(self): """origin.push() generic exception → HTTPException 500.""" from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() mixin._align_origin_host_with_config = MagicMock(return_value=None) repo = MagicMock() repo.heads = [MagicMock()] origin = MagicMock() origin.urls = ["https://git.com/org/repo.git"] repo.remote.return_value = origin branch = MagicMock() branch.name = "main" branch.tracking_branch.return_value = MagicMock() # has tracking → uses origin.push repo.active_branch = branch origin.push.side_effect = Exception("generic push error") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("src.services.git._sync.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 pytest.raises(HTTPException) as exc_info: await mixin.push_changes(1) assert exc_info.value.status_code == 500 class TestPullChangesEdge: """pull_changes — remaining edge paths.""" @pytest.mark.asyncio async def test_origin_urls_exception_on_pull(self): """list(origin.urls) raises → caught, pull continues.""" from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.working_tree_dir = "/tmp/repo" repo.active_branch.name = "main" origin = MagicMock() # list(origin.urls) raises type(origin).urls = PropertyMock(side_effect=Exception("urls error")) repo.remote.return_value = origin ref = MagicMock() ref.name = "origin/main" repo.refs = [ref] with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("os.path.exists", return_value=False): await mixin.pull_changes(1) origin.fetch.assert_called_once_with(prune=True) repo.git.pull.assert_called_with("--no-rebase", "origin", "main") @pytest.mark.asyncio async def test_git_command_error_non_conflict(self): """GitCommandError that is not conflict → HTTPException 500.""" from src.services.git._sync import GitServiceSyncMixin mixin = GitServiceSyncMixin() mixin._locked = lambda x: contextlib.nullcontext() repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.working_tree_dir = "/tmp/repo" repo.active_branch.name = "main" origin = MagicMock() origin.urls = ["https://git.com/org/repo.git"] repo.remote.return_value = origin ref = MagicMock() ref.name = "origin/main" repo.refs = [ref] repo.git.pull.side_effect = GitCommandError("pull", "fatal: not a git repository") with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \ patch("os.path.exists", return_value=False): with pytest.raises(HTTPException) as exc_info: await mixin.pull_changes(1) assert exc_info.value.status_code == 500 # #endregion Test.Git.Sync.Edge