# #region Test.Git.Branch [C:3] [TYPE Module] [SEMANTICS test,git,branch,checkout,commit,gitflow] # @BRIEF Tests for GitServiceBranchMixin — gitflow bootstrap, list/create/checkout branches, commit changes. # @RELATION BINDS_TO -> [GitServiceBranchMixin] # @TEST_EDGE: empty_repo -> initial commit created for branching # @TEST_EDGE: no_commits -> gitflow bootstrap skipped # @TEST_EDGE: checkout_local_changes -> HTTPException 409 # @TEST_EDGE: checkout_generic_error -> HTTPException 500 # @TEST_EDGE: commit_dirty -> stages all and commits # @TEST_EDGE: commit_clean -> no commit when not dirty # @TEST_EDGE: commit_specific_files -> only listed files staged # @TEST_EDGE: list_branches_filters_tags -> refs/tags/ excluded # @TEST_EDGE: create_branch_from_missing -> falls back to HEAD import contextlib import os 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, call, PropertyMock 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 ── class TestEnsureGitflowBranches: """_ensure_gitflow_branches — main/dev/preprod bootstrap.""" # #region test_gitflow_no_commits [C:2] [TYPE Function] def test_gitflow_no_commits(self): """Empty repo (no commits) → skip bootstrap, no error.""" repo = MagicMock() type(repo.head).commit = property(lambda self: (_ for _ in StopIteration()).throw(ValueError("no commits"))) repo.heads = [] svc = TestableGitBranch() svc._ensure_gitflow_branches(repo, 1) repo.create_head.assert_not_called() # #endregion test_gitflow_no_commits # #region test_gitflow_creates_missing_branches [C:2] [TYPE Function] def test_gitflow_creates_missing_branches(self): """Missing dev/preprod → created from main.""" 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 = [] repo.remote.return_value = origin svc = TestableGitBranch() svc._ensure_gitflow_branches(repo, 1) created_names = [c.args[0] for c in repo.create_head.call_args_list] assert "dev" in created_names assert "preprod" in created_names # #endregion test_gitflow_creates_missing_branches # #region test_gitflow_no_origin [C:2] [TYPE Function] def test_gitflow_no_origin(self): """No origin remote → skip remote push, no error.""" repo = MagicMock() main_head = MagicMock() main_head.name = "main" main_head.commit = MagicMock() repo.heads = [main_head] repo.head.commit = MagicMock() repo.remote.side_effect = ValueError("no origin") svc = TestableGitBranch() # Should not raise svc._ensure_gitflow_branches(repo, 1) # #endregion test_gitflow_no_origin # #region test_gitflow_already_on_dev [C:2] [TYPE Function] def test_gitflow_already_on_dev(self): """Already on dev → no checkout call.""" repo = MagicMock() main_head = MagicMock() main_head.name = "main" main_head.commit = MagicMock() dev_head = MagicMock() dev_head.name = "dev" dev_head.commit = MagicMock() preprod_head = MagicMock() preprod_head.name = "preprod" preprod_head.commit = MagicMock() repo.heads = [main_head, dev_head, preprod_head] repo.head.commit = MagicMock() repo.active_branch.name = "dev" origin = MagicMock() origin.refs = [MagicMock(remote_head="main"), MagicMock(remote_head="dev"), MagicMock(remote_head="preprod")] repo.remote.return_value = origin svc = TestableGitBranch() svc._ensure_gitflow_branches(repo, 1) repo.git.checkout.assert_not_called() # #endregion test_gitflow_already_on_dev # ── list_branches ── class TestListBranches: """list_branches — branch listing with tag filtering.""" # #region test_list_branches_basic [C:2] [TYPE Function] @pytest.mark.asyncio async def test_list_branches_basic(self): """Returns branch dicts with name, commit_hash, is_remote.""" repo = MagicMock() ref1 = MagicMock() ref1.name = "refs/heads/dev" ref1.commit.hexsha = "abc123" ref1.commit.committed_date = 1700000000 ref1.is_remote.return_value = False repo.refs = [ref1] repo.active_branch.name = "dev" svc = TestableGitBranch(repo) result = await svc.list_branches(1) assert any(b["name"] == "dev" for b in result) assert result[0]["commit_hash"] == "abc123" # #endregion test_list_branches_basic # #region test_list_branches_filters_tags [C:2] [TYPE Function] @pytest.mark.asyncio async def test_list_branches_filters_tags(self): """refs/tags/ entries excluded from result.""" repo = MagicMock() tag_ref = MagicMock() tag_ref.name = "refs/tags/v1.0" tag_ref.commit.hexsha = "tag123" tag_ref.commit.committed_date = 1700000000 branch_ref = MagicMock() branch_ref.name = "refs/heads/main" branch_ref.commit.hexsha = "main123" branch_ref.commit.committed_date = 1700000000 branch_ref.is_remote.return_value = False repo.refs = [tag_ref, branch_ref] repo.active_branch.name = "main" svc = TestableGitBranch(repo) result = await svc.list_branches(1) names = [b["name"] for b in result] assert "v1.0" not in names assert "main" in names # #endregion test_list_branches_filters_tags # #region test_list_branches_deduplicates [C:2] [TYPE Function] @pytest.mark.asyncio async def test_list_branches_deduplicates(self): """Same name from heads and remotes → only one entry.""" repo = MagicMock() local_ref = MagicMock() local_ref.name = "refs/heads/dev" local_ref.commit.hexsha = "abc" local_ref.commit.committed_date = 1700000000 local_ref.is_remote.return_value = False remote_ref = MagicMock() remote_ref.name = "refs/remotes/origin/dev" remote_ref.commit.hexsha = "abc" remote_ref.commit.committed_date = 1700000000 remote_ref.is_remote.return_value = True repo.refs = [local_ref, remote_ref] repo.active_branch.name = "dev" svc = TestableGitBranch(repo) result = await svc.list_branches(1) dev_entries = [b for b in result if b["name"] == "dev"] assert len(dev_entries) == 1 # #endregion test_list_branches_deduplicates # #region test_list_branches_detached_head [C:2] [TYPE Function] @pytest.mark.asyncio async def test_list_branches_detached_head(self): """Detached HEAD → fallback 'dev' branch added if empty.""" repo = MagicMock() repo.refs = [] type(repo.active_branch).name = PropertyMock(side_effect=TypeError("detached HEAD")) svc = TestableGitBranch(repo) result = await svc.list_branches(1) assert any(b["name"] == "dev" for b in result) # #endregion test_list_branches_detached_head # ── create_branch ── class TestCreateBranch: """create_branch — new branch from existing.""" # #region test_create_branch_success [C:2] [TYPE Function] @pytest.mark.asyncio async def test_create_branch_success(self): """Branch created from specified source.""" repo = MagicMock() repo.heads = [MagicMock()] repo.remotes = [MagicMock()] repo.commit.return_value = MagicMock() new_branch = MagicMock() repo.create_head.return_value = new_branch svc = TestableGitBranch(repo) result = await svc.create_branch(1, "feature-x", "main") repo.create_head.assert_called_with("feature-x", "main") assert result == new_branch # #endregion test_create_branch_success # #region test_create_branch_empty_repo [C:2] [TYPE Function] @pytest.mark.asyncio async def test_create_branch_empty_repo(self): """Empty repo → initial commit created, then branch.""" 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=False), \ 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") # #endregion test_create_branch_empty_repo # #region test_create_branch_missing_source [C:2] [TYPE Function] @pytest.mark.asyncio async def test_create_branch_missing_source(self): """Source branch not found → falls back to repo.head.""" repo = MagicMock() repo.heads = [MagicMock()] repo.remotes = [MagicMock()] repo.commit.side_effect = Exception("not found") repo.head = MagicMock() repo.create_head.return_value = MagicMock() svc = TestableGitBranch(repo) await svc.create_branch(1, "feature", "nonexistent") repo.create_head.assert_called_with("feature", repo.head) # #endregion test_create_branch_missing_source # ── checkout_branch ── class TestCheckoutBranch: """checkout_branch — switch active branch.""" # #region test_checkout_success [C:2] [TYPE Function] @pytest.mark.asyncio async def test_checkout_success(self): """Successful checkout → git.checkout called.""" repo = MagicMock() svc = TestableGitBranch(repo) await svc.checkout_branch(1, "main") repo.git.checkout.assert_called_with("main") # #endregion test_checkout_success # #region test_checkout_local_changes [C:2] [TYPE Function] @pytest.mark.asyncio async def test_checkout_local_changes(self): """Local changes conflict → HTTPException 409.""" repo = MagicMock() error = GitCommandError("checkout", "error") error.stderr = "error: Your local changes to the following files would be overwritten by checkout:\n\tconfig.yaml" repo.git.checkout.side_effect = error svc = TestableGitBranch(repo) with pytest.raises(HTTPException) as exc_info: await svc.checkout_branch(1, "main") assert exc_info.value.status_code == 409 detail = exc_info.value.detail assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES" # #endregion test_checkout_local_changes # #region test_checkout_generic_error [C:2] [TYPE Function] @pytest.mark.asyncio async def test_checkout_generic_error(self): """Other GitCommandError → HTTPException 500.""" repo = MagicMock() repo.git.checkout.side_effect = GitCommandError("checkout", "fatal: bad ref") svc = TestableGitBranch(repo) with pytest.raises(HTTPException) as exc_info: await svc.checkout_branch(1, "bad-branch") assert exc_info.value.status_code == 500 # #endregion test_checkout_generic_error # ── commit_changes ── class TestCommitChanges: """commit_changes — stage and commit.""" # #region test_commit_dirty_all [C:2] [TYPE Function] @pytest.mark.asyncio async def test_commit_dirty_all(self): """Dirty repo, no files → stage all and commit.""" repo = MagicMock() repo.is_dirty.return_value = True svc = TestableGitBranch(repo) await svc.commit_changes(1, "update config") repo.git.add.assert_called_with(A=True) repo.index.commit.assert_called_with("update config") # #endregion test_commit_dirty_all # #region test_commit_clean [C:2] [TYPE Function] @pytest.mark.asyncio async def test_commit_clean(self): """Clean repo, no files → no commit.""" repo = MagicMock() repo.is_dirty.return_value = False svc = TestableGitBranch(repo) await svc.commit_changes(1, "nothing") repo.index.commit.assert_not_called() # #endregion test_commit_clean # #region test_commit_specific_files [C:2] [TYPE Function] @pytest.mark.asyncio async def test_commit_specific_files(self): """Files list → only those files staged.""" repo = MagicMock() repo.is_dirty.return_value = False svc = TestableGitBranch(repo) await svc.commit_changes(1, "fix", files=["a.yaml", "b.yaml"]) repo.index.add.assert_called_with(["a.yaml", "b.yaml"]) repo.index.commit.assert_called_with("fix") # #endregion test_commit_specific_files # #endregion Test.Git.Branch