# #region Test.Git.Sync [C:3] [TYPE Module] [SEMANTICS test,git,sync,push,pull,remote] # @BRIEF Tests for GitServiceSyncMixin — push_changes and pull_changes with origin host alignment, error mapping, and merge detection. # @RELATION BINDS_TO -> [GitServiceSyncMixin] # @TEST_EDGE: push_no_heads -> warning logged, no push attempted # @TEST_EDGE: push_no_origin -> HTTPException 400 # @TEST_EDGE: push_non_fast_forward -> HTTPException 409 # @TEST_EDGE: push_success -> origin.push called # @TEST_EDGE: push_no_tracking -> set-upstream push # @TEST_EDGE: pull_unfinished_merge -> HTTPException 409 # @TEST_EDGE: pull_no_origin -> HTTPException 400 # @TEST_EDGE: pull_no_remote_branch -> HTTPException 409 # @TEST_EDGE: pull_conflict -> HTTPException 409 # @TEST_EDGE: pull_success -> fetch + pull called import contextlib import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import pytest from unittest.mock import MagicMock, patch, PropertyMock from fastapi import HTTPException from git.exc import GitCommandError from src.services.git._sync import GitServiceSyncMixin from src.services.git._merge import GitServiceMergeMixin class TestableGitSync(GitServiceSyncMixin, GitServiceMergeMixin): """Concrete test class providing _locked, get_repo, and URL mixin stubs.""" def __init__(self, mock_repo=None): self._mock_repo = mock_repo or MagicMock() async def get_repo(self, dashboard_id): return self._mock_repo def _locked(self, dashboard_id): @contextlib.contextmanager def _lock(): yield return _lock() def _align_origin_host_with_config(self, **kwargs): return None # ── push_changes ── class TestPushChanges: """push_changes — push local commits to origin.""" # #region test_push_no_heads [C:2] [TYPE Function] @pytest.mark.asyncio async def test_push_no_heads(self): """No local branches → returns early, no push.""" repo = MagicMock() repo.heads = [] svc = TestableGitSync(repo) with patch("src.services.git._sync.SessionLocal"): result = await svc.push_changes(1) assert result is None # #endregion test_push_no_heads # #region test_push_no_origin [C:2] [TYPE Function] @pytest.mark.asyncio async def test_push_no_origin(self): """No origin remote → HTTPException 400.""" repo = MagicMock() repo.heads = [MagicMock()] repo.remote.side_effect = ValueError("no remote") svc = TestableGitSync(repo) with patch("src.services.git._sync.SessionLocal"): with pytest.raises(HTTPException, match="origin"): await svc.push_changes(1) # #endregion test_push_no_origin # #region test_push_success_with_tracking [C:2] [TYPE Function] @pytest.mark.asyncio async def test_push_success_with_tracking(self): """Branch with tracking → origin.push called.""" repo = MagicMock() repo.heads = [MagicMock()] branch = MagicMock() branch.name = "dev" branch.tracking_branch.return_value = MagicMock() repo.active_branch = branch origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] origin.push.return_value = [] repo.remote.return_value = origin svc = TestableGitSync(repo) with patch("src.services.git._sync.SessionLocal") as mock_session_cls: mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None await svc.push_changes(1) origin.push.assert_called_once() # #endregion test_push_success_with_tracking # #region test_push_no_tracking_set_upstream [C:2] [TYPE Function] @pytest.mark.asyncio async def test_push_no_tracking_set_upstream(self): """No tracking branch → --set-upstream push.""" repo = MagicMock() repo.heads = [MagicMock()] branch = MagicMock() branch.name = "feature" branch.tracking_branch.return_value = None repo.active_branch = branch origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] repo.remote.return_value = origin svc = TestableGitSync(repo) with patch("src.services.git._sync.SessionLocal") as mock_session_cls: mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None await svc.push_changes(1) repo.git.push.assert_called_with("--set-upstream", "origin", "feature:feature") # #endregion test_push_no_tracking_set_upstream # #region test_push_non_fast_forward [C:2] [TYPE Function] @pytest.mark.asyncio async def test_push_non_fast_forward(self): """Non-fast-forward rejection → HTTPException 409.""" repo = MagicMock() repo.heads = [MagicMock()] branch = MagicMock() branch.name = "dev" branch.tracking_branch.return_value = MagicMock() repo.active_branch = branch origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] origin.push.side_effect = GitCommandError("push", "rejected non-fast-forward") repo.remote.return_value = origin svc = TestableGitSync(repo) with patch("src.services.git._sync.SessionLocal") as mock_session_cls: mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None with pytest.raises(HTTPException) as exc_info: await svc.push_changes(1) assert exc_info.value.status_code == 409 # #endregion test_push_non_fast_forward # #region test_push_error_flags [C:2] [TYPE Function] @pytest.mark.asyncio async def test_push_error_flags(self): """Push info with ERROR flag → raises Exception.""" repo = MagicMock() repo.heads = [MagicMock()] branch = MagicMock() branch.name = "dev" branch.tracking_branch.return_value = MagicMock() repo.active_branch = branch origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] push_info = MagicMock() push_info.flags = push_info.ERROR push_info.remote_ref_string = "refs/heads/dev" push_info.summary = "remote error" origin.push.return_value = [push_info] repo.remote.return_value = origin svc = TestableGitSync(repo) with patch("src.services.git._sync.SessionLocal") as mock_session_cls: mock_session = MagicMock() mock_session_cls.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None with pytest.raises(HTTPException, match="push failed"): await svc.push_changes(1) # #endregion test_push_error_flags # ── pull_changes ── class TestPullChanges: """pull_changes — pull from origin with merge detection.""" # #region test_pull_unfinished_merge [C:2] [TYPE Function] @pytest.mark.asyncio async def test_pull_unfinished_merge(self): """MERGE_HEAD exists → HTTPException 409.""" repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.active_branch.name = "dev" repo.working_tree_dir = "/tmp/repo" repo.index.unmerged_blobs.return_value = {} svc = TestableGitSync(repo) with patch("os.path.exists", return_value=True), \ patch("pathlib.Path.read_text", return_value="merge_head_sha"): with pytest.raises(HTTPException) as exc_info: await svc.pull_changes(1) assert exc_info.value.status_code == 409 # #endregion test_pull_unfinished_merge # #region test_pull_no_origin [C:2] [TYPE Function] @pytest.mark.asyncio async def test_pull_no_origin(self): """No origin remote → HTTPException 400.""" repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.remote.side_effect = ValueError("no remote") svc = TestableGitSync(repo) with patch("os.path.exists", return_value=False): with pytest.raises(HTTPException, match="origin"): await svc.pull_changes(1) # #endregion test_pull_no_origin # #region test_pull_no_remote_branch [C:2] [TYPE Function] @pytest.mark.asyncio async def test_pull_no_remote_branch(self): """Remote branch doesn't exist → HTTPException 409.""" repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.active_branch.name = "feature" origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] repo.remote.return_value = origin repo.refs = [] # no origin/feature ref svc = TestableGitSync(repo) with patch("os.path.exists", return_value=False): with pytest.raises(HTTPException, match="does not exist"): await svc.pull_changes(1) # #endregion test_pull_no_remote_branch # #region test_pull_success [C:2] [TYPE Function] @pytest.mark.asyncio async def test_pull_success(self): """Successful pull → fetch + pull called.""" repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.active_branch.name = "dev" origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] repo.remote.return_value = origin remote_ref = MagicMock() remote_ref.name = "origin/dev" repo.refs = [remote_ref] svc = TestableGitSync(repo) with patch("os.path.exists", return_value=False): await svc.pull_changes(1) origin.fetch.assert_called_once_with(prune=True) repo.git.pull.assert_called_with("--no-rebase", "origin", "dev") # #endregion test_pull_success # #region test_pull_conflict [C:2] [TYPE Function] @pytest.mark.asyncio async def test_pull_conflict(self): """Merge conflict during pull → HTTPException 409.""" repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.active_branch.name = "dev" origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] repo.remote.return_value = origin remote_ref = MagicMock() remote_ref.name = "origin/dev" repo.refs = [remote_ref] repo.git.pull.side_effect = GitCommandError("pull", "CONFLICT content") svc = TestableGitSync(repo) with patch("os.path.exists", return_value=False): with pytest.raises(HTTPException) as exc_info: await svc.pull_changes(1) assert exc_info.value.status_code == 409 # #endregion test_pull_conflict # #region test_pull_generic_error [C:2] [TYPE Function] @pytest.mark.asyncio async def test_pull_generic_error(self): """Generic exception → HTTPException 500.""" repo = MagicMock() repo.git_dir = "/tmp/repo/.git" repo.active_branch.name = "dev" origin = MagicMock() origin.urls = ["https://gitea.com/org/repo.git"] repo.remote.return_value = origin remote_ref = MagicMock() remote_ref.name = "origin/dev" repo.refs = [remote_ref] repo.git.pull.side_effect = Exception("network failure") svc = TestableGitSync(repo) with patch("os.path.exists", return_value=False): with pytest.raises(HTTPException) as exc_info: await svc.pull_changes(1) assert exc_info.value.status_code == 500 # #endregion test_pull_generic_error # #endregion Test.Git.Sync