Systematic rename of all semantic anchors (#region, [DEF], @RELATION) across 1400+ files — backend Python, frontend Svelte/TS, specs, docs: - Flat anchors become Namespace.Module.Entity - @RELATION references updated to match new anchor paths - Zero business logic changes
436 lines
18 KiB
Python
436 lines
18 KiB
Python
# #region Test.Git.Merge [C:3] [TYPE Module] [SEMANTICS test,git,merge,conflict,resolution]
|
|
# @BRIEF Tests for GitServiceMergeMixin — merge status, conflict listing, resolution, abort, continue, direct promote.
|
|
# @RELATION BINDS_TO -> [Services.Merge.GitServiceMergeMixin]
|
|
# @TEST_EDGE: no_merge_in_progress -> has_unfinished_merge=false
|
|
# @TEST_EDGE: unfinished_merge -> MERGE_HEAD present, payload built
|
|
# @TEST_EDGE: conflict_list -> unmerged blobs parsed with mine/theirs
|
|
# @TEST_EDGE: resolve_mine -> checkout --ours called
|
|
# @TEST_EDGE: resolve_theirs -> checkout --theirs called
|
|
# @TEST_EDGE: resolve_manual -> file written with content
|
|
# @TEST_EDGE: resolve_invalid_strategy -> HTTPException 400
|
|
# @TEST_EDGE: resolve_empty_filepath -> HTTPException 400
|
|
# @TEST_EDGE: abort_no_merge -> returns no_merge_in_progress
|
|
# @TEST_EDGE: continue_with_conflicts -> HTTPException 409
|
|
# @TEST_EDGE: promote_same_branch -> HTTPException 400
|
|
# @TEST_EDGE: promote_missing_branch -> HTTPException 404
|
|
|
|
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 AsyncMock, MagicMock, patch, mock_open
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from src.services.git._merge import GitServiceMergeMixin
|
|
|
|
|
|
class TestableGitMerge(GitServiceMergeMixin):
|
|
"""Concrete test class providing _locked stub. get_repo must be set per-test."""
|
|
def __init__(self, mock_repo=None):
|
|
self._mock_repo = mock_repo or MagicMock()
|
|
|
|
def _locked(self, dashboard_id):
|
|
@contextlib.contextmanager
|
|
def _lock():
|
|
yield
|
|
return _lock()
|
|
|
|
|
|
# ── _read_blob_text ──
|
|
|
|
class TestReadBlobText:
|
|
"""_read_blob_text — blob content decoding."""
|
|
|
|
# #region Test.Git.TestBlobNone [C:2] [TYPE Function]
|
|
def test_blob_none(self):
|
|
"""None blob returns empty string."""
|
|
svc = TestableGitMerge()
|
|
assert svc._read_blob_text(None) == ""
|
|
# #endregion Test.Git.TestBlobNone
|
|
|
|
# #region Test.Git.TestBlobValid [C:2] [TYPE Function]
|
|
def test_blob_valid(self):
|
|
"""Valid blob decoded as UTF-8."""
|
|
svc = TestableGitMerge()
|
|
blob = MagicMock()
|
|
blob.data_stream.read.return_value = b"hello world"
|
|
assert svc._read_blob_text(blob) == "hello world"
|
|
# #endregion Test.Git.TestBlobValid
|
|
|
|
# #region Test.Git.TestBlobDecodeError [C:2] [TYPE Function]
|
|
def test_blob_decode_error(self):
|
|
"""Decode exception returns empty string."""
|
|
svc = TestableGitMerge()
|
|
blob = MagicMock()
|
|
blob.data_stream.read.side_effect = Exception("binary data")
|
|
assert svc._read_blob_text(blob) == ""
|
|
# #endregion Test.Git.TestBlobDecodeError
|
|
|
|
|
|
# ── _get_unmerged_file_paths ──
|
|
|
|
class TestGetUnmergedFilePaths:
|
|
"""_get_unmerged_file_paths — list conflicted files."""
|
|
|
|
# #region Test.Git.TestUnmergedFiles [C:2] [TYPE Function]
|
|
def test_unmerged_files(self):
|
|
"""Returns sorted list of conflicted file paths."""
|
|
svc = TestableGitMerge()
|
|
repo = MagicMock()
|
|
repo.index.unmerged_blobs.return_value = {"b.txt": [], "a.txt": []}
|
|
result = svc._get_unmerged_file_paths(repo)
|
|
assert result == ["a.txt", "b.txt"]
|
|
# #endregion Test.Git.TestUnmergedFiles
|
|
|
|
# #region Test.Git.TestUnmergedException [C:2] [TYPE Function]
|
|
def test_unmerged_exception(self):
|
|
"""Exception returns empty list."""
|
|
svc = TestableGitMerge()
|
|
repo = MagicMock()
|
|
repo.index.unmerged_blobs.side_effect = Exception("index error")
|
|
assert svc._get_unmerged_file_paths(repo) == []
|
|
# #endregion Test.Git.TestUnmergedException
|
|
|
|
|
|
# ── get_merge_status ──
|
|
|
|
class TestGetMergeStatus:
|
|
"""get_merge_status — unfinished merge detection."""
|
|
|
|
# #region Test.Git.TestNoMergeInProgress [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_no_merge_in_progress(self):
|
|
"""No MERGE_HEAD → has_unfinished_merge=false."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/fake_repo/.git"
|
|
repo.active_branch.name = "dev"
|
|
repo.working_tree_dir = "/tmp/fake_repo"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = AsyncMock(return_value=repo)
|
|
with patch("os.path.exists", return_value=False):
|
|
result = await svc.get_merge_status(1)
|
|
assert result["has_unfinished_merge"] is False
|
|
assert result["current_branch"] == "dev"
|
|
assert result["conflicts_count"] == 0
|
|
# #endregion Test.Git.TestNoMergeInProgress
|
|
|
|
# #region Test.Git.TestMergeInProgress [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_merge_in_progress(self):
|
|
"""MERGE_HEAD exists → has_unfinished_merge=true."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/fake_repo/.git"
|
|
repo.active_branch.name = "dev"
|
|
repo.working_tree_dir = "/tmp/fake_repo"
|
|
repo.index.unmerged_blobs.return_value = {"conflict.txt": [(2, MagicMock()), (3, MagicMock())]}
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = AsyncMock(return_value=repo)
|
|
with patch("os.path.exists", return_value=True), \
|
|
patch("pathlib.Path.read_text", return_value="abc123def456"):
|
|
result = await svc.get_merge_status(1)
|
|
assert result["has_unfinished_merge"] is True
|
|
assert result["merge_head"] == "abc123def456"
|
|
assert result["conflicts_count"] >= 0
|
|
# #endregion Test.Git.TestMergeInProgress
|
|
|
|
|
|
# ── get_merge_conflicts ──
|
|
|
|
class TestGetMergeConflicts:
|
|
"""get_merge_conflicts — list conflict details."""
|
|
|
|
# #region Test.Git.TestConflictsListed [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_conflicts_listed(self):
|
|
"""Unmerged blobs parsed into mine/theirs content."""
|
|
repo = MagicMock()
|
|
mine_blob = MagicMock()
|
|
mine_blob.data_stream.read.return_value = b"mine content"
|
|
theirs_blob = MagicMock()
|
|
theirs_blob.data_stream.read.return_value = b"theirs content"
|
|
repo.index.unmerged_blobs.return_value = {
|
|
"file.txt": [(2, mine_blob), (3, theirs_blob)],
|
|
}
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = AsyncMock(return_value=repo)
|
|
result = await svc.get_merge_conflicts(1)
|
|
assert len(result) == 1
|
|
assert result[0]["file_path"] == "file.txt"
|
|
assert result[0]["mine"] == "mine content"
|
|
assert result[0]["theirs"] == "theirs content"
|
|
# #endregion Test.Git.TestConflictsListed
|
|
|
|
# #region Test.Git.TestNoConflicts [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_no_conflicts(self):
|
|
"""No unmerged blobs → empty list."""
|
|
repo = MagicMock()
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = AsyncMock(return_value=repo)
|
|
result = await svc.get_merge_conflicts(1)
|
|
assert result == []
|
|
# #endregion Test.Git.TestNoConflicts
|
|
|
|
|
|
# ── resolve_merge_conflicts ──
|
|
|
|
class TestResolveMergeConflicts:
|
|
"""resolve_merge_conflicts — strategy-based resolution."""
|
|
|
|
# #region Test.Git.TestResolveMine [C:2] [TYPE Function]
|
|
def test_resolve_mine(self):
|
|
"""Strategy 'mine' → checkout --ours."""
|
|
repo = MagicMock()
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.resolve_merge_conflicts(1, [{"file_path": "a.txt", "resolution": "mine"}])
|
|
repo.git.checkout.assert_called_with("--ours", "--", "a.txt")
|
|
repo.git.add.assert_called_with("a.txt")
|
|
assert result == ["a.txt"]
|
|
# #endregion Test.Git.TestResolveMine
|
|
|
|
# #region Test.Git.TestResolveTheirs [C:2] [TYPE Function]
|
|
def test_resolve_theirs(self):
|
|
"""Strategy 'theirs' → checkout --theirs."""
|
|
repo = MagicMock()
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.resolve_merge_conflicts(1, [{"file_path": "b.txt", "resolution": "theirs"}])
|
|
repo.git.checkout.assert_called_with("--theirs", "--", "b.txt")
|
|
assert result == ["b.txt"]
|
|
# #endregion Test.Git.TestResolveTheirs
|
|
|
|
# #region Test.Git.TestResolveManual [C:2] [TYPE Function]
|
|
def test_resolve_manual(self):
|
|
"""Strategy 'manual' → writes content to file."""
|
|
repo = MagicMock()
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with patch("builtins.open", mock_open()) as mf, \
|
|
patch("os.makedirs"), \
|
|
patch("os.path.abspath", side_effect=lambda p: p if p.startswith("/") else f"/tmp/repo/{p}"):
|
|
result = svc.resolve_merge_conflicts(1, [
|
|
{"file_path": "c.txt", "resolution": "manual", "content": "resolved text"},
|
|
])
|
|
assert result == ["c.txt"]
|
|
repo.git.add.assert_called_with("c.txt")
|
|
# #endregion Test.Git.TestResolveManual
|
|
|
|
# #region Test.Git.TestResolveInvalidStrategy [C:2] [TYPE Function]
|
|
def test_resolve_invalid_strategy(self):
|
|
"""Unknown strategy → HTTPException 400."""
|
|
repo = MagicMock()
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException, match="Unsupported"):
|
|
svc.resolve_merge_conflicts(1, [{"file_path": "x.txt", "resolution": "invalid"}])
|
|
# #endregion Test.Git.TestResolveInvalidStrategy
|
|
|
|
# #region Test.Git.TestResolveEmptyFilepath [C:2] [TYPE Function]
|
|
def test_resolve_empty_filepath(self):
|
|
"""Missing file_path → HTTPException 400."""
|
|
repo = MagicMock()
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException, match="file_path is required"):
|
|
svc.resolve_merge_conflicts(1, [{"file_path": "", "resolution": "mine"}])
|
|
# #endregion Test.Git.TestResolveEmptyFilepath
|
|
|
|
# #region Test.Git.TestResolveEmptyResolutions [C:2] [TYPE Function]
|
|
def test_resolve_empty_resolutions(self):
|
|
"""Empty resolutions list → returns []."""
|
|
repo = MagicMock()
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
assert svc.resolve_merge_conflicts(1, []) == []
|
|
assert svc.resolve_merge_conflicts(1, None) == []
|
|
# #endregion Test.Git.TestResolveEmptyResolutions
|
|
|
|
|
|
# ── abort_merge ──
|
|
|
|
class TestAbortMerge:
|
|
"""abort_merge — cancel ongoing merge."""
|
|
|
|
# #region Test.Git.TestAbortSuccess [C:2] [TYPE Function]
|
|
def test_abort_success(self):
|
|
"""Successful abort → status='aborted'."""
|
|
repo = MagicMock()
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.abort_merge(1)
|
|
repo.git.merge.assert_called_with("--abort")
|
|
assert result["status"] == "aborted"
|
|
# #endregion Test.Git.TestAbortSuccess
|
|
|
|
# #region Test.Git.TestAbortNoMerge [C:2] [TYPE Function]
|
|
def test_abort_no_merge(self):
|
|
"""No merge in progress → status='no_merge_in_progress'."""
|
|
from git.exc import GitCommandError
|
|
repo = MagicMock()
|
|
repo.git.merge.side_effect = GitCommandError("merge", "fatal: There is no merge to abort")
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.abort_merge(1)
|
|
assert result["status"] == "no_merge_in_progress"
|
|
# #endregion Test.Git.TestAbortNoMerge
|
|
|
|
# #region Test.Git.TestAbortConflict [C:2] [TYPE Function]
|
|
def test_abort_conflict(self):
|
|
"""Other GitCommandError → HTTPException 409."""
|
|
from git.exc import GitCommandError
|
|
repo = MagicMock()
|
|
repo.git.merge.side_effect = GitCommandError("merge", "fatal: something else")
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException, match="Cannot abort"):
|
|
svc.abort_merge(1)
|
|
# #endregion Test.Git.TestAbortConflict
|
|
|
|
|
|
# ── continue_merge ──
|
|
|
|
class TestContinueMerge:
|
|
"""continue_merge — finalize merge after resolution."""
|
|
|
|
# #region Test.Git.TestContinueWithMessage [C:2] [TYPE Function]
|
|
def test_continue_with_message(self):
|
|
"""Commit with custom message."""
|
|
repo = MagicMock()
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
repo.head.commit.hexsha = "abc123"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.continue_merge(1, message="resolve conflicts")
|
|
repo.git.commit.assert_called_with("-m", "resolve conflicts")
|
|
assert result["status"] == "committed"
|
|
assert result["commit_hash"] == "abc123"
|
|
# #endregion Test.Git.TestContinueWithMessage
|
|
|
|
# #region Test.Git.TestContinueNoMessage [C:2] [TYPE Function]
|
|
def test_continue_no_message(self):
|
|
"""No message → --no-edit."""
|
|
repo = MagicMock()
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
repo.head.commit.hexsha = "def456"
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.continue_merge(1)
|
|
repo.git.commit.assert_called_with("--no-edit")
|
|
assert result["status"] == "committed"
|
|
# #endregion Test.Git.TestContinueNoMessage
|
|
|
|
# #region Test.Git.TestContinueUnresolvedConflicts [C:2] [TYPE Function]
|
|
def test_continue_unresolved_conflicts(self):
|
|
"""Unmerged files remain → HTTPException 409."""
|
|
repo = MagicMock()
|
|
repo.index.unmerged_blobs.return_value = {"file.txt": [(2, MagicMock())]}
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
svc.continue_merge(1)
|
|
assert exc_info.value.status_code == 409
|
|
# #endregion Test.Git.TestContinueUnresolvedConflicts
|
|
|
|
# #region Test.Git.TestContinueNothingToCommit [C:2] [TYPE Function]
|
|
def test_continue_nothing_to_commit(self):
|
|
"""Nothing to commit → status='already_clean'."""
|
|
from git.exc import GitCommandError
|
|
repo = MagicMock()
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
repo.git.commit.side_effect = GitCommandError("commit", "nothing to commit")
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.continue_merge(1)
|
|
assert result["status"] == "already_clean"
|
|
# #endregion Test.Git.TestContinueNothingToCommit
|
|
|
|
|
|
# ── promote_direct_merge ──
|
|
|
|
class TestPromoteDirectMerge:
|
|
"""promote_direct_merge — direct branch-to-branch merge."""
|
|
|
|
# #region Test.Git.TestPromoteSameBranch [C:2] [TYPE Function]
|
|
def test_promote_same_branch(self):
|
|
"""Same from/to → HTTPException 400."""
|
|
repo = MagicMock()
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException, match="must be different"):
|
|
svc.promote_direct_merge(1, "dev", "dev")
|
|
# #endregion Test.Git.TestPromoteSameBranch
|
|
|
|
# #region Test.Git.TestPromoteEmptyBranch [C:2] [TYPE Function]
|
|
def test_promote_empty_branch(self):
|
|
"""Empty branch name → HTTPException 400."""
|
|
repo = MagicMock()
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException, match="required"):
|
|
svc.promote_direct_merge(1, "", "main")
|
|
# #endregion Test.Git.TestPromoteEmptyBranch
|
|
|
|
# #region Test.Git.TestPromoteSuccess [C:2] [TYPE Function]
|
|
def test_promote_success(self):
|
|
"""Successful merge → status='merged'."""
|
|
repo = MagicMock()
|
|
repo.active_branch.name = "dev"
|
|
repo.heads = [MagicMock(name="dev"), MagicMock(name="main")]
|
|
repo.heads[0].name = "dev"
|
|
repo.heads[1].name = "main"
|
|
repo.refs = [MagicMock()]
|
|
repo.refs[0].name = "origin/main"
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
result = svc.promote_direct_merge(1, "dev", "main")
|
|
assert result["status"] == "merged"
|
|
assert result["from_branch"] == "dev"
|
|
assert result["to_branch"] == "main"
|
|
origin.fetch.assert_called_once()
|
|
repo.git.merge.assert_called_once()
|
|
origin.push.assert_called_once()
|
|
repo.git.checkout.assert_called()
|
|
# #endregion Test.Git.TestPromoteSuccess
|
|
|
|
# #region Test.Git.TestPromoteNoOrigin [C:2] [TYPE Function]
|
|
def test_promote_no_origin(self):
|
|
"""No origin remote → HTTPException 400."""
|
|
repo = MagicMock()
|
|
repo.active_branch.name = "dev"
|
|
repo.remote.side_effect = ValueError("no remote")
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException, match="origin"):
|
|
svc.promote_direct_merge(1, "dev", "main")
|
|
# #endregion Test.Git.TestPromoteNoOrigin
|
|
|
|
# #region Test.Git.TestPromoteSourceNotFound [C:2] [TYPE Function]
|
|
def test_promote_source_not_found(self):
|
|
"""Source branch not in heads or refs → HTTPException 404."""
|
|
repo = MagicMock()
|
|
repo.active_branch.name = "dev"
|
|
repo.heads = []
|
|
repo.refs = []
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitMerge(repo)
|
|
svc.get_repo = MagicMock(return_value=repo)
|
|
with pytest.raises(HTTPException, match="not found"):
|
|
svc.promote_direct_merge(1, "nonexistent", "main")
|
|
# #endregion Test.Git.TestPromoteSourceNotFound
|
|
# #endregion Test.Git.Merge
|