fix(agent): resolve ModuleNotFoundError for backend, add E2E test infra
- Dockerfile.agent: fix CMD (python -m src.agent.run), use backend/requirements.txt, minimal COPY (only src.agent + src.core.cot_logger), add GRACE contract - docker-compose.yml: SERVICE_TOKEN_SECRET -> SERVICE_JWT (match code) - docker-compose.enterprise-clean.yml: same env var fix - docker/.env.agent.example: same env var fix - build.sh: same env var fix - chore: semantics-testing SKILL.md, backend tests, pyproject.toml
This commit is contained in:
288
backend/tests/services/git/test_git_sync.py
Normal file
288
backend/tests/services/git/test_git_sync.py
Normal file
@@ -0,0 +1,288 @@
|
||||
# #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 -> [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 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_push_no_heads [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_push_no_heads
|
||||
|
||||
# #region test_push_no_origin [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_push_no_origin
|
||||
|
||||
# #region test_push_success_with_tracking [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_push_success_with_tracking
|
||||
|
||||
# #region test_push_no_tracking_set_upstream [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_push_no_tracking_set_upstream
|
||||
|
||||
# #region test_push_non_fast_forward [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_push_non_fast_forward
|
||||
|
||||
# #region test_push_error_flags [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_push_error_flags
|
||||
|
||||
|
||||
# ── pull_changes ──
|
||||
|
||||
class TestPullChanges:
|
||||
"""pull_changes — pull from origin with merge detection."""
|
||||
|
||||
# #region test_pull_unfinished_merge [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_pull_unfinished_merge
|
||||
|
||||
# #region test_pull_no_origin [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_pull_no_origin
|
||||
|
||||
# #region test_pull_no_remote_branch [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_pull_no_remote_branch
|
||||
|
||||
# #region test_pull_success [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_pull_success
|
||||
|
||||
# #region test_pull_conflict [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_pull_conflict
|
||||
|
||||
# #region test_pull_generic_error [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_pull_generic_error
|
||||
# #endregion Test.Git.Sync
|
||||
Reference in New Issue
Block a user