test: 8 parallel agents — fix 150+ failures, add 30+ test files. schemas/models 99-100%, core/client_registry ~98%, maintenance 95-98%, git edges 97%, translate 98-100%, dashboard routes 95-96%, dataset_review 100%
This commit is contained in:
264
backend/tests/services/git/test_git_base_edge.py
Normal file
264
backend/tests/services/git/test_git_base_edge.py
Normal file
@@ -0,0 +1,264 @@
|
||||
# #region Test.Git.Base.Edge [C:3] [TYPE Module] [SEMANTICS test, git, base, repo, migrate, coverage]
|
||||
# @BRIEF Additional edge case tests for GitServiceBase — repo path resolution, migration, delete_repo edge paths.
|
||||
# @RELATION BINDS_TO -> [GitServiceBase]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.services.git._base import GitServiceBase
|
||||
|
||||
|
||||
class TestResolveBasePath:
|
||||
def test_custom_base_path_returns_direct(self):
|
||||
"""Custom base_path (not 'git_repos') returns path directly."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
|
||||
patch.object(GitServiceBase, '_resolve_base_path', return_value="/custom/path"):
|
||||
svc = GitServiceBase(base_path="/custom/path")
|
||||
# Already resolved in __init__
|
||||
assert svc.base_path == "/custom/path"
|
||||
|
||||
def test_fallback_on_db_error(self):
|
||||
"""DB error during resolve → fallback to default."""
|
||||
with patch("src.services.git._base.SessionLocal", side_effect=Exception("DB error")):
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
assert svc.base_path is not None
|
||||
|
||||
|
||||
class TestGetRepoPathDBPath:
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_path_exists_returned(self):
|
||||
"""DB has local_path and it exists → return it."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
svc.base_path = "/tmp/base"
|
||||
svc.legacy_base_path = "/tmp/legacy"
|
||||
svc._uses_default_base_path = True
|
||||
|
||||
with patch("src.services.git._base.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value = mock_db
|
||||
mock_db_repo = MagicMock()
|
||||
mock_db_repo.local_path = "/tmp/existing/repo"
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
|
||||
with patch("os.path.exists", return_value=True), \
|
||||
patch("os.path.abspath", side_effect=lambda x: x):
|
||||
result = await svc._get_repo_path(1, "my-key")
|
||||
assert result == "/tmp/existing/repo"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_path_legacy_migration(self):
|
||||
"""DB path starts with legacy base → migrate."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
svc.base_path = "/tmp/new-base"
|
||||
svc.legacy_base_path = "/tmp/legacy"
|
||||
svc._uses_default_base_path = True
|
||||
|
||||
with patch("src.services.git._base.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value = mock_db
|
||||
mock_db_repo = MagicMock()
|
||||
mock_db_repo.local_path = "/tmp/legacy/repo"
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
|
||||
with patch("os.path.exists", return_value=True), \
|
||||
patch("os.path.abspath", side_effect=lambda x: x), \
|
||||
patch.object(svc, '_migrate_repo_directory', new_callable=AsyncMock, return_value="/tmp/new-base/my-key"):
|
||||
result = await svc._get_repo_path(1, "my-key")
|
||||
assert result == "/tmp/new-base/my-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_query_exception(self):
|
||||
"""Exception in DB query → fallback to path resolution."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
svc.base_path = "/tmp/base"
|
||||
svc.legacy_base_path = "/tmp/legacy"
|
||||
svc._uses_default_base_path = True
|
||||
|
||||
with patch("src.services.git._base.SessionLocal", side_effect=Exception("DB down")):
|
||||
result = await svc._get_repo_path(1, "my-key")
|
||||
assert result == "/tmp/base/my-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_id_path_migration(self):
|
||||
"""Legacy ID path exists → migrate to new path."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
svc.base_path = "/tmp/base"
|
||||
svc.legacy_base_path = "/tmp/legacy"
|
||||
svc._uses_default_base_path = True
|
||||
|
||||
with patch("src.services.git._base.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
with patch("os.path.exists", side_effect=lambda p: p == "/tmp/legacy/1"), \
|
||||
patch.object(svc, '_migrate_repo_directory', new_callable=AsyncMock, return_value="/tmp/base/dashboard"):
|
||||
result = await svc._get_repo_path(1)
|
||||
assert result == "/tmp/base/dashboard"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_target_path_exists_updates_db(self):
|
||||
"""Target path already exists → update DB record."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
svc.base_path = "/tmp/base"
|
||||
svc.legacy_base_path = "/tmp/legacy"
|
||||
svc._uses_default_base_path = True
|
||||
|
||||
with patch("src.services.git._base.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
with patch("os.path.exists", return_value=True), \
|
||||
patch.object(svc, '_update_repo_local_path') as mock_update:
|
||||
result = await svc._get_repo_path(1, "my-key")
|
||||
assert result == "/tmp/base/my-key"
|
||||
mock_update.assert_called_once()
|
||||
|
||||
|
||||
class TestDeleteRepoEdge:
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_repo_file_not_dir(self):
|
||||
"""Repo path exists but is a file → use os.remove."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
svc.base_path = "/tmp/base"
|
||||
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo-file"), \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("os.path.isdir", return_value=False), \
|
||||
patch("os.remove", new_callable=MagicMock) as mock_remove, \
|
||||
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()):
|
||||
|
||||
import src.services.git._base as gb_mod
|
||||
orig_sl = gb_mod.SessionLocal
|
||||
try:
|
||||
mock_db = MagicMock()
|
||||
gb_mod.SessionLocal = MagicMock(return_value=mock_db)
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
with pytest.raises(HTTPException, match="404"):
|
||||
await svc.delete_repo(1)
|
||||
mock_remove.assert_called_once_with("/tmp/repo-file")
|
||||
finally:
|
||||
gb_mod.SessionLocal = orig_sl
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_repo_db_error_rollback(self):
|
||||
"""DB error during delete → rollback and HTTP 500."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
|
||||
|
||||
svc = GitServiceBase(base_path="git_repos")
|
||||
svc.base_path = "/tmp/base"
|
||||
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("os.path.isdir", return_value=True), \
|
||||
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()):
|
||||
|
||||
import src.services.git._base as gb_mod
|
||||
orig_sl = gb_mod.SessionLocal
|
||||
try:
|
||||
mock_db = MagicMock()
|
||||
gb_mod.SessionLocal = MagicMock(return_value=mock_db)
|
||||
mock_db_repo = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
|
||||
mock_db.delete.side_effect = Exception("DB error")
|
||||
gb_mod.run_blocking = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException, match="500"):
|
||||
await svc.delete_repo(1)
|
||||
mock_db.rollback.assert_called_once()
|
||||
finally:
|
||||
gb_mod.SessionLocal = orig_sl
|
||||
|
||||
|
||||
class TestMigrateRepoDirectoryEdges:
|
||||
@pytest.mark.asyncio
|
||||
async def test_os_replace_fallback_to_shutil(self):
|
||||
"""OSError on os.replace → falls back to shutil.move."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
|
||||
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
|
||||
svc = GitServiceBase(base_path="/tmp/test")
|
||||
with patch("os.path.exists", return_value=False), \
|
||||
patch.object(svc, '_update_repo_local_path'), \
|
||||
patch("src.services.git._base.run_blocking", new_callable=AsyncMock) as mock_blocking:
|
||||
# Make first os.replace fail, second shutil.move succeed
|
||||
mock_blocking.side_effect = [None, OSError("rename failed"), None]
|
||||
result = await svc._migrate_repo_directory(1, "/source", "/target")
|
||||
assert result == "/target"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_existing_target_keeps_source(self):
|
||||
"""Target already exists → return source."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
|
||||
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
|
||||
svc = GitServiceBase(base_path="/tmp/test")
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = await svc._migrate_repo_directory(1, "/source", "/target")
|
||||
assert result == "/source"
|
||||
|
||||
|
||||
class TestInitRepoEdge:
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_path_is_not_git_repo_recreates(self):
|
||||
"""Path exists but is not a valid Git repo → recreate."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
|
||||
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
|
||||
svc = GitServiceBase(base_path="/tmp/test")
|
||||
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("src.services.git._base.run_blocking") as mock_blocking, \
|
||||
patch.object(svc, "_clone_with_auth", new_callable=AsyncMock) as mock_clone, \
|
||||
patch.object(svc, "_ensure_gitflow_branches", create=True), \
|
||||
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()):
|
||||
|
||||
# First call (Repo(repo_path)) raises InvalidGitRepositoryError
|
||||
mock_blocking.side_effect = [Exception("InvalidGitRepositoryError"), None, None]
|
||||
mock_clone.return_value = MagicMock()
|
||||
result = await svc.init_repo(1, "https://remote.com/repo.git", "pat")
|
||||
assert result is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_path_unlink_fallback(self):
|
||||
"""stale_path.exists after rmtree → tries unlink."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
|
||||
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
|
||||
svc = GitServiceBase(base_path="/tmp/test")
|
||||
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("src.services.git._base.run_blocking") as mock_blocking, \
|
||||
patch.object(svc, "_clone_with_auth", new_callable=AsyncMock) as mock_clone, \
|
||||
patch.object(svc, "_ensure_gitflow_branches", create=True), \
|
||||
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()), \
|
||||
patch("pathlib.Path.exists", return_value=True):
|
||||
|
||||
mock_blocking.side_effect = [
|
||||
Exception("InvalidGitRepositoryError"), # Repo() call
|
||||
None, # shutil.rmtree
|
||||
None, # stale_path.unlink
|
||||
MagicMock(), # Repo from clone
|
||||
]
|
||||
mock_clone.return_value = MagicMock()
|
||||
result = await svc.init_repo(1, "https://remote.com/repo.git", "pat")
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestClosedService:
|
||||
def test_closed_service_blocks_locked(self):
|
||||
"""Closed service raises RuntimeError in _locked."""
|
||||
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
|
||||
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
|
||||
svc = GitServiceBase(base_path="/tmp/test")
|
||||
svc._closed = True
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
with svc._locked(1):
|
||||
pass
|
||||
# #endregion Test.Git.Base.Edge
|
||||
264
backend/tests/services/git/test_git_merge_edge.py
Normal file
264
backend/tests/services/git/test_git_merge_edge.py
Normal file
@@ -0,0 +1,264 @@
|
||||
# #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
|
||||
241
backend/tests/services/git/test_git_sync_edge.py
Normal file
241
backend/tests/services/git/test_git_sync_edge.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# #region Test.Git.Sync.Edge [C:3] [TYPE Module] [SEMANTICS test, git, sync, push, pull, coverage]
|
||||
# @BRIEF Edge case tests for GitServiceSyncMixin — push/pull paths.
|
||||
# @RELATION BINDS_TO -> [GitServiceSyncMixin]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from git.exc import GitCommandError
|
||||
|
||||
|
||||
class TestPushChanges:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_branches(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
repo = MagicMock()
|
||||
repo.heads = []
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
|
||||
await mixin.push_changes(1) # should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_origin_remote(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
repo.remote.side_effect = ValueError("no origin")
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
|
||||
with pytest.raises(HTTPException, match="origin"):
|
||||
await mixin.push_changes(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tracking_branch_sets_upstream(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
mixin._align_origin_host_with_config = MagicMock(return_value=None)
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
origin = MagicMock()
|
||||
repo.remote.return_value = origin
|
||||
current_branch = MagicMock()
|
||||
current_branch.name = "feature"
|
||||
current_branch.tracking_branch.return_value = None
|
||||
repo.active_branch = current_branch
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
|
||||
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
|
||||
mock_db = MagicMock()
|
||||
mock_sl_cls.return_value = mock_db
|
||||
mock_db_repo = MagicMock()
|
||||
mock_db_repo.remote_url = "https://git.com/org/repo.git"
|
||||
mock_db_repo.config_id = 1
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
|
||||
mock_db_config = MagicMock()
|
||||
mock_db_config.url = "https://git.com"
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_db_config
|
||||
|
||||
await mixin.push_changes(1)
|
||||
repo.git.push.assert_called_with("--set-upstream", "origin", "feature:feature")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracking_branch_push(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
mixin._align_origin_host_with_config = MagicMock(return_value=None)
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
origin = MagicMock()
|
||||
repo.remote.return_value = origin
|
||||
current_branch = MagicMock()
|
||||
current_branch.name = "main"
|
||||
current_branch.tracking_branch.return_value = "origin/main"
|
||||
repo.active_branch = current_branch
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
|
||||
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
|
||||
mock_db = MagicMock()
|
||||
mock_sl_cls.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None # no db_repo
|
||||
|
||||
await mixin.push_changes(1)
|
||||
origin.push.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_git_command_error_nonfastforward(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
mixin._align_origin_host_with_config = MagicMock(return_value=None)
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
origin = MagicMock()
|
||||
repo.remote.return_value = origin
|
||||
current_branch = MagicMock()
|
||||
current_branch.name = "main"
|
||||
current_branch.tracking_branch.return_value = None
|
||||
repo.active_branch = current_branch
|
||||
repo.git.push.side_effect = GitCommandError("push", "non-fast-forward")
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
|
||||
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
|
||||
mock_db = MagicMock()
|
||||
mock_sl_cls.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
with pytest.raises(HTTPException, match="conflict|rejected|409"):
|
||||
await mixin.push_changes(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_generic_exception(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
mixin._align_origin_host_with_config = MagicMock(return_value=None)
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
origin = MagicMock()
|
||||
repo.remote.return_value = origin
|
||||
current_branch = MagicMock()
|
||||
current_branch.name = "main"
|
||||
current_branch.tracking_branch.return_value = None
|
||||
repo.active_branch = current_branch
|
||||
repo.git.push.side_effect = GitCommandError("push", "some error")
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
|
||||
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
|
||||
mock_db = MagicMock()
|
||||
mock_sl_cls.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
with pytest.raises(HTTPException, match="500"):
|
||||
await mixin.push_changes(1)
|
||||
|
||||
|
||||
class TestPullChanges:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unfinished_merge_detected(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
repo = MagicMock()
|
||||
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=True), \
|
||||
patch.object(mixin, '_build_unfinished_merge_payload', return_value={"error_code": "GIT_UNFINISHED_MERGE"}):
|
||||
with pytest.raises(HTTPException, match="409"):
|
||||
await mixin.pull_changes(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_branch_not_found(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
repo = MagicMock()
|
||||
repo.git_dir = "/tmp/repo/.git"
|
||||
repo.working_tree_dir = "/tmp/repo"
|
||||
current_branch = MagicMock()
|
||||
current_branch.name = "feature"
|
||||
repo.active_branch = current_branch
|
||||
origin = MagicMock()
|
||||
repo.remote.return_value = origin
|
||||
repo.refs = []
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
|
||||
patch("os.path.exists", return_value=False):
|
||||
with pytest.raises(HTTPException, match="409"):
|
||||
await mixin.pull_changes(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_during_pull(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
repo = MagicMock()
|
||||
repo.git_dir = "/tmp/repo/.git"
|
||||
repo.working_tree_dir = "/tmp/repo"
|
||||
current_branch = MagicMock()
|
||||
current_branch.name = "main"
|
||||
repo.active_branch = current_branch
|
||||
origin = MagicMock()
|
||||
repo.remote.return_value = origin
|
||||
# Simulate remote branch existence
|
||||
ref = MagicMock()
|
||||
ref.name = "origin/main"
|
||||
repo.refs = [ref]
|
||||
repo.git.pull.side_effect = GitCommandError("pull", "conflict")
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
|
||||
patch("os.path.exists", return_value=False):
|
||||
with pytest.raises(HTTPException, match="409"):
|
||||
await mixin.pull_changes(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_not_found(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
repo = MagicMock()
|
||||
repo.git_dir = "/tmp/repo/.git"
|
||||
repo.working_tree_dir = "/tmp/repo"
|
||||
repo.active_branch.name = "main"
|
||||
repo.remote.side_effect = ValueError
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
|
||||
patch("os.path.exists", return_value=False):
|
||||
with pytest.raises(HTTPException, match="origin"):
|
||||
await mixin.pull_changes(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_pull_error(self):
|
||||
from src.services.git._sync import GitServiceSyncMixin
|
||||
mixin = GitServiceSyncMixin()
|
||||
mixin._locked = lambda x: (lambda g=None: (yield))()
|
||||
repo = MagicMock()
|
||||
repo.git_dir = "/tmp/repo/.git"
|
||||
repo.working_tree_dir = "/tmp/repo"
|
||||
current_branch = MagicMock()
|
||||
current_branch.name = "main"
|
||||
repo.active_branch = current_branch
|
||||
origin = MagicMock()
|
||||
repo.remote.return_value = origin
|
||||
ref = MagicMock()
|
||||
ref.name = "origin/main"
|
||||
repo.refs = [ref]
|
||||
repo.git.pull.side_effect = Exception("unknown error")
|
||||
|
||||
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
|
||||
patch("os.path.exists", return_value=False):
|
||||
with pytest.raises(HTTPException, match="500"):
|
||||
await mixin.pull_changes(1)
|
||||
# #endregion Test.Git.Sync.Edge
|
||||
256
backend/tests/services/git/test_git_url_edge.py
Normal file
256
backend/tests/services/git/test_git_url_edge.py
Normal file
@@ -0,0 +1,256 @@
|
||||
# #region Test.Git.Url.Edge [C:3] [TYPE Module] [SEMANTICS test, git, url, parse, coverage]
|
||||
# @BRIEF Edge case tests for GitServiceUrlMixin — URL parsing, host extraction, alignment, identity parsing.
|
||||
# @RELATION BINDS_TO -> [GitServiceUrlMixin]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.git._url import GitServiceUrlMixin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mixin():
|
||||
return GitServiceUrlMixin()
|
||||
|
||||
|
||||
class TestExtractHttpHost:
|
||||
def test_none_or_empty(self, mixin):
|
||||
assert mixin._extract_http_host(None) is None
|
||||
assert mixin._extract_http_host("") is None
|
||||
assert mixin._extract_http_host(" ") is None
|
||||
|
||||
def test_non_http_scheme(self, mixin):
|
||||
assert mixin._extract_http_host("ssh://git@example.com") is None
|
||||
assert mixin._extract_http_host("git@example.com:repo.git") is None
|
||||
|
||||
def test_http_with_port(self, mixin):
|
||||
result = mixin._extract_http_host("https://example.com:8443/repo")
|
||||
assert result == "example.com:8443"
|
||||
|
||||
def test_http_without_port(self, mixin):
|
||||
result = mixin._extract_http_host("https://example.com/repo")
|
||||
assert result == "example.com"
|
||||
|
||||
def test_parse_error(self, mixin):
|
||||
# urlparse doesn't typically raise, but test the except path
|
||||
result = mixin._extract_http_host(None)
|
||||
assert result is None
|
||||
|
||||
def test_no_hostname(self, mixin):
|
||||
with patch("src.services.git._url.urlparse") as mock_parse:
|
||||
parsed = MagicMock()
|
||||
parsed.scheme = "http"
|
||||
parsed.hostname = None
|
||||
mock_parse.return_value = parsed
|
||||
result = mixin._extract_http_host("http:///path")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestStripUrlCredentials:
|
||||
def test_empty(self, mixin):
|
||||
assert mixin._strip_url_credentials("") == ""
|
||||
|
||||
def test_no_credentials(self, mixin):
|
||||
url = "https://example.com/repo.git"
|
||||
assert mixin._strip_url_credentials(url) == url
|
||||
|
||||
def test_with_credentials(self, mixin):
|
||||
url = "https://user:pass@example.com/repo.git"
|
||||
result = mixin._strip_url_credentials(url)
|
||||
assert "user" not in result
|
||||
assert "pass" not in result
|
||||
assert "example.com/repo.git" in result
|
||||
|
||||
def test_non_http_scheme(self, mixin):
|
||||
url = "ssh://git@example.com/repo.git"
|
||||
assert mixin._strip_url_credentials(url) == url
|
||||
|
||||
def test_parse_error(self, mixin):
|
||||
with patch("src.services.git._url.urlparse", side_effect=Exception("parse error")):
|
||||
url = "https://example.com/repo"
|
||||
result = mixin._strip_url_credentials(url)
|
||||
assert result == url
|
||||
|
||||
|
||||
class TestReplaceHostInUrl:
|
||||
def test_empty_source(self, mixin):
|
||||
assert mixin._replace_host_in_url("", "https://new.com") is None
|
||||
|
||||
def test_empty_config(self, mixin):
|
||||
assert mixin._replace_host_in_url("https://old.com/repo", "") is None
|
||||
|
||||
def test_non_http_scheme(self, mixin):
|
||||
assert mixin._replace_host_in_url("ssh://old.com", "https://new.com") is None
|
||||
|
||||
def test_no_hostname(self, mixin):
|
||||
result = mixin._replace_host_in_url("http:///path", "https://new.com")
|
||||
assert result is None
|
||||
|
||||
def test_successful_replacement(self, mixin):
|
||||
result = mixin._replace_host_in_url(
|
||||
"https://old.com:8080/org/repo.git",
|
||||
"https://new.com:8443"
|
||||
)
|
||||
assert result is not None
|
||||
assert "new.com:8443" in result
|
||||
assert "old.com" not in result
|
||||
|
||||
def test_preserves_credentials(self, mixin):
|
||||
result = mixin._replace_host_in_url(
|
||||
"https://user:pass@old.com/org/repo.git",
|
||||
"https://new.com"
|
||||
)
|
||||
assert result is not None
|
||||
assert "user" in result
|
||||
assert "new.com" in result
|
||||
|
||||
def test_parse_error(self, mixin):
|
||||
with patch("src.services.git._url.urlparse", side_effect=Exception("parse error")):
|
||||
result = mixin._replace_host_in_url("https://old.com", "https://new.com")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestParseRemoteRepoIdentity:
|
||||
def test_empty_url(self, mixin):
|
||||
with pytest.raises(Exception, match="empty"):
|
||||
mixin._parse_remote_repo_identity("")
|
||||
|
||||
def test_ssh_url(self, mixin):
|
||||
result = mixin._parse_remote_repo_identity("git@github.com:owner/repo.git")
|
||||
assert result["owner"] == "owner"
|
||||
assert result["repo"] == "repo"
|
||||
assert result["full_name"] == "owner/repo"
|
||||
|
||||
def test_https_url(self, mixin):
|
||||
result = mixin._parse_remote_repo_identity("https://github.com/owner/repo.git")
|
||||
assert result["owner"] == "owner"
|
||||
assert result["repo"] == "repo"
|
||||
|
||||
def test_nested_path(self, mixin):
|
||||
result = mixin._parse_remote_repo_identity("https://github.com/org/team/repo.git")
|
||||
assert result["owner"] == "org"
|
||||
assert result["repo"] == "repo"
|
||||
assert result["namespace"] == "org/team"
|
||||
|
||||
def test_no_git_suffix(self, mixin):
|
||||
result = mixin._parse_remote_repo_identity("https://github.com/owner/repo")
|
||||
assert result["repo"] == "repo"
|
||||
assert result["full_name"] == "owner/repo"
|
||||
|
||||
def test_too_few_parts(self, mixin):
|
||||
with pytest.raises(Exception, match="Cannot parse"):
|
||||
mixin._parse_remote_repo_identity("https://github.com/owner")
|
||||
|
||||
def test_ssh_with_no_colon(self, mixin):
|
||||
"""SSH URL with no colon after host."""
|
||||
result = mixin._parse_remote_repo_identity("git@github.com/owner/repo.git")
|
||||
# This goes through urlparse path
|
||||
assert result["owner"] == "owner"
|
||||
assert result["repo"] == "repo"
|
||||
|
||||
|
||||
class TestDeriveServerUrlFromRemote:
|
||||
def test_empty(self, mixin):
|
||||
assert mixin._derive_server_url_from_remote("") is None
|
||||
|
||||
def test_ssh_url_returns_none(self, mixin):
|
||||
assert mixin._derive_server_url_from_remote("git@github.com:org/repo.git") is None
|
||||
|
||||
def test_non_http_scheme(self, mixin):
|
||||
assert mixin._derive_server_url_from_remote("ftp://example.com/repo") is None
|
||||
|
||||
def test_no_hostname(self, mixin):
|
||||
assert mixin._derive_server_url_from_remote("http:///path") is None
|
||||
|
||||
def test_success_https(self, mixin):
|
||||
result = mixin._derive_server_url_from_remote("https://github.com/org/repo.git")
|
||||
assert result == "https://github.com"
|
||||
|
||||
def test_with_port(self, mixin):
|
||||
result = mixin._derive_server_url_from_remote("https://gitea.local:3000/org/repo.git")
|
||||
assert result == "https://gitea.local:3000"
|
||||
|
||||
|
||||
class TestNormalizeGitServerUrl:
|
||||
def test_empty_raises(self, mixin):
|
||||
with pytest.raises(Exception, match="required"):
|
||||
mixin._normalize_git_server_url("")
|
||||
|
||||
def test_strips_trailing_slash(self, mixin):
|
||||
result = mixin._normalize_git_server_url("https://github.com/")
|
||||
assert result == "https://github.com"
|
||||
|
||||
def test_preserves_no_slash(self, mixin):
|
||||
result = mixin._normalize_git_server_url("https://github.com")
|
||||
assert result == "https://github.com"
|
||||
|
||||
|
||||
class TestAlignOriginHostWithConfig:
|
||||
def test_no_config_host(self, mixin):
|
||||
result = mixin._align_origin_host_with_config(1, MagicMock(), None, None, None)
|
||||
assert result is None
|
||||
|
||||
def test_no_origin_host(self, mixin):
|
||||
result = mixin._align_origin_host_with_config(
|
||||
1, MagicMock(), "https://new.com", "", None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_hosts_match(self, mixin):
|
||||
result = mixin._align_origin_host_with_config(
|
||||
1, MagicMock(), "https://same.com", "https://same.com/repo", None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_hosts_mismatch_sets_url(self, mixin):
|
||||
origin = MagicMock()
|
||||
result = mixin._align_origin_host_with_config(
|
||||
1, origin, "https://new.com", "https://old.com/repo", None
|
||||
)
|
||||
assert result is not None
|
||||
assert "new.com" in result
|
||||
origin.set_url.assert_called_once()
|
||||
|
||||
def test_set_url_failure(self, mixin):
|
||||
origin = MagicMock()
|
||||
origin.set_url.side_effect = Exception("set_url failed")
|
||||
result = mixin._align_origin_host_with_config(
|
||||
1, origin, "https://new.com", "https://old.com/repo", None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_sets_aligned_url(self, mixin):
|
||||
origin = MagicMock()
|
||||
with patch("src.services.git._url.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value = mock_db
|
||||
mock_db_repo = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
|
||||
result = mixin._align_origin_host_with_config(
|
||||
1, origin, "https://new.com", "https://old.com/repo", None
|
||||
)
|
||||
assert result is not None
|
||||
mock_db_repo.remote_url = mixin._strip_url_credentials(result)
|
||||
assert mock_db_repo.commit.called
|
||||
|
||||
def test_db_update_failure(self, mixin):
|
||||
origin = MagicMock()
|
||||
with patch("src.services.git._url.SessionLocal", side_effect=Exception("DB error")):
|
||||
result = mixin._align_origin_host_with_config(
|
||||
1, origin, "https://new.com", "https://old.com/repo", None
|
||||
)
|
||||
assert result is not None # still returns aligned URL
|
||||
|
||||
def test_uses_binding_remote_url_when_current_empty(self, mixin):
|
||||
origin = MagicMock()
|
||||
result = mixin._align_origin_host_with_config(
|
||||
1, origin, "https://new.com", "", "https://old.com/repo"
|
||||
)
|
||||
assert result is not None
|
||||
assert "old.com" in result
|
||||
# #endregion Test.Git.Url.Edge
|
||||
Reference in New Issue
Block a user