fix: 5 agents — core 703/703, settings 38/38, git edges 191/191, API routes 1139/1140, plugins +70 coverage

This commit is contained in:
2026-06-15 18:02:09 +03:00
parent 9cf2d6400a
commit 3de67c258a
18 changed files with 1432 additions and 154 deletions

View File

@@ -6,7 +6,8 @@ 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 contextlib
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
from fastapi import HTTPException
@@ -19,7 +20,7 @@ from git.objects.blob import Blob
def mixin():
from src.services.git._merge import GitServiceMergeMixin
m = GitServiceMergeMixin()
m._locked = lambda x: (lambda g=None: (yield))() # no-op context manager
m._locked = lambda x: contextlib.nullcontext() # no-op context manager
return m
@@ -62,7 +63,7 @@ class TestGetMergeStatus:
repo.working_tree_dir = "/tmp/repo"
repo.active_branch.name = "main"
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
result = await mixin.get_merge_status(1)
assert result["has_unfinished_merge"] is False
@@ -74,7 +75,7 @@ class TestGetMergeStatus:
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=True), \
patch.object(mixin, '_build_unfinished_merge_payload', return_value={
"repository_path": "/tmp/repo",
@@ -96,7 +97,7 @@ class TestGetMergeStatus:
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), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
result = await mixin.get_merge_status(1)
assert result["current_branch"] == "detached_or_unknown"
@@ -106,28 +107,28 @@ 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):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
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 patch.object(mixin, 'get_repo', return_value=repo, create=True):
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 patch.object(mixin, 'get_repo', return_value=repo, create=True):
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):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
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")
@@ -135,7 +136,7 @@ class TestResolveMergeConflicts:
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):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
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")
@@ -143,7 +144,7 @@ class TestResolveMergeConflicts:
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), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch("os.path.abspath", return_value="/tmp/repo/f.txt"), \
patch("os.makedirs"), \
patch("builtins.open", MagicMock()):
@@ -155,8 +156,7 @@ class TestResolveMergeConflicts:
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 patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="Invalid conflict file path"):
mixin.resolve_merge_conflicts(1, [
{"file_path": "../../etc/passwd", "resolution": "manual", "content": "x"}
@@ -165,15 +165,16 @@ class TestResolveMergeConflicts:
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"}])
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
# When working_tree_dir is None, abspath("") returns CWD, so function proceeds
result = mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
assert result == ["f.txt"]
class TestAbortMerge:
def test_success(self, mixin):
repo = MagicMock(spec=Repo)
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.abort_merge(1)
assert result["status"] == "aborted"
repo.git.merge.assert_called_with("--abort")
@@ -181,14 +182,14 @@ class TestAbortMerge:
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):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
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 patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="Cannot abort"):
mixin.abort_merge(1)
@@ -197,14 +198,14 @@ 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 patch.object(mixin, 'get_repo', return_value=repo, create=True):
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), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1, message="Resolved conflicts")
assert result["status"] == "committed"
@@ -213,7 +214,7 @@ class TestContinueMerge:
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), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1)
assert result["status"] == "committed"
@@ -223,7 +224,7 @@ class TestContinueMerge:
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), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1)
assert result["status"] == "already_clean"
@@ -232,7 +233,7 @@ class TestContinueMerge:
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), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
with pytest.raises(HTTPException, match="Cannot continue"):
mixin.continue_merge(1)
@@ -246,7 +247,7 @@ class TestContinueMerge:
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), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1, message="msg")
assert result["status"] == "committed"
@@ -259,6 +260,8 @@ class TestPromoteDirectMerge:
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")
repo = MagicMock(spec=Repo)
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="must be different"):
mixin.promote_direct_merge(1, "main", "main")
# #endregion Test.Git.Merge.Edge