265 lines
11 KiB
Python
265 lines
11 KiB
Python
# #region Test.Git.Merge.Edge [C:4] [TYPE Module] [SEMANTICS test, git, merge, conflict, coverage]
|
|
# @BRIEF Edge case tests for GitServiceMergeMixin — merge operations, conflict resolution, abort, continue, promote.
|
|
# @RELATION BINDS_TO -> [GitServiceMergeMixin]
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
from unittest.mock import MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from git.exc import GitCommandError
|
|
from git import Repo
|
|
from git.objects.blob import Blob
|
|
|
|
|
|
@pytest.fixture
|
|
def mixin():
|
|
from src.services.git._merge import GitServiceMergeMixin
|
|
m = GitServiceMergeMixin()
|
|
m._locked = lambda x: (lambda g=None: (yield))() # no-op context manager
|
|
return m
|
|
|
|
|
|
class TestReadBlobText:
|
|
def test_none_blob(self, mixin):
|
|
assert mixin._read_blob_text(None) == ""
|
|
|
|
def test_decode_error(self, mixin):
|
|
blob = MagicMock(spec=Blob)
|
|
blob.data_stream.read.return_value = b"\xff\xfe"
|
|
result = mixin._read_blob_text(blob)
|
|
assert result == "" or result is not None
|
|
|
|
def test_success(self, mixin):
|
|
blob = MagicMock(spec=Blob)
|
|
blob.data_stream.read.return_value = b"hello world"
|
|
result = mixin._read_blob_text(blob)
|
|
assert result == "hello world"
|
|
|
|
|
|
class TestGetUnmergedFilePaths:
|
|
def test_success(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.return_value = {"file1.txt": [], "file2.txt": []}
|
|
result = mixin._get_unmerged_file_paths(repo)
|
|
assert result == ["file1.txt", "file2.txt"]
|
|
|
|
def test_exception(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.side_effect = Exception("error")
|
|
result = mixin._get_unmerged_file_paths(repo)
|
|
assert result == []
|
|
|
|
|
|
class TestGetMergeStatus:
|
|
@pytest.mark.asyncio
|
|
async def test_no_unfinished_merge(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
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=MagicMock, return_value=repo), \
|
|
patch("os.path.exists", return_value=False):
|
|
result = await mixin.get_merge_status(1)
|
|
assert result["has_unfinished_merge"] is False
|
|
assert result["current_branch"] == "main"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unfinished_merge(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
|
|
with patch.object(mixin, 'get_repo', new_callable=MagicMock, 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,
|
|
}):
|
|
result = await mixin.get_merge_status(1)
|
|
assert result["has_unfinished_merge"] is True
|
|
assert result["conflicts_count"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_branch_exception(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
repo.active_branch.name = "main"
|
|
type(repo).active_branch = PropertyMock(side_effect=Exception("no branch"))
|
|
|
|
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
|
|
patch("os.path.exists", return_value=False):
|
|
result = await mixin.get_merge_status(1)
|
|
assert result["current_branch"] == "detached_or_unknown"
|
|
|
|
|
|
class TestResolveMergeConflicts:
|
|
def test_no_resolutions(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
result = mixin.resolve_merge_conflicts(1, [])
|
|
assert result == []
|
|
|
|
def test_missing_file_path(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
with pytest.raises(HTTPException, match="file_path is required"):
|
|
mixin.resolve_merge_conflicts(1, [{"resolution": "mine"}])
|
|
|
|
def test_unsupported_strategy(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
with pytest.raises(HTTPException, match="Unsupported resolution"):
|
|
mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "invalid"}])
|
|
|
|
def test_mine_strategy(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
result = mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
|
|
assert result == ["f.txt"]
|
|
repo.git.checkout.assert_called_with("--ours", "--", "f.txt")
|
|
|
|
def test_theirs_strategy(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
result = mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "theirs"}])
|
|
assert result == ["f.txt"]
|
|
repo.git.checkout.assert_called_with("--theirs", "--", "f.txt")
|
|
|
|
def test_manual_strategy(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
with patch.object(mixin, 'get_repo', return_value=repo), \
|
|
patch("os.path.abspath", return_value="/tmp/repo/f.txt"), \
|
|
patch("os.makedirs"), \
|
|
patch("builtins.open", MagicMock()):
|
|
result = mixin.resolve_merge_conflicts(1, [
|
|
{"file_path": "f.txt", "resolution": "manual", "content": "new content"}
|
|
])
|
|
assert result == ["f.txt"]
|
|
|
|
def test_path_traversal_blocked(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
with patch.object(mixin, 'get_repo', return_value=repo), \
|
|
patch("os.path.abspath", return_value="/etc/passwd"):
|
|
with pytest.raises(HTTPException, match="Invalid conflict file path"):
|
|
mixin.resolve_merge_conflicts(1, [
|
|
{"file_path": "../../etc/passwd", "resolution": "manual", "content": "x"}
|
|
])
|
|
|
|
def test_empty_working_tree(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.working_tree_dir = None
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
with pytest.raises(HTTPException, match="unavailable"):
|
|
mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
|
|
|
|
|
|
class TestAbortMerge:
|
|
def test_success(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
result = mixin.abort_merge(1)
|
|
assert result["status"] == "aborted"
|
|
repo.git.merge.assert_called_with("--abort")
|
|
|
|
def test_no_merge_to_abort(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.git.merge.side_effect = GitCommandError("merge", "no merge to abort")
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
result = mixin.abort_merge(1)
|
|
assert result["status"] == "no_merge_in_progress"
|
|
|
|
def test_other_error(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.git.merge.side_effect = GitCommandError("merge", "some other error")
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
with pytest.raises(HTTPException, match="Cannot abort"):
|
|
mixin.abort_merge(1)
|
|
|
|
|
|
class TestContinueMerge:
|
|
def test_unresolved_conflicts(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.return_value = {"f.txt": [(1, MagicMock())]}
|
|
with patch.object(mixin, 'get_repo', return_value=repo):
|
|
with pytest.raises(HTTPException, match="GIT_MERGE_CONFLICTS_REMAIN"):
|
|
mixin.continue_merge(1)
|
|
|
|
def test_commit_with_message(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
with patch.object(mixin, 'get_repo', return_value=repo), \
|
|
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
|
|
result = mixin.continue_merge(1, message="Resolved conflicts")
|
|
assert result["status"] == "committed"
|
|
repo.git.commit.assert_called_with("-m", "Resolved conflicts")
|
|
|
|
def test_commit_no_message(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
with patch.object(mixin, 'get_repo', return_value=repo), \
|
|
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
|
|
result = mixin.continue_merge(1)
|
|
assert result["status"] == "committed"
|
|
repo.git.commit.assert_called_with("--no-edit")
|
|
|
|
def test_nothing_to_commit(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
repo.git.commit.side_effect = GitCommandError("commit", "nothing to commit")
|
|
with patch.object(mixin, 'get_repo', return_value=repo), \
|
|
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
|
|
result = mixin.continue_merge(1)
|
|
assert result["status"] == "already_clean"
|
|
|
|
def test_commit_error(self, mixin):
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
repo.git.commit.side_effect = GitCommandError("commit", "some error")
|
|
with patch.object(mixin, 'get_repo', return_value=repo), \
|
|
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
|
|
with pytest.raises(HTTPException, match="Cannot continue"):
|
|
mixin.continue_merge(1)
|
|
|
|
def test_commit_hash_exception(self, mixin):
|
|
"""Exception getting commit hash → empty string."""
|
|
repo = MagicMock(spec=Repo)
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
repo.head.commit.hexsha = "abc123"
|
|
def raiser():
|
|
raise Exception("no head")
|
|
repo.head.commit = MagicMock()
|
|
type(repo.head.commit).hexsha = PropertyMock(side_effect=Exception("no hexsha"))
|
|
with patch.object(mixin, 'get_repo', return_value=repo), \
|
|
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
|
|
result = mixin.continue_merge(1, message="msg")
|
|
assert result["status"] == "committed"
|
|
assert result["commit_hash"] == ""
|
|
|
|
|
|
class TestPromoteDirectMerge:
|
|
def test_raises_on_missing_branches(self, mixin):
|
|
with pytest.raises(HTTPException, match="required"):
|
|
mixin.promote_direct_merge(1, "", "target")
|
|
|
|
def test_raises_on_same_branches(self, mixin):
|
|
with pytest.raises(HTTPException, match="must be different"):
|
|
mixin.promote_direct_merge(1, "main", "main")
|
|
# #endregion Test.Git.Merge.Edge
|