- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
610 lines
24 KiB
Python
610 lines
24 KiB
Python
# #region Test.Git.Sync [C:3] [TYPE Module] [SEMANTICS test,git,sync,push,pull,remote]
|
|
# @BRIEF Tests for GitServiceSyncMixin — push_changes and pull_changes with origin host alignment, error mapping, and merge detection.
|
|
# @RELATION BINDS_TO -> [Services.Sync.GitServiceSyncMixin]
|
|
# @TEST_EDGE: push_no_heads -> warning logged, no push attempted
|
|
# @TEST_EDGE: push_no_origin -> HTTPException 400
|
|
# @TEST_EDGE: push_non_fast_forward -> HTTPException 409
|
|
# @TEST_EDGE: push_success -> origin.push called
|
|
# @TEST_EDGE: push_no_tracking -> set-upstream push
|
|
# @TEST_EDGE: pull_unfinished_merge -> HTTPException 409
|
|
# @TEST_EDGE: pull_no_origin -> HTTPException 400
|
|
# @TEST_EDGE: pull_no_remote_branch -> HTTPException 409
|
|
# @TEST_EDGE: pull_conflict -> HTTPException 409
|
|
# @TEST_EDGE: pull_success -> fetch + pull called
|
|
|
|
import contextlib
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch, PropertyMock
|
|
|
|
from fastapi import HTTPException
|
|
from git.exc import GitCommandError
|
|
|
|
from src.services.git._sync import GitServiceSyncMixin
|
|
from src.services.git._merge import GitServiceMergeMixin
|
|
|
|
|
|
class TestableGitSync(GitServiceSyncMixin, GitServiceMergeMixin):
|
|
"""Concrete test class providing _locked, get_repo, and URL mixin stubs."""
|
|
def __init__(self, mock_repo=None):
|
|
self._mock_repo = mock_repo or MagicMock()
|
|
|
|
async def get_repo(self, dashboard_id):
|
|
return self._mock_repo
|
|
|
|
def _locked(self, dashboard_id):
|
|
@contextlib.contextmanager
|
|
def _lock():
|
|
yield
|
|
return _lock()
|
|
|
|
def _align_origin_host_with_config(self, **kwargs):
|
|
return None
|
|
|
|
|
|
# ── push_changes ──
|
|
|
|
class TestPushChanges:
|
|
"""push_changes — push local commits to origin."""
|
|
|
|
# #region Test.Git.TestPushNoHeads [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_push_no_heads(self):
|
|
"""No local branches → returns early, no push."""
|
|
repo = MagicMock()
|
|
repo.heads = []
|
|
svc = TestableGitSync(repo)
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
result = await svc.push_changes(1)
|
|
assert result is None
|
|
# #endregion Test.Git.TestPushNoHeads
|
|
|
|
# #region Test.Git.TestPushNoOrigin [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_push_no_origin(self):
|
|
"""No origin remote → HTTPException 400."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.remote.side_effect = ValueError("no remote")
|
|
svc = TestableGitSync(repo)
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
with pytest.raises(HTTPException, match="origin"):
|
|
await svc.push_changes(1)
|
|
# #endregion Test.Git.TestPushNoOrigin
|
|
|
|
# #region Test.Git.TestPushSuccessWithTracking [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_push_success_with_tracking(self):
|
|
"""Branch with tracking → origin.push called."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
branch = MagicMock()
|
|
branch.name = "dev"
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
repo.active_branch = branch
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
origin.push.return_value = []
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitSync(repo)
|
|
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
|
|
mock_session = MagicMock()
|
|
mock_session_cls.return_value = mock_session
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
await svc.push_changes(1)
|
|
origin.push.assert_called_once()
|
|
# #endregion Test.Git.TestPushSuccessWithTracking
|
|
|
|
# #region Test.Git.TestPushNoTrackingSetUpstream [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_push_no_tracking_set_upstream(self):
|
|
"""No tracking branch → --set-upstream push."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
branch = MagicMock()
|
|
branch.name = "feature"
|
|
branch.tracking_branch.return_value = None
|
|
repo.active_branch = branch
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitSync(repo)
|
|
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
|
|
mock_session = MagicMock()
|
|
mock_session_cls.return_value = mock_session
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
await svc.push_changes(1)
|
|
repo.git.push.assert_called_with("--set-upstream", "origin", "feature:feature")
|
|
# #endregion Test.Git.TestPushNoTrackingSetUpstream
|
|
|
|
# #region Test.Git.TestPushNonFastForward [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_push_non_fast_forward(self):
|
|
"""Non-fast-forward rejection → HTTPException 409."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
branch = MagicMock()
|
|
branch.name = "dev"
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
repo.active_branch = branch
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
origin.push.side_effect = GitCommandError("push", "rejected non-fast-forward")
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitSync(repo)
|
|
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
|
|
mock_session = MagicMock()
|
|
mock_session_cls.return_value = mock_session
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.push_changes(1)
|
|
assert exc_info.value.status_code == 409
|
|
# #endregion Test.Git.TestPushNonFastForward
|
|
|
|
# #region Test.Git.TestPushErrorFlags [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_push_error_flags(self):
|
|
"""Push info with ERROR flag → raises Exception."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
branch = MagicMock()
|
|
branch.name = "dev"
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
repo.active_branch = branch
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
push_info = MagicMock()
|
|
push_info.flags = push_info.ERROR
|
|
push_info.remote_ref_string = "refs/heads/dev"
|
|
push_info.summary = "remote error"
|
|
origin.push.return_value = [push_info]
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitSync(repo)
|
|
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
|
|
mock_session = MagicMock()
|
|
mock_session_cls.return_value = mock_session
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
with pytest.raises(HTTPException, match="push failed"):
|
|
await svc.push_changes(1)
|
|
# #endregion Test.Git.TestPushErrorFlags
|
|
|
|
|
|
# ── pull_changes ──
|
|
|
|
class TestPullChanges:
|
|
"""pull_changes — pull from origin with merge detection."""
|
|
|
|
# #region Test.Git.TestPullUnfinishedMerge [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pull_unfinished_merge(self):
|
|
"""MERGE_HEAD exists → HTTPException 409."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.active_branch.name = "dev"
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
svc = TestableGitSync(repo)
|
|
with patch("os.path.exists", return_value=True), \
|
|
patch("pathlib.Path.read_text", return_value="merge_head_sha"):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.pull_changes(1)
|
|
assert exc_info.value.status_code == 409
|
|
# #endregion Test.Git.TestPullUnfinishedMerge
|
|
|
|
# #region Test.Git.TestPullNoOrigin [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pull_no_origin(self):
|
|
"""No origin remote → HTTPException 400."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.remote.side_effect = ValueError("no remote")
|
|
svc = TestableGitSync(repo)
|
|
with patch("os.path.exists", return_value=False):
|
|
with pytest.raises(HTTPException, match="origin"):
|
|
await svc.pull_changes(1)
|
|
# #endregion Test.Git.TestPullNoOrigin
|
|
|
|
# #region Test.Git.TestPullNoRemoteBranch [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pull_no_remote_branch(self):
|
|
"""Remote branch doesn't exist → HTTPException 409."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.active_branch.name = "feature"
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
repo.refs = [] # no origin/feature ref
|
|
svc = TestableGitSync(repo)
|
|
with patch("os.path.exists", return_value=False):
|
|
with pytest.raises(HTTPException, match="does not exist"):
|
|
await svc.pull_changes(1)
|
|
# #endregion Test.Git.TestPullNoRemoteBranch
|
|
|
|
# #region Test.Git.TestPullSuccess [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pull_success(self):
|
|
"""Successful pull → fetch + pull called."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.active_branch.name = "dev"
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
remote_ref = MagicMock()
|
|
remote_ref.name = "origin/dev"
|
|
repo.refs = [remote_ref]
|
|
svc = TestableGitSync(repo)
|
|
with patch("os.path.exists", return_value=False):
|
|
await svc.pull_changes(1)
|
|
origin.fetch.assert_called_once_with(prune=True)
|
|
repo.git.pull.assert_called_with("--no-rebase", "origin", "dev")
|
|
# #endregion Test.Git.TestPullSuccess
|
|
|
|
# #region Test.Git.TestPullConflict [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pull_conflict(self):
|
|
"""Merge conflict during pull → HTTPException 409."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.active_branch.name = "dev"
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
remote_ref = MagicMock()
|
|
remote_ref.name = "origin/dev"
|
|
repo.refs = [remote_ref]
|
|
repo.git.pull.side_effect = GitCommandError("pull", "CONFLICT content")
|
|
svc = TestableGitSync(repo)
|
|
with patch("os.path.exists", return_value=False):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.pull_changes(1)
|
|
assert exc_info.value.status_code == 409
|
|
# #endregion Test.Git.TestPullConflict
|
|
|
|
# #region Test.Git.TestPullGenericError [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pull_generic_error(self):
|
|
"""Generic exception → HTTPException 500."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.active_branch.name = "dev"
|
|
origin = MagicMock()
|
|
origin.urls = ["https://gitea.com/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
remote_ref = MagicMock()
|
|
remote_ref.name = "origin/dev"
|
|
repo.refs = [remote_ref]
|
|
repo.git.pull.side_effect = Exception("network failure")
|
|
svc = TestableGitSync(repo)
|
|
with patch("os.path.exists", return_value=False):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.pull_changes(1)
|
|
assert exc_info.value.status_code == 500
|
|
# #endregion Test.Git.TestPullGenericError
|
|
# #endregion Test.Git.Sync
|
|
|
|
|
|
# #region Test.Git.Sync.AdditionalBranches [C:3] [TYPE Module]
|
|
# @defgroup Extra branch coverage: _http_host, PAT embedding, binding mismatch, error mapping.
|
|
|
|
from src.services.git._sync import GitServiceSyncMixin as _SyncMixin, _http_host
|
|
|
|
|
|
class TestHttpHost:
|
|
"""Verify _http_host normalization."""
|
|
|
|
def test_invalid_url_returns_none(self):
|
|
assert _http_host("http://[") is None # urlparse raises ValueError on bad bracket
|
|
|
|
def test_non_http_scheme_returns_none(self):
|
|
assert _http_host("ssh://git@example.com/repo.git") is None
|
|
assert _http_host("") is None
|
|
assert _http_host(None) is None
|
|
|
|
def test_http_with_port(self):
|
|
assert _http_host("https://GitHub.Example.com:8443/x.git") == "github.example.com:8443"
|
|
|
|
def test_http_without_port(self):
|
|
assert _http_host("https://example.com/x.git") == "example.com"
|
|
|
|
|
|
class TestRedactPat:
|
|
"""Verify PAT redaction in messages and URLs."""
|
|
|
|
def test_redacts_explicit_pat(self):
|
|
assert _SyncMixin._redact_pat_from_message("token abc123 boom", pat="abc123") == "token *** boom"
|
|
|
|
def test_redacts_url_credentials(self):
|
|
msg = "https://user:secret@example.com/x.git"
|
|
assert "secret" not in _SyncMixin._redact_pat_from_message(msg)
|
|
|
|
|
|
class TestEmbedPatInOriginUrl:
|
|
"""Verify temporary PAT embedding in the origin URL."""
|
|
|
|
def test_no_origin_returns_none(self):
|
|
repo = MagicMock()
|
|
repo.remote.side_effect = Exception("no origin")
|
|
assert _SyncMixin._embed_pat_in_origin_url(repo, "pat-1") is None
|
|
|
|
def test_non_http_origin_returns_none(self):
|
|
repo = MagicMock()
|
|
origin = MagicMock()
|
|
origin.urls = ["ssh://git@example.com/repo.git"]
|
|
repo.remote.return_value = origin
|
|
assert _SyncMixin._embed_pat_in_origin_url(repo, "pat-1") is None
|
|
|
|
def test_embeds_and_returns_original(self):
|
|
repo = MagicMock()
|
|
origin = MagicMock()
|
|
origin.urls = ["https://git-user@example.com:8443/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
original = _SyncMixin._embed_pat_in_origin_url(repo, "p@t/1")
|
|
assert original == "https://git-user@example.com:8443/org/repo.git"
|
|
new_url = origin.set_url.call_args[0][0]
|
|
assert "p%40t%2F1" in new_url
|
|
assert "git-user" in new_url
|
|
|
|
def test_set_url_failure_returns_none(self):
|
|
repo = MagicMock()
|
|
origin = MagicMock()
|
|
origin.urls = ["https://git-user@example.com/repo.git"]
|
|
origin.set_url.side_effect = Exception("read-only")
|
|
repo.remote.return_value = origin
|
|
assert _SyncMixin._embed_pat_in_origin_url(repo, "pat") is None
|
|
|
|
|
|
class TestPushChangesExtra:
|
|
"""push_changes — remaining branches."""
|
|
|
|
def _svc(self, repo):
|
|
return TestableGitSync(repo)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_origin_urls_error_still_pushes(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = None # list(None) raises TypeError -> origin_urls = []
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
origin.push.return_value = []
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
await self._svc(repo).push_changes(1)
|
|
origin.push.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_binding_host_mismatch_409(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://origin.example/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
db_repo = MagicMock()
|
|
db_repo.remote_url = "https://different.example/x.git"
|
|
db_repo.config_id = "cfg-1"
|
|
db_config = MagicMock()
|
|
db_config.url = "https://origin.example"
|
|
session = MagicMock()
|
|
session.query.return_value.filter.return_value.first.side_effect = [db_repo, db_config]
|
|
with patch("src.services.git._sync.SessionLocal", return_value=session):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await self._svc(repo).push_changes(1)
|
|
assert exc.value.status_code == 409
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_with_pat_embeds_and_restores(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://git-user@example.com/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
origin.push.return_value = []
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
await self._svc(repo).push_changes(1, pat="secret-pat")
|
|
# PAT embedded then restored to the original URL.
|
|
calls = [c[0][0] for c in origin.set_url.call_args_list]
|
|
assert "secret-pat" in calls[0] or "secret%2Dpat" in calls[0]
|
|
assert calls[-1] == "https://git-user@example.com/org/repo.git"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_tracking_failure_falls_back(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://example.com/x.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.side_effect = Exception("no tracking")
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
await self._svc(repo).push_changes(1)
|
|
repo.git.push.assert_called_once_with("--set-upstream", "origin", "main:main")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_info_no_error_flag(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://example.com/x.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
ok_info = SimpleNamespace(flags=0, ERROR=1, remote_ref_string="main", summary="ok")
|
|
origin.push.return_value = [ok_info]
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
await self._svc(repo).push_changes(1)
|
|
origin.push.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_pat_restore_failure_is_ignored(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://git-user@example.com/org/repo.git"]
|
|
origin.set_url.side_effect = [None, Exception("read-only")]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
origin.push.return_value = []
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
await self._svc(repo).push_changes(1, pat="secret-pat")
|
|
assert origin.set_url.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_git_command_error_other_500(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://example.com/x.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
origin.push.side_effect = GitCommandError("push", "some other failure")
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await self._svc(repo).push_changes(1)
|
|
assert exc.value.status_code == 500
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_binding_diagnostics_error_logged(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://origin.example/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
session = MagicMock()
|
|
session.query.side_effect = RuntimeError("db down")
|
|
with patch("src.services.git._sync.SessionLocal", return_value=session):
|
|
await self._svc(repo).push_changes(1)
|
|
origin.push.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_generic_exception_500(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://example.com/x.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
origin.push.side_effect = RuntimeError("boom")
|
|
with patch("src.services.git._sync.SessionLocal"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await self._svc(repo).push_changes(1)
|
|
assert exc.value.status_code == 500
|
|
|
|
|
|
class TestPullChangesExtra:
|
|
"""pull_changes — remaining branches."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_with_pat_embeds_and_restores(self):
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.remote.return_value = MagicMock()
|
|
origin = MagicMock()
|
|
origin.urls = ["https://git-user@example.com/org/repo.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
repo.refs = [SimpleNamespace(name="origin/main")]
|
|
await TestableGitSync(repo).pull_changes(1, pat="secret-pat")
|
|
calls = [c[0][0] for c in origin.set_url.call_args_list]
|
|
assert "secret-pat" in calls[0]
|
|
assert calls[-1] == "https://git-user@example.com/org/repo.git"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_origin_urls_error(self):
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
origin = MagicMock()
|
|
origin.urls = None # iterating None raises TypeError -> origin_urls = []
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
repo.refs = [SimpleNamespace(name="origin/main")]
|
|
await TestableGitSync(repo).pull_changes(1)
|
|
origin.fetch.assert_called_once_with(prune=True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_git_command_error_other_500(self):
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
origin = MagicMock()
|
|
origin.urls = ["https://example.com/x.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
repo.refs = [SimpleNamespace(name="origin/main")]
|
|
origin.fetch.side_effect = GitCommandError("fetch", "network down")
|
|
with pytest.raises(HTTPException) as exc:
|
|
await TestableGitSync(repo).pull_changes(1)
|
|
assert exc.value.status_code == 500
|
|
# #endregion Test.Git.Sync.AdditionalBranches
|
|
|
|
|
|
class TestSyncFinalBranches:
|
|
"""Final branch coverage: db_config None, pull restore failure."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_db_config_missing_ok(self):
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
origin = MagicMock()
|
|
origin.urls = ["https://example.com/x.git"]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
branch.tracking_branch.return_value = MagicMock()
|
|
origin.push.return_value = []
|
|
db_repo = MagicMock()
|
|
db_repo.remote_url = "https://example.com/x.git"
|
|
db_repo.config_id = "cfg-missing"
|
|
session = MagicMock()
|
|
session.query.return_value.filter.return_value.first.side_effect = [db_repo, None]
|
|
with patch("src.services.git._sync.SessionLocal", return_value=session):
|
|
await TestableGitSync(repo).push_changes(1)
|
|
origin.push.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_pat_restore_failure_ignored(self):
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
origin = MagicMock()
|
|
origin.urls = ["https://git-user@example.com/org/repo.git"]
|
|
origin.set_url.side_effect = [None, Exception("read-only")]
|
|
repo.remote.return_value = origin
|
|
branch = MagicMock()
|
|
branch.name = "main"
|
|
repo.active_branch = branch
|
|
repo.refs = [SimpleNamespace(name="origin/main")]
|
|
await TestableGitSync(repo).pull_changes(1, pat="secret-pat")
|
|
assert origin.set_url.call_count == 2
|
|
# #endregion Test.Git.Sync.FinalBranches
|