Files
ss-tools/backend/tests/services/git/test_git_merge_coverage.py

202 lines
9.1 KiB
Python

# #region Test.Git.Merge.Coverage [C:3] [TYPE Module] [SEMANTICS test, git, merge, coverage, edge]
# @BRIEF Additional edge coverage for GitServiceMergeMixin — error-specific paths in _build_unfinished_merge_payload, resolve_merge_conflicts root, promote_direct_merge fallbacks.
# @RELATION BINDS_TO -> [GitServiceMergeMixin]
import contextlib
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
from fastapi import HTTPException
from git.exc import GitCommandError
@pytest.fixture
def mixin():
from src.services.git._merge import GitServiceMergeMixin
m = GitServiceMergeMixin()
m._locked = lambda x: contextlib.nullcontext()
return m
def _make_ref(name: str):
"""Create a simple mock object with .name returning the given string."""
r = MagicMock()
r.name = name
return r
class TestBuildUnfinishedMergePayload:
"""Cover edge-error handlers in _build_unfinished_merge_payload."""
def test_merge_head_read_error(self, mixin):
"""merge_head_path.read_text fails → merge_head = '<unreadable>'."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
repo.active_branch.name = "main"
repo.index.unmerged_blobs.return_value = {}
with patch("os.path.exists", return_value=True), \
patch("pathlib.Path.read_text", side_effect=[OSError("perm denied"), "merge msg"]):
result = mixin._build_unfinished_merge_payload(repo)
assert result["merge_head"] == "<unreadable>"
def test_merge_msg_read_error(self, mixin):
"""MERGE_MSG read fails → merge_message_preview = '<unreadable>'."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
repo.active_branch.name = "main"
repo.index.unmerged_blobs.return_value = {}
with patch("os.path.exists", return_value=True), \
patch("pathlib.Path.read_text", side_effect=["abc123", OSError("no msg")]):
result = mixin._build_unfinished_merge_payload(repo)
assert result["merge_message_preview"] == "<unreadable>"
def test_merge_msg_file_missing(self, mixin):
"""MERGE_MSG file does not exist → merge_message_preview = empty."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
repo.active_branch.name = "main"
repo.index.unmerged_blobs.return_value = {}
def exists_side(path):
return "MERGE_MSG" not in path
with patch("os.path.exists", side_effect=exists_side), \
patch("pathlib.Path.read_text", return_value="abc123"):
result = mixin._build_unfinished_merge_payload(repo)
assert result["merge_message_preview"] == ""
def test_active_branch_error(self, mixin):
"""active_branch.name raises → current_branch = 'detached_or_unknown'."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
type(repo.active_branch).name = PropertyMock(side_effect=Exception("detached"))
repo.index.unmerged_blobs.return_value = {}
with patch("os.path.exists", return_value=True), \
patch("pathlib.Path.read_text", return_value="abc123"):
result = mixin._build_unfinished_merge_payload(repo)
assert result["current_branch"] == "detached_or_unknown"
class TestResolveMergeConflicts:
"""Cover missing root error path."""
def test_empty_working_tree_dir_raises(self, mixin):
"""Empty string working_tree_dir → repo_root becomes CWD, but abspath returns ''."""
repo = MagicMock()
repo.working_tree_dir = ""
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch("os.path.abspath", return_value=""):
with pytest.raises(HTTPException, match="unavailable"):
mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
class TestPromoteDirectMerge:
"""Cover promote error-recovery paths."""
def test_original_branch_exception(self, mixin):
"""active_branch.name raises → original_branch = None."""
repo = MagicMock()
repo.heads = [_make_ref("dev"), _make_ref("main")]
repo.refs = [_make_ref("origin/dev"), _make_ref("origin/main")]
origin = MagicMock()
repo.remote.return_value = origin
type(repo.active_branch).name = PropertyMock(side_effect=[Exception("no branch"), "dev"])
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.promote_direct_merge(1, "dev", "main")
assert result["status"] == "merged"
def test_source_branch_from_origin_ref(self, mixin):
"""Source from origin/ref → checkout -b from origin."""
repo = MagicMock()
repo.heads = [_make_ref("main")]
repo.refs = [_make_ref("origin/dev"), _make_ref("origin/main")]
origin = MagicMock()
repo.remote.return_value = origin
type(repo.active_branch).name = PropertyMock(return_value="dev")
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.promote_direct_merge(1, "dev", "main")
assert result["status"] == "merged"
repo.git.checkout.assert_any_call("-b", "dev", "origin/dev")
def test_target_branch_from_origin_ref(self, mixin):
"""Target from origin/ref → checkout -b from origin."""
repo = MagicMock()
repo.heads = [_make_ref("dev")]
repo.refs = [_make_ref("origin/dev"), _make_ref("origin/staging")]
origin = MagicMock()
repo.remote.return_value = origin
type(repo.active_branch).name = PropertyMock(return_value="dev")
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.promote_direct_merge(1, "dev", "staging")
assert result["status"] == "merged"
def test_pull_target_failure_logged(self, mixin):
"""origin.pull(target) fails → logged, merge proceeds."""
repo = MagicMock()
repo.heads = [_make_ref("dev"), _make_ref("main")]
repo.refs = [_make_ref("origin/main")]
origin = MagicMock()
repo.remote.return_value = origin
type(repo.active_branch).name = PropertyMock(return_value="dev")
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.promote_direct_merge(1, "dev", "main")
assert result["status"] == "merged"
def test_merge_conflict_raises_409(self, mixin):
"""CONFLICT → HTTP 409."""
repo = MagicMock()
repo.heads = [_make_ref("dev"), _make_ref("main")]
repo.refs = [_make_ref("origin/main")]
origin = MagicMock()
repo.remote.return_value = origin
repo.git.merge.side_effect = Exception("CONFLICT in file.txt")
type(repo.active_branch).name = PropertyMock(return_value="dev")
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="Merge conflict"):
mixin.promote_direct_merge(1, "dev", "main")
def test_merge_generic_error_raises_500(self, mixin):
"""Generic error → HTTP 500."""
repo = MagicMock()
repo.heads = [_make_ref("dev"), _make_ref("main")]
repo.refs = [_make_ref("origin/main")]
origin = MagicMock()
repo.remote.return_value = origin
repo.git.merge.side_effect = Exception("some other error")
type(repo.active_branch).name = PropertyMock(return_value="dev")
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="Direct promote failed"):
mixin.promote_direct_merge(1, "dev", "main")
def test_restore_original_branch_failure(self, mixin):
"""Restore fails in finally → caught."""
repo = MagicMock()
repo.heads = [_make_ref("dev"), _make_ref("main")]
repo.refs = [_make_ref("origin/main")]
origin = MagicMock()
repo.remote.return_value = origin
repo.git.checkout.side_effect = [None, None, GeneratorExit("checkout fail")]
type(repo.active_branch).name = PropertyMock(return_value="dev")
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.promote_direct_merge(1, "dev", "main")
assert result["status"] == "merged"
def test_target_branch_not_found(self, mixin):
"""Target not found → HTTP 404."""
repo = MagicMock()
repo.heads = [_make_ref("dev")]
repo.refs = [_make_ref("origin/dev")]
origin = MagicMock()
repo.remote.return_value = origin
type(repo.active_branch).name = PropertyMock(return_value="dev")
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="not found"):
mixin.promote_direct_merge(1, "dev", "nonexistent")
# #endregion Test.Git.Merge.Coverage