# #region Test.Git.Branch.Edge [C:3] [TYPE Module] [SEMANTICS test, git, branch, edge, coverage] # @BRIEF Edge case tests for GitServiceBranchMixin — gitflow error paths, list/create/checkout edge cases. # @RELATION BINDS_TO -> [GitServiceBranchMixin] 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 from src.services.git._branch import GitServiceBranchMixin class TestableGitBranch(GitServiceBranchMixin): """Concrete test class providing _locked and get_repo 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() # ── _ensure_gitflow_branches edge ── class TestEnsureGitflowBranchesEdge: """Edge paths in _ensure_gitflow_branches.""" def test_push_branch_failure(self): """Push branch to origin fails → HTTPException 500.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() main_head = MagicMock() main_head.name = "main" main_head.commit = MagicMock() repo.heads = [main_head] repo.head.commit = MagicMock() origin = MagicMock() origin.refs = [] origin.push.side_effect = Exception("push failed") repo.remote.return_value = origin svc = TestableGitBranch(repo) with pytest.raises(HTTPException, match="Failed to create default branch"): svc._ensure_gitflow_branches(repo, 1) def test_fetch_exception_swallowed(self): """origin.fetch() failure → caught, remote branches skipped.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() main_head = MagicMock() main_head.name = "main" main_head.commit = MagicMock() repo.heads = [main_head] repo.head.commit = MagicMock() origin = MagicMock() origin.fetch.side_effect = Exception("fetch failed") repo.remote.return_value = origin svc = TestableGitBranch(repo) # Should not raise — fetch failure is caught and logged svc._ensure_gitflow_branches(repo, 1) def test_push_missing_branch_raises(self): """Exception pushing branch to origin → HTTPException 500.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() main_head = MagicMock() main_head.name = "main" main_head.commit = MagicMock() dev_head = MagicMock() dev_head.name = "dev" dev_head.commit = MagicMock() repo.heads = [main_head, dev_head] repo.head.commit = MagicMock() origin = MagicMock() origin.refs = [MagicMock()] origin.refs[0].remote_head = "main" repo.remote.return_value = origin # Only dev is missing remote — push fails origin.push.side_effect = Exception("push error") svc = TestableGitBranch(repo) with pytest.raises(HTTPException, match="Failed to create default branch"): svc._ensure_gitflow_branches(repo, 1) def test_checkout_dev_failure_swallowed(self): """Checkout dev after gitflow fails → logged, not fatal.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() main_head = MagicMock() main_head.name = "main" main_head.commit = MagicMock() preprod_head = MagicMock() preprod_head.name = "preprod" preprod_head.commit = MagicMock() repo.heads = [main_head, preprod_head] repo.head.commit = MagicMock() repo.active_branch.name = "main" origin = MagicMock() origin.refs = [MagicMock(remote_head="main")] repo.remote.return_value = origin repo.create_head.side_effect = None repo.git.checkout.side_effect = Exception("checkout failed") svc = TestableGitBranch(repo) svc._ensure_gitflow_branches(repo, 1) # Should not raise — checkout failure is logged # ── list_branches edge ── class TestListBranchesEdge: """list_branches — skip-exception and fallback paths.""" @pytest.mark.asyncio async def test_skip_ref_on_exception(self): """Ref with no commit attribute → skipped, not failed.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() bad_ref = MagicMock() bad_ref.name = "refs/heads/dev" del bad_ref.commit # Simulate missing commit attribute repo.refs = [bad_ref] repo.active_branch.name = "dev" svc = TestableGitBranch(repo) result = await svc.list_branches(1) # Should still have dev entry from active_branch assert any(b["name"] == "dev" for b in result) @pytest.mark.asyncio async def test_active_branch_not_in_list_added(self): """Active branch not in refs list → appended.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() local_ref = MagicMock() local_ref.name = "refs/heads/main" local_ref.commit.hexsha = "abc" local_ref.commit.committed_date = 1700000000 local_ref.is_remote.return_value = False repo.refs = [local_ref] repo.active_branch.name = "dev" # dev not in refs svc = TestableGitBranch(repo) result = await svc.list_branches(1) names = [b["name"] for b in result] assert "dev" in names assert "main" in names @pytest.mark.asyncio async def test_detached_head_fallback_uses_refs(self): """Detached HEAD but refs exist → no fallback needed.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() local_ref = MagicMock() local_ref.name = "refs/heads/main" local_ref.commit.hexsha = "abc" local_ref.commit.committed_date = 1700000000 local_ref.is_remote.return_value = False repo.refs = [local_ref] type(repo.active_branch).name = PropertyMock(side_effect=TypeError("detached")) svc = TestableGitBranch(repo) result = await svc.list_branches(1) # Fallback 'dev' should not be added because refs exist assert any(b["name"] == "main" for b in result) # ── checkout_branch edge ── class TestCheckoutBranchEdge: """checkout_branch — file parsing in stderr and generic paths.""" @pytest.mark.asyncio async def test_local_changes_returns_409_with_error_code(self): """Local changes → HTTPException 409 with GIT_CHECKOUT_LOCAL_CHANGES.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() error = GitCommandError("checkout", "error") error.stderr = ( "error: Your local changes to the following files would be overwritten by checkout:\n" "\tconfig.yaml\n" ) repo.git.checkout.side_effect = error svc = TestableGitBranch(repo) with pytest.raises(HTTPException) as exc_info: await svc.checkout_branch(1, "other") assert exc_info.value.status_code == 409 detail = exc_info.value.detail assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES" # Files list is empty because stderr parsing requires line.startswith('\t') @pytest.mark.asyncio async def test_local_changes_parses_files_from_stderr(self): """Stderr with tab-prefixed files → files list populated (line 200).""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() error = GitCommandError("checkout", "error") error.stderr = ( "error: Your local changes to the following files would be overwritten:\n" "\tdashboard/config.yaml\n" "\tdata/schema.sql\n" ) repo.git.checkout.side_effect = error svc = TestableGitBranch(repo) with pytest.raises(HTTPException) as exc_info: await svc.checkout_branch(1, "other") detail = exc_info.value.detail assert "dashboard/config.yaml" in detail["files"] assert "data/schema.sql" in detail["files"] @pytest.mark.asyncio async def test_checkout_git_error_no_stderr(self): """GitCommandError with no stderr → generic 500.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() error = GitCommandError("checkout", "fatal: bad revision") error.stderr = "" repo.git.checkout.side_effect = error svc = TestableGitBranch(repo) with pytest.raises(HTTPException) as exc_info: await svc.checkout_branch(1, "nonexistent") assert exc_info.value.status_code == 500 # ── create_branch edge ── class TestCreateBranchEdge: """create_branch — exception handling.""" @pytest.mark.asyncio async def test_create_head_raises(self): """create_head exception → re-raised.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() repo.heads = [MagicMock()] repo.remotes = [MagicMock()] repo.commit.return_value = MagicMock() repo.create_head.side_effect = Exception("branch exists") svc = TestableGitBranch(repo) with pytest.raises(Exception, match="branch exists"): await svc.create_branch(1, "existing", "main") @pytest.mark.asyncio async def test_init_commit_already_exists(self): """Empty repo but README.md already exists → skips creation.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() repo.heads = [] repo.remotes = [] repo.working_dir = "/tmp/repo" new_branch = MagicMock() repo.create_head.return_value = new_branch svc = TestableGitBranch(repo) with patch("os.path.exists", return_value=True), \ patch("builtins.open", MagicMock()): result = await svc.create_branch(1, "feature", "main") repo.index.add.assert_called_with(["README.md"]) repo.index.commit.assert_called_with("Initial commit") # ── commit_changes edge ── class TestCommitChangesEdge: """commit_changes — edge paths.""" @pytest.mark.asyncio async def test_commit_with_empty_files_list(self): """Empty files list → stages all (not specific).""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() repo.is_dirty.return_value = True svc = TestableGitBranch(repo) await svc.commit_changes(1, "msg", files=[]) repo.git.add.assert_called_with(A=True) # ── _ensure_gitflow_branches specific branches ── class TestEnsureGitflowMissingMain: """_ensure_gitflow_branches — main branch missing from heads.""" def test_main_not_in_heads_created_from_base(self): """main not in local_heads → created from repo.head.commit.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() # No heads at all repo.heads = [] repo.head.commit = MagicMock() repo.active_branch.name = "dev" origin = MagicMock() origin.refs = [] repo.remote.return_value = origin svc = TestableGitBranch(repo) svc._ensure_gitflow_branches(repo, 1) # prod should be created from repo.head.commit repo.create_head.assert_any_call("prod", repo.head.commit) def test_active_branch_raises_no_checkout(self): """active_branch.name raises → current_branch=None, checkout dev attempted.""" from src.services.git._branch import GitServiceBranchMixin repo = MagicMock() main_head = MagicMock() main_head.name = "main" main_head.commit = MagicMock() repo.heads = [main_head] repo.head.commit = MagicMock() type(repo.active_branch).name = PropertyMock(side_effect=Exception("detached")) origin = MagicMock() origin.refs = [MagicMock(remote_head="main"), MagicMock(remote_head="dev"), MagicMock(remote_head="preprod")] repo.remote.return_value = origin svc = TestableGitBranch(repo) svc._ensure_gitflow_branches(repo, 1) # With current_branch=None, None != "dev" is True, so checkout("dev") is called repo.git.checkout.assert_called_once_with("dev") # #endregion Test.Git.Branch.Edge