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:
339
backend/tests/services/git/test_git_branch.py
Normal file
339
backend/tests/services/git/test_git_branch.py
Normal file
@@ -0,0 +1,339 @@
|
||||
# #region Test.Git.Branch [C:3] [TYPE Module] [SEMANTICS test,git,branch,checkout,commit,gitflow]
|
||||
# @BRIEF Tests for GitServiceBranchMixin — gitflow bootstrap, list/create/checkout branches, commit changes.
|
||||
# @RELATION BINDS_TO -> [GitServiceBranchMixin]
|
||||
# @TEST_EDGE: empty_repo -> initial commit created for branching
|
||||
# @TEST_EDGE: no_commits -> gitflow bootstrap skipped
|
||||
# @TEST_EDGE: checkout_local_changes -> HTTPException 409
|
||||
# @TEST_EDGE: checkout_generic_error -> HTTPException 500
|
||||
# @TEST_EDGE: commit_dirty -> stages all and commits
|
||||
# @TEST_EDGE: commit_clean -> no commit when not dirty
|
||||
# @TEST_EDGE: commit_specific_files -> only listed files staged
|
||||
# @TEST_EDGE: list_branches_filters_tags -> refs/tags/ excluded
|
||||
# @TEST_EDGE: create_branch_from_missing -> falls back to HEAD
|
||||
|
||||
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 MagicMock, patch, call, PropertyMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
from git.exc import GitCommandError
|
||||
|
||||
from src.services.git._branch import GitServiceBranchMixin
|
||||
|
||||
|
||||
class TestableGitBranch(GitServiceBranchMixin):
|
||||
"""Concrete test class providing _locked and get_repo 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()
|
||||
|
||||
|
||||
# ── _ensure_gitflow_branches ──
|
||||
|
||||
class TestEnsureGitflowBranches:
|
||||
"""_ensure_gitflow_branches — main/dev/preprod bootstrap."""
|
||||
|
||||
# #region test_gitflow_no_commits [C:2] [TYPE Function]
|
||||
def test_gitflow_no_commits(self):
|
||||
"""Empty repo (no commits) → skip bootstrap, no error."""
|
||||
repo = MagicMock()
|
||||
type(repo.head).commit = property(lambda self: (_ for _ in StopIteration()).throw(ValueError("no commits")))
|
||||
repo.heads = []
|
||||
svc = TestableGitBranch()
|
||||
svc._ensure_gitflow_branches(repo, 1)
|
||||
repo.create_head.assert_not_called()
|
||||
# #endregion test_gitflow_no_commits
|
||||
|
||||
# #region test_gitflow_creates_missing_branches [C:2] [TYPE Function]
|
||||
def test_gitflow_creates_missing_branches(self):
|
||||
"""Missing dev/preprod → created from main."""
|
||||
repo = MagicMock()
|
||||
main_head = MagicMock()
|
||||
main_head.name = "main"
|
||||
main_head.commit = MagicMock()
|
||||
repo.heads = [main_head]
|
||||
repo.head.commit = MagicMock()
|
||||
origin = MagicMock()
|
||||
origin.refs = []
|
||||
repo.remote.return_value = origin
|
||||
svc = TestableGitBranch()
|
||||
svc._ensure_gitflow_branches(repo, 1)
|
||||
created_names = [c.args[0] for c in repo.create_head.call_args_list]
|
||||
assert "dev" in created_names
|
||||
assert "preprod" in created_names
|
||||
# #endregion test_gitflow_creates_missing_branches
|
||||
|
||||
# #region test_gitflow_no_origin [C:2] [TYPE Function]
|
||||
def test_gitflow_no_origin(self):
|
||||
"""No origin remote → skip remote push, no error."""
|
||||
repo = MagicMock()
|
||||
main_head = MagicMock()
|
||||
main_head.name = "main"
|
||||
main_head.commit = MagicMock()
|
||||
repo.heads = [main_head]
|
||||
repo.head.commit = MagicMock()
|
||||
repo.remote.side_effect = ValueError("no origin")
|
||||
svc = TestableGitBranch()
|
||||
# Should not raise
|
||||
svc._ensure_gitflow_branches(repo, 1)
|
||||
# #endregion test_gitflow_no_origin
|
||||
|
||||
# #region test_gitflow_already_on_dev [C:2] [TYPE Function]
|
||||
def test_gitflow_already_on_dev(self):
|
||||
"""Already on dev → no checkout call."""
|
||||
repo = MagicMock()
|
||||
main_head = MagicMock()
|
||||
main_head.name = "main"
|
||||
main_head.commit = MagicMock()
|
||||
dev_head = MagicMock()
|
||||
dev_head.name = "dev"
|
||||
dev_head.commit = MagicMock()
|
||||
preprod_head = MagicMock()
|
||||
preprod_head.name = "preprod"
|
||||
preprod_head.commit = MagicMock()
|
||||
repo.heads = [main_head, dev_head, preprod_head]
|
||||
repo.head.commit = MagicMock()
|
||||
repo.active_branch.name = "dev"
|
||||
origin = MagicMock()
|
||||
origin.refs = [MagicMock(remote_head="main"), MagicMock(remote_head="dev"), MagicMock(remote_head="preprod")]
|
||||
repo.remote.return_value = origin
|
||||
svc = TestableGitBranch()
|
||||
svc._ensure_gitflow_branches(repo, 1)
|
||||
repo.git.checkout.assert_not_called()
|
||||
# #endregion test_gitflow_already_on_dev
|
||||
|
||||
|
||||
# ── list_branches ──
|
||||
|
||||
class TestListBranches:
|
||||
"""list_branches — branch listing with tag filtering."""
|
||||
|
||||
# #region test_list_branches_basic [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_basic(self):
|
||||
"""Returns branch dicts with name, commit_hash, is_remote."""
|
||||
repo = MagicMock()
|
||||
ref1 = MagicMock()
|
||||
ref1.name = "refs/heads/dev"
|
||||
ref1.commit.hexsha = "abc123"
|
||||
ref1.commit.committed_date = 1700000000
|
||||
ref1.is_remote.return_value = False
|
||||
repo.refs = [ref1]
|
||||
repo.active_branch.name = "dev"
|
||||
svc = TestableGitBranch(repo)
|
||||
result = await svc.list_branches(1)
|
||||
assert any(b["name"] == "dev" for b in result)
|
||||
assert result[0]["commit_hash"] == "abc123"
|
||||
# #endregion test_list_branches_basic
|
||||
|
||||
# #region test_list_branches_filters_tags [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_filters_tags(self):
|
||||
"""refs/tags/ entries excluded from result."""
|
||||
repo = MagicMock()
|
||||
tag_ref = MagicMock()
|
||||
tag_ref.name = "refs/tags/v1.0"
|
||||
tag_ref.commit.hexsha = "tag123"
|
||||
tag_ref.commit.committed_date = 1700000000
|
||||
branch_ref = MagicMock()
|
||||
branch_ref.name = "refs/heads/main"
|
||||
branch_ref.commit.hexsha = "main123"
|
||||
branch_ref.commit.committed_date = 1700000000
|
||||
branch_ref.is_remote.return_value = False
|
||||
repo.refs = [tag_ref, branch_ref]
|
||||
repo.active_branch.name = "main"
|
||||
svc = TestableGitBranch(repo)
|
||||
result = await svc.list_branches(1)
|
||||
names = [b["name"] for b in result]
|
||||
assert "v1.0" not in names
|
||||
assert "main" in names
|
||||
# #endregion test_list_branches_filters_tags
|
||||
|
||||
# #region test_list_branches_deduplicates [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_deduplicates(self):
|
||||
"""Same name from heads and remotes → only one entry."""
|
||||
repo = MagicMock()
|
||||
local_ref = MagicMock()
|
||||
local_ref.name = "refs/heads/dev"
|
||||
local_ref.commit.hexsha = "abc"
|
||||
local_ref.commit.committed_date = 1700000000
|
||||
local_ref.is_remote.return_value = False
|
||||
remote_ref = MagicMock()
|
||||
remote_ref.name = "refs/remotes/origin/dev"
|
||||
remote_ref.commit.hexsha = "abc"
|
||||
remote_ref.commit.committed_date = 1700000000
|
||||
remote_ref.is_remote.return_value = True
|
||||
repo.refs = [local_ref, remote_ref]
|
||||
repo.active_branch.name = "dev"
|
||||
svc = TestableGitBranch(repo)
|
||||
result = await svc.list_branches(1)
|
||||
dev_entries = [b for b in result if b["name"] == "dev"]
|
||||
assert len(dev_entries) == 1
|
||||
# #endregion test_list_branches_deduplicates
|
||||
|
||||
# #region test_list_branches_detached_head [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_detached_head(self):
|
||||
"""Detached HEAD → fallback 'dev' branch added if empty."""
|
||||
repo = MagicMock()
|
||||
repo.refs = []
|
||||
type(repo.active_branch).name = PropertyMock(side_effect=TypeError("detached HEAD"))
|
||||
svc = TestableGitBranch(repo)
|
||||
result = await svc.list_branches(1)
|
||||
assert any(b["name"] == "dev" for b in result)
|
||||
# #endregion test_list_branches_detached_head
|
||||
|
||||
|
||||
# ── create_branch ──
|
||||
|
||||
class TestCreateBranch:
|
||||
"""create_branch — new branch from existing."""
|
||||
|
||||
# #region test_create_branch_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_success(self):
|
||||
"""Branch created from specified source."""
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
repo.remotes = [MagicMock()]
|
||||
repo.commit.return_value = MagicMock()
|
||||
new_branch = MagicMock()
|
||||
repo.create_head.return_value = new_branch
|
||||
svc = TestableGitBranch(repo)
|
||||
result = await svc.create_branch(1, "feature-x", "main")
|
||||
repo.create_head.assert_called_with("feature-x", "main")
|
||||
assert result == new_branch
|
||||
# #endregion test_create_branch_success
|
||||
|
||||
# #region test_create_branch_empty_repo [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_empty_repo(self):
|
||||
"""Empty repo → initial commit created, then branch."""
|
||||
repo = MagicMock()
|
||||
repo.heads = []
|
||||
repo.remotes = []
|
||||
repo.working_dir = "/tmp/repo"
|
||||
new_branch = MagicMock()
|
||||
repo.create_head.return_value = new_branch
|
||||
svc = TestableGitBranch(repo)
|
||||
with patch("os.path.exists", return_value=False), \
|
||||
patch("builtins.open", MagicMock()):
|
||||
result = await svc.create_branch(1, "feature", "main")
|
||||
repo.index.add.assert_called_with(["README.md"])
|
||||
repo.index.commit.assert_called_with("Initial commit")
|
||||
# #endregion test_create_branch_empty_repo
|
||||
|
||||
# #region test_create_branch_missing_source [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_missing_source(self):
|
||||
"""Source branch not found → falls back to repo.head."""
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
repo.remotes = [MagicMock()]
|
||||
repo.commit.side_effect = Exception("not found")
|
||||
repo.head = MagicMock()
|
||||
repo.create_head.return_value = MagicMock()
|
||||
svc = TestableGitBranch(repo)
|
||||
await svc.create_branch(1, "feature", "nonexistent")
|
||||
repo.create_head.assert_called_with("feature", repo.head)
|
||||
# #endregion test_create_branch_missing_source
|
||||
|
||||
|
||||
# ── checkout_branch ──
|
||||
|
||||
class TestCheckoutBranch:
|
||||
"""checkout_branch — switch active branch."""
|
||||
|
||||
# #region test_checkout_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_success(self):
|
||||
"""Successful checkout → git.checkout called."""
|
||||
repo = MagicMock()
|
||||
svc = TestableGitBranch(repo)
|
||||
await svc.checkout_branch(1, "main")
|
||||
repo.git.checkout.assert_called_with("main")
|
||||
# #endregion test_checkout_success
|
||||
|
||||
# #region test_checkout_local_changes [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_local_changes(self):
|
||||
"""Local changes conflict → HTTPException 409."""
|
||||
repo = MagicMock()
|
||||
error = GitCommandError("checkout", "error")
|
||||
error.stderr = "error: Your local changes to the following files would be overwritten by checkout:\n\tconfig.yaml"
|
||||
repo.git.checkout.side_effect = error
|
||||
svc = TestableGitBranch(repo)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.checkout_branch(1, "main")
|
||||
assert exc_info.value.status_code == 409
|
||||
detail = exc_info.value.detail
|
||||
assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES"
|
||||
# #endregion test_checkout_local_changes
|
||||
|
||||
# #region test_checkout_generic_error [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_generic_error(self):
|
||||
"""Other GitCommandError → HTTPException 500."""
|
||||
repo = MagicMock()
|
||||
repo.git.checkout.side_effect = GitCommandError("checkout", "fatal: bad ref")
|
||||
svc = TestableGitBranch(repo)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.checkout_branch(1, "bad-branch")
|
||||
assert exc_info.value.status_code == 500
|
||||
# #endregion test_checkout_generic_error
|
||||
|
||||
|
||||
# ── commit_changes ──
|
||||
|
||||
class TestCommitChanges:
|
||||
"""commit_changes — stage and commit."""
|
||||
|
||||
# #region test_commit_dirty_all [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_dirty_all(self):
|
||||
"""Dirty repo, no files → stage all and commit."""
|
||||
repo = MagicMock()
|
||||
repo.is_dirty.return_value = True
|
||||
svc = TestableGitBranch(repo)
|
||||
await svc.commit_changes(1, "update config")
|
||||
repo.git.add.assert_called_with(A=True)
|
||||
repo.index.commit.assert_called_with("update config")
|
||||
# #endregion test_commit_dirty_all
|
||||
|
||||
# #region test_commit_clean [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_clean(self):
|
||||
"""Clean repo, no files → no commit."""
|
||||
repo = MagicMock()
|
||||
repo.is_dirty.return_value = False
|
||||
svc = TestableGitBranch(repo)
|
||||
await svc.commit_changes(1, "nothing")
|
||||
repo.index.commit.assert_not_called()
|
||||
# #endregion test_commit_clean
|
||||
|
||||
# #region test_commit_specific_files [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_specific_files(self):
|
||||
"""Files list → only those files staged."""
|
||||
repo = MagicMock()
|
||||
repo.is_dirty.return_value = False
|
||||
svc = TestableGitBranch(repo)
|
||||
await svc.commit_changes(1, "fix", files=["a.yaml", "b.yaml"])
|
||||
repo.index.add.assert_called_with(["a.yaml", "b.yaml"])
|
||||
repo.index.commit.assert_called_with("fix")
|
||||
# #endregion test_commit_specific_files
|
||||
# #endregion Test.Git.Branch
|
||||
480
backend/tests/services/git/test_git_gitea.py
Normal file
480
backend/tests/services/git/test_git_gitea.py
Normal file
@@ -0,0 +1,480 @@
|
||||
# #region Test.Git.Gitea [C:3] [TYPE Module] [SEMANTICS test,git,gitea,api,http,pull-request]
|
||||
# @BRIEF Tests for GitServiceGiteaMixin — connection testing, headers, API requests, user resolution, repo CRUD, PR creation with fallback.
|
||||
# @RELATION BINDS_TO -> [GitServiceGiteaMixin]
|
||||
# @TEST_EDGE: local_url -> returns True without API call
|
||||
# @TEST_EDGE: invalid_protocol -> returns False
|
||||
# @TEST_EDGE: empty_pat -> returns False
|
||||
# @TEST_EDGE: api_error -> HTTPException with status code
|
||||
# @TEST_EDGE: network_error -> HTTPException 503
|
||||
# @TEST_EDGE: empty_pat_headers -> HTTPException 400
|
||||
# @TEST_EDGE: create_repo_conflict -> fetches existing repo
|
||||
# @TEST_EDGE: pr_404_fallback -> retries with derived URL
|
||||
# @TEST_EDGE: pr_404_branch_missing -> HTTPException 400 with detail
|
||||
# @TEST_EDGE: delete_missing_owner -> HTTPException 400
|
||||
|
||||
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
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.models.git import GitProvider
|
||||
from src.services.git._gitea import GitServiceGiteaMixin
|
||||
|
||||
|
||||
class TestableGitGitea(GitServiceGiteaMixin):
|
||||
"""Concrete test class providing _http_client and URL mixin stubs."""
|
||||
def __init__(self):
|
||||
self._http_client = AsyncMock()
|
||||
|
||||
def _normalize_git_server_url(self, raw_url):
|
||||
normalized = (raw_url or "").strip()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="Git server URL is required")
|
||||
return normalized.rstrip("/")
|
||||
|
||||
def _parse_remote_repo_identity(self, remote_url):
|
||||
from urllib.parse import urlparse
|
||||
normalized = str(remote_url or "").strip()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="empty")
|
||||
if normalized.startswith("git@"):
|
||||
path = normalized.split(":", 1)[1] if ":" in normalized else ""
|
||||
else:
|
||||
parsed = urlparse(normalized)
|
||||
path = parsed.path or ""
|
||||
path = path.strip("/")
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
parts = [s for s in path.split("/") if s]
|
||||
if len(parts) < 2:
|
||||
raise HTTPException(status_code=400, detail="Cannot parse")
|
||||
owner, repo = parts[0], parts[-1]
|
||||
namespace = "/".join(parts[:-1])
|
||||
return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"}
|
||||
|
||||
def _derive_server_url_from_remote(self, remote_url):
|
||||
from urllib.parse import urlparse
|
||||
normalized = str(remote_url or "").strip()
|
||||
if not normalized or normalized.startswith("git@"):
|
||||
return None
|
||||
parsed = urlparse(normalized)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return None
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
netloc = parsed.hostname
|
||||
if parsed.port:
|
||||
netloc = f"{netloc}:{parsed.port}"
|
||||
return f"{parsed.scheme}://{netloc}"
|
||||
|
||||
|
||||
# ── test_connection ──
|
||||
|
||||
class TestTestConnection:
|
||||
"""test_connection — provider connectivity check."""
|
||||
|
||||
# #region test_connection_local_url [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_local_url(self):
|
||||
"""Local/localhost URL → True without API call."""
|
||||
svc = TestableGitGitea()
|
||||
result = await svc.test_connection(GitProvider.GITEA, "http://gitea.local:3000", "pat")
|
||||
assert result is True
|
||||
svc._http_client.get.assert_not_called()
|
||||
# #endregion test_connection_local_url
|
||||
|
||||
# #region test_connection_invalid_protocol [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_invalid_protocol(self):
|
||||
"""Non-HTTP URL → False."""
|
||||
svc = TestableGitGitea()
|
||||
result = await svc.test_connection(GitProvider.GITEA, "ftp://host.com", "pat")
|
||||
assert result is False
|
||||
# #endregion test_connection_invalid_protocol
|
||||
|
||||
# #region test_connection_empty_pat [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_empty_pat(self):
|
||||
"""Empty PAT → False."""
|
||||
svc = TestableGitGitea()
|
||||
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.com", "")
|
||||
assert result is False
|
||||
# #endregion test_connection_empty_pat
|
||||
|
||||
# #region test_connection_gitea_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_gitea_success(self):
|
||||
"""Gitea 200 → True."""
|
||||
svc = TestableGitGitea()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
svc._http_client.get.return_value = resp
|
||||
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "valid-pat")
|
||||
assert result is True
|
||||
svc._http_client.get.assert_called_once()
|
||||
call_url = svc._http_client.get.call_args[0][0]
|
||||
assert "/api/v1/user" in call_url
|
||||
# #endregion test_connection_gitea_success
|
||||
|
||||
# #region test_connection_github_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_github_success(self):
|
||||
"""GitHub 200 → True."""
|
||||
svc = TestableGitGitea()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
svc._http_client.get.return_value = resp
|
||||
result = await svc.test_connection(GitProvider.GITHUB, "https://github.com", "valid-pat")
|
||||
assert result is True
|
||||
call_url = svc._http_client.get.call_args[0][0]
|
||||
assert "api.github.com" in call_url
|
||||
# #endregion test_connection_github_success
|
||||
|
||||
# #region test_connection_gitlab_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_gitlab_success(self):
|
||||
"""GitLab 200 → True."""
|
||||
svc = TestableGitGitea()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
svc._http_client.get.return_value = resp
|
||||
result = await svc.test_connection(GitProvider.GITLAB, "https://gitlab.com", "valid-pat")
|
||||
assert result is True
|
||||
call_url = svc._http_client.get.call_args[0][0]
|
||||
assert "/api/v4/user" in call_url
|
||||
# #endregion test_connection_gitlab_success
|
||||
|
||||
# #region test_connection_failure_status [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_failure_status(self):
|
||||
"""Non-200 response → False."""
|
||||
svc = TestableGitGitea()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 401
|
||||
svc._http_client.get.return_value = resp
|
||||
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "bad-pat")
|
||||
assert result is False
|
||||
# #endregion test_connection_failure_status
|
||||
|
||||
# #region test_connection_network_error [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_network_error(self):
|
||||
"""Network exception → False."""
|
||||
svc = TestableGitGitea()
|
||||
svc._http_client.get.side_effect = Exception("connection refused")
|
||||
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "pat")
|
||||
assert result is False
|
||||
# #endregion test_connection_network_error
|
||||
|
||||
# #region test_connection_unknown_provider [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_unknown_provider(self):
|
||||
"""Unknown provider → False."""
|
||||
svc = TestableGitGitea()
|
||||
result = await svc.test_connection("BITBUCKET", "https://bitbucket.org", "pat")
|
||||
assert result is False
|
||||
# #endregion test_connection_unknown_provider
|
||||
|
||||
|
||||
# ── _gitea_headers ──
|
||||
|
||||
class TestGiteaHeaders:
|
||||
"""_gitea_headers — authorization header construction."""
|
||||
|
||||
# #region test_headers_valid_pat [C:2] [TYPE Function]
|
||||
def test_headers_valid_pat(self):
|
||||
"""Valid PAT → Authorization token header."""
|
||||
svc = TestableGitGitea()
|
||||
headers = svc._gitea_headers("my-token")
|
||||
assert headers["Authorization"] == "token my-token"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
# #endregion test_headers_valid_pat
|
||||
|
||||
# #region test_headers_empty_pat [C:2] [TYPE Function]
|
||||
def test_headers_empty_pat(self):
|
||||
"""Empty PAT → HTTPException 400."""
|
||||
svc = TestableGitGitea()
|
||||
with pytest.raises(HTTPException, match="PAT is required"):
|
||||
svc._gitea_headers("")
|
||||
# #endregion test_headers_empty_pat
|
||||
|
||||
|
||||
# ── _gitea_request ──
|
||||
|
||||
class TestGiteaRequest:
|
||||
"""_gitea_request — HTTP request wrapper with error mapping."""
|
||||
|
||||
# #region test_request_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_success(self):
|
||||
"""200 response → JSON payload returned."""
|
||||
svc = TestableGitGitea()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"login": "admin"}
|
||||
svc._http_client.request.return_value = resp
|
||||
result = await svc._gitea_request("GET", "https://gitea.com", "pat", "/user")
|
||||
assert result == {"login": "admin"}
|
||||
# #endregion test_request_success
|
||||
|
||||
# #region test_request_204_no_content [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_204_no_content(self):
|
||||
"""204 → returns None."""
|
||||
svc = TestableGitGitea()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 204
|
||||
svc._http_client.request.return_value = resp
|
||||
result = await svc._gitea_request("DELETE", "https://gitea.com", "pat", "/repos/o/r")
|
||||
assert result is None
|
||||
# #endregion test_request_204_no_content
|
||||
|
||||
# #region test_request_api_error [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_api_error(self):
|
||||
"""4xx/5xx → HTTPException with detail."""
|
||||
svc = TestableGitGitea()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 404
|
||||
resp.text = "Not Found"
|
||||
resp.json.return_value = {"message": "repository not found"}
|
||||
svc._http_client.request.return_value = resp
|
||||
with pytest.raises(HTTPException, match="repository not found"):
|
||||
await svc._gitea_request("GET", "https://gitea.com", "pat", "/repos/o/r")
|
||||
# #endregion test_request_api_error
|
||||
|
||||
# #region test_request_network_error [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_network_error(self):
|
||||
"""Network exception → HTTPException 503."""
|
||||
svc = TestableGitGitea()
|
||||
svc._http_client.request.side_effect = Exception("connection refused")
|
||||
with pytest.raises(HTTPException, match="unavailable"):
|
||||
await svc._gitea_request("GET", "https://gitea.com", "pat", "/user")
|
||||
# #endregion test_request_network_error
|
||||
|
||||
|
||||
# ── get_gitea_current_user ──
|
||||
|
||||
class TestGetGiteaCurrentUser:
|
||||
"""get_gitea_current_user — resolve authenticated username."""
|
||||
|
||||
# #region test_current_user_login [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_user_login(self):
|
||||
"""Returns 'login' field from API response."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"login": "admin"}):
|
||||
result = await svc.get_gitea_current_user("https://gitea.com", "pat")
|
||||
assert result == "admin"
|
||||
# #endregion test_current_user_login
|
||||
|
||||
# #region test_current_user_username_fallback [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_user_username_fallback(self):
|
||||
"""Falls back to 'username' field."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"username": "bob"}):
|
||||
result = await svc.get_gitea_current_user("https://gitea.com", "pat")
|
||||
assert result == "bob"
|
||||
# #endregion test_current_user_username_fallback
|
||||
|
||||
# #region test_current_user_missing [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_user_missing(self):
|
||||
"""No login/username → HTTPException 500."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={}):
|
||||
with pytest.raises(HTTPException, match="Failed to resolve"):
|
||||
await svc.get_gitea_current_user("https://gitea.com", "pat")
|
||||
# #endregion test_current_user_missing
|
||||
|
||||
|
||||
# ── list_gitea_repositories ──
|
||||
|
||||
class TestListGiteaRepositories:
|
||||
"""list_gitea_repositories — list user repos."""
|
||||
|
||||
# #region test_list_repos_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repos_success(self):
|
||||
"""Returns list from API."""
|
||||
svc = TestableGitGitea()
|
||||
repos = [{"name": "repo1"}, {"name": "repo2"}]
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=repos):
|
||||
result = await svc.list_gitea_repositories("https://gitea.com", "pat")
|
||||
assert len(result) == 2
|
||||
# #endregion test_list_repos_success
|
||||
|
||||
# #region test_list_repos_non_list [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repos_non_list(self):
|
||||
"""Non-list response → returns []."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"error": "bad"}):
|
||||
result = await svc.list_gitea_repositories("https://gitea.com", "pat")
|
||||
assert result == []
|
||||
# #endregion test_list_repos_non_list
|
||||
|
||||
|
||||
# ── create_gitea_repository ──
|
||||
|
||||
class TestCreateGiteaRepository:
|
||||
"""create_gitea_repository — create or fetch existing."""
|
||||
|
||||
# #region test_create_repo_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_repo_success(self):
|
||||
"""200 → returns created repo dict."""
|
||||
svc = TestableGitGitea()
|
||||
created = {"name": "new-repo", "clone_url": "https://gitea.com/user/new-repo.git"}
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=created):
|
||||
result = await svc.create_gitea_repository("https://gitea.com", "pat", "new-repo")
|
||||
assert result["name"] == "new-repo"
|
||||
# #endregion test_create_repo_success
|
||||
|
||||
# #region test_create_repo_conflict_fetches_existing [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_repo_conflict_fetches_existing(self):
|
||||
"""409 → fetches existing repo by name."""
|
||||
svc = TestableGitGitea()
|
||||
existing = {"name": "existing-repo", "clone_url": "https://gitea.com/user/existing-repo.git"}
|
||||
call_count = 0
|
||||
async def mock_request(method, url, pat, endpoint, payload=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if method == "POST":
|
||||
raise HTTPException(status_code=409, detail="conflict")
|
||||
if endpoint.startswith("/user"):
|
||||
return {"login": "user"}
|
||||
return existing
|
||||
with patch.object(svc, "_gitea_request", side_effect=mock_request):
|
||||
result = await svc.create_gitea_repository("https://gitea.com", "pat", "existing-repo")
|
||||
assert result["name"] == "existing-repo"
|
||||
# #endregion test_create_repo_conflict_fetches_existing
|
||||
|
||||
# #region test_create_repo_unexpected_response [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_repo_unexpected_response(self):
|
||||
"""Non-dict response → HTTPException 500."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value="not a dict"):
|
||||
with pytest.raises(HTTPException, match="Unexpected"):
|
||||
await svc.create_gitea_repository("https://gitea.com", "pat", "repo")
|
||||
# #endregion test_create_repo_unexpected_response
|
||||
|
||||
|
||||
# ── delete_gitea_repository ──
|
||||
|
||||
class TestDeleteGiteaRepository:
|
||||
"""delete_gitea_repository — delete remote repo."""
|
||||
|
||||
# #region test_delete_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_success(self):
|
||||
"""Valid owner/repo → DELETE request made."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=None) as mock_req:
|
||||
await svc.delete_gitea_repository("https://gitea.com", "pat", "owner", "repo")
|
||||
mock_req.assert_called_once()
|
||||
assert mock_req.call_args[0][0] == "DELETE"
|
||||
# #endregion test_delete_success
|
||||
|
||||
# #region test_delete_missing_owner [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_missing_owner(self):
|
||||
"""Empty owner → HTTPException 400."""
|
||||
svc = TestableGitGitea()
|
||||
with pytest.raises(HTTPException, match="required"):
|
||||
await svc.delete_gitea_repository("https://gitea.com", "pat", "", "repo")
|
||||
# #endregion test_delete_missing_owner
|
||||
|
||||
|
||||
# ── _gitea_branch_exists ──
|
||||
|
||||
class TestGiteaBranchExists:
|
||||
"""_gitea_branch_exists — check branch existence."""
|
||||
|
||||
# #region test_branch_exists_true [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_exists_true(self):
|
||||
"""200 → True."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"name": "dev"}):
|
||||
result = await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "dev")
|
||||
assert result is True
|
||||
# #endregion test_branch_exists_true
|
||||
|
||||
# #region test_branch_exists_404 [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_exists_404(self):
|
||||
"""404 → False."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=404)):
|
||||
result = await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "missing")
|
||||
assert result is False
|
||||
# #endregion test_branch_exists_404
|
||||
|
||||
# #region test_branch_exists_empty_params [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_exists_empty_params(self):
|
||||
"""Empty owner/repo/branch → False."""
|
||||
svc = TestableGitGitea()
|
||||
assert await svc._gitea_branch_exists("https://gitea.com", "pat", "", "repo", "dev") is False
|
||||
assert await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "", "dev") is False
|
||||
assert await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "") is False
|
||||
# #endregion test_branch_exists_empty_params
|
||||
|
||||
|
||||
# ── create_gitea_pull_request ──
|
||||
|
||||
class TestCreateGiteaPullRequest:
|
||||
"""create_gitea_pull_request — PR creation with fallback."""
|
||||
|
||||
# #region test_pr_success [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_success(self):
|
||||
"""Successful PR → normalized metadata."""
|
||||
svc = TestableGitGitea()
|
||||
pr_data = {"number": 42, "html_url": "https://gitea.com/o/r/pulls/42", "state": "open"}
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=pr_data):
|
||||
result = await svc.create_gitea_pull_request(
|
||||
"https://gitea.com", "pat", "https://gitea.com/owner/repo.git",
|
||||
"feature", "main", "Add feature",
|
||||
)
|
||||
assert result["id"] == 42
|
||||
assert result["status"] == "open"
|
||||
assert "pulls/42" in result["url"]
|
||||
# #endregion test_pr_success
|
||||
|
||||
# #region test_pr_404_non_retryable [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_404_non_retryable(self):
|
||||
"""404 with no fallback URL → raises with branch detail."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=404, detail="not found")), \
|
||||
patch.object(svc, "_build_gitea_pr_404_detail", new_callable=AsyncMock, return_value=None):
|
||||
with pytest.raises(HTTPException, match="not found"):
|
||||
await svc.create_gitea_pull_request(
|
||||
"https://gitea.com", "pat", "https://gitea.com/owner/repo.git",
|
||||
"feature", "main", "PR title",
|
||||
)
|
||||
# #endregion test_pr_404_non_retryable
|
||||
|
||||
# #region test_pr_unexpected_response [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_unexpected_response(self):
|
||||
"""Non-dict response → HTTPException 500."""
|
||||
svc = TestableGitGitea()
|
||||
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value="not a dict"):
|
||||
with pytest.raises(HTTPException, match="Unexpected"):
|
||||
await svc.create_gitea_pull_request(
|
||||
"https://gitea.com", "pat", "https://gitea.com/owner/repo.git",
|
||||
"feature", "main", "PR title",
|
||||
)
|
||||
# #endregion test_pr_unexpected_response
|
||||
# #endregion Test.Git.Gitea
|
||||
435
backend/tests/services/git/test_git_merge.py
Normal file
435
backend/tests/services/git/test_git_merge.py
Normal file
@@ -0,0 +1,435 @@
|
||||
# #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 -> [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_blob_none [C:2] [TYPE Function]
|
||||
def test_blob_none(self):
|
||||
"""None blob returns empty string."""
|
||||
svc = TestableGitMerge()
|
||||
assert svc._read_blob_text(None) == ""
|
||||
# #endregion test_blob_none
|
||||
|
||||
# #region test_blob_valid [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_blob_valid
|
||||
|
||||
# #region test_blob_decode_error [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_blob_decode_error
|
||||
|
||||
|
||||
# ── _get_unmerged_file_paths ──
|
||||
|
||||
class TestGetUnmergedFilePaths:
|
||||
"""_get_unmerged_file_paths — list conflicted files."""
|
||||
|
||||
# #region test_unmerged_files [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_unmerged_files
|
||||
|
||||
# #region test_unmerged_exception [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_unmerged_exception
|
||||
|
||||
|
||||
# ── get_merge_status ──
|
||||
|
||||
class TestGetMergeStatus:
|
||||
"""get_merge_status — unfinished merge detection."""
|
||||
|
||||
# #region test_no_merge_in_progress [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_no_merge_in_progress
|
||||
|
||||
# #region test_merge_in_progress [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_merge_in_progress
|
||||
|
||||
|
||||
# ── get_merge_conflicts ──
|
||||
|
||||
class TestGetMergeConflicts:
|
||||
"""get_merge_conflicts — list conflict details."""
|
||||
|
||||
# #region test_conflicts_listed [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_conflicts_listed
|
||||
|
||||
# #region test_no_conflicts [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_no_conflicts
|
||||
|
||||
|
||||
# ── resolve_merge_conflicts ──
|
||||
|
||||
class TestResolveMergeConflicts:
|
||||
"""resolve_merge_conflicts — strategy-based resolution."""
|
||||
|
||||
# #region test_resolve_mine [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_resolve_mine
|
||||
|
||||
# #region test_resolve_theirs [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_resolve_theirs
|
||||
|
||||
# #region test_resolve_manual [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_resolve_manual
|
||||
|
||||
# #region test_resolve_invalid_strategy [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_resolve_invalid_strategy
|
||||
|
||||
# #region test_resolve_empty_filepath [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_resolve_empty_filepath
|
||||
|
||||
# #region test_resolve_empty_resolutions [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_resolve_empty_resolutions
|
||||
|
||||
|
||||
# ── abort_merge ──
|
||||
|
||||
class TestAbortMerge:
|
||||
"""abort_merge — cancel ongoing merge."""
|
||||
|
||||
# #region test_abort_success [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_abort_success
|
||||
|
||||
# #region test_abort_no_merge [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_abort_no_merge
|
||||
|
||||
# #region test_abort_conflict [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_abort_conflict
|
||||
|
||||
|
||||
# ── continue_merge ──
|
||||
|
||||
class TestContinueMerge:
|
||||
"""continue_merge — finalize merge after resolution."""
|
||||
|
||||
# #region test_continue_with_message [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_continue_with_message
|
||||
|
||||
# #region test_continue_no_message [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_continue_no_message
|
||||
|
||||
# #region test_continue_unresolved_conflicts [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_continue_unresolved_conflicts
|
||||
|
||||
# #region test_continue_nothing_to_commit [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_continue_nothing_to_commit
|
||||
|
||||
|
||||
# ── promote_direct_merge ──
|
||||
|
||||
class TestPromoteDirectMerge:
|
||||
"""promote_direct_merge — direct branch-to-branch merge."""
|
||||
|
||||
# #region test_promote_same_branch [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_promote_same_branch
|
||||
|
||||
# #region test_promote_empty_branch [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_promote_empty_branch
|
||||
|
||||
# #region test_promote_success [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_promote_success
|
||||
|
||||
# #region test_promote_no_origin [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_promote_no_origin
|
||||
|
||||
# #region test_promote_source_not_found [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_promote_source_not_found
|
||||
# #endregion Test.Git.Merge
|
||||
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
|
||||
347
backend/tests/services/git/test_git_url.py
Normal file
347
backend/tests/services/git/test_git_url.py
Normal file
@@ -0,0 +1,347 @@
|
||||
# #region Test.Git.Url [C:3] [TYPE Module] [SEMANTICS test,git,url,parse,remote]
|
||||
# @BRIEF Tests for GitServiceUrlMixin — host extraction, credential stripping, host replacement, origin alignment, remote identity parsing, server URL derivation, URL normalization.
|
||||
# @RELATION BINDS_TO -> [GitServiceUrlMixin]
|
||||
# @TEST_EDGE: empty_url -> returns None or empty
|
||||
# @TEST_EDGE: non_http_scheme -> returns None (ssh/git protocol)
|
||||
# @TEST_EDGE: url_with_port -> host:port preserved
|
||||
# @TEST_EDGE: url_with_credentials -> credentials stripped/replaced correctly
|
||||
# @TEST_EDGE: ssh_url -> parsed via git@ split
|
||||
# @TEST_EDGE: missing_owner_repo -> raises HTTPException 400
|
||||
# @TEST_EDGE: empty_server_url -> raises HTTPException 400
|
||||
|
||||
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
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.services.git._url import GitServiceUrlMixin
|
||||
|
||||
|
||||
class TestableGitUrl(GitServiceUrlMixin):
|
||||
"""Minimal concrete subclass for testing URL mixin methods."""
|
||||
pass
|
||||
|
||||
|
||||
# ── _extract_http_host ──
|
||||
|
||||
class TestExtractHttpHost:
|
||||
"""_extract_http_host — host[:port] extraction from HTTP(S) URLs."""
|
||||
|
||||
# #region test_extract_host_https [C:2] [TYPE Function]
|
||||
def test_extract_host_https(self):
|
||||
"""Standard HTTPS URL returns lowercase host."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._extract_http_host("https://Git.Example.COM/org/repo.git")
|
||||
assert result == "git.example.com"
|
||||
# #endregion test_extract_host_https
|
||||
|
||||
# #region test_extract_host_with_port [C:2] [TYPE Function]
|
||||
def test_extract_host_with_port(self):
|
||||
"""URL with port returns host:port."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._extract_http_host("http://gitea.local:3000/repo")
|
||||
assert result == "gitea.local:3000"
|
||||
# #endregion test_extract_host_with_port
|
||||
|
||||
# #region test_extract_host_empty [C:2] [TYPE Function]
|
||||
def test_extract_host_empty(self):
|
||||
"""Empty/None input returns None."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._extract_http_host(None) is None
|
||||
assert svc._extract_http_host("") is None
|
||||
assert svc._extract_http_host(" ") is None
|
||||
# #endregion test_extract_host_empty
|
||||
|
||||
# #region test_extract_host_non_http [C:2] [TYPE Function]
|
||||
def test_extract_host_non_http(self):
|
||||
"""SSH or FTP scheme returns None."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._extract_http_host("git@github.com:org/repo.git") is None
|
||||
assert svc._extract_http_host("ftp://files.example.com/repo") is None
|
||||
# #endregion test_extract_host_non_http
|
||||
|
||||
# #region test_extract_host_no_hostname [C:2] [TYPE Function]
|
||||
def test_extract_host_no_hostname(self):
|
||||
"""Malformed URL with no hostname returns None."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._extract_http_host("http://") is None
|
||||
# #endregion test_extract_host_no_hostname
|
||||
|
||||
|
||||
# ── _strip_url_credentials ──
|
||||
|
||||
class TestStripUrlCredentials:
|
||||
"""_strip_url_credentials — remove user:pass from URL."""
|
||||
|
||||
# #region test_strip_credentials [C:2] [TYPE Function]
|
||||
def test_strip_credentials(self):
|
||||
"""Credentials removed, scheme/host/path preserved."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._strip_url_credentials("https://oauth2:token123@git.example.com/org/repo.git")
|
||||
assert result == "https://git.example.com/org/repo.git"
|
||||
# #endregion test_strip_credentials
|
||||
|
||||
# #region test_strip_no_credentials [C:2] [TYPE Function]
|
||||
def test_strip_no_credentials(self):
|
||||
"""URL without credentials returned unchanged."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._strip_url_credentials("https://git.example.com/org/repo.git")
|
||||
assert result == "https://git.example.com/org/repo.git"
|
||||
# #endregion test_strip_no_credentials
|
||||
|
||||
# #region test_strip_empty [C:2] [TYPE Function]
|
||||
def test_strip_empty(self):
|
||||
"""Empty string returned as-is."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._strip_url_credentials("") == ""
|
||||
# #endregion test_strip_empty
|
||||
|
||||
# #region test_strip_non_http_passthrough [C:2] [TYPE Function]
|
||||
def test_strip_non_http_passthrough(self):
|
||||
"""Non-HTTP URL returned unchanged."""
|
||||
svc = TestableGitUrl()
|
||||
url = "git@github.com:org/repo.git"
|
||||
assert svc._strip_url_credentials(url) == url
|
||||
# #endregion test_strip_non_http_passthrough
|
||||
|
||||
# #region test_strip_with_port [C:2] [TYPE Function]
|
||||
def test_strip_with_port(self):
|
||||
"""Port preserved after credential stripping."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._strip_url_credentials("https://user:pass@gitea.local:3000/repo")
|
||||
assert result == "https://gitea.local:3000/repo"
|
||||
# #endregion test_strip_with_port
|
||||
|
||||
|
||||
# ── _replace_host_in_url ──
|
||||
|
||||
class TestReplaceHostInUrl:
|
||||
"""_replace_host_in_url — swap origin host with config host."""
|
||||
|
||||
# #region test_replace_host_basic [C:2] [TYPE Function]
|
||||
def test_replace_host_basic(self):
|
||||
"""Source host replaced with config host, credentials preserved."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._replace_host_in_url(
|
||||
"https://old-host.com/org/repo.git",
|
||||
"https://new-host.com",
|
||||
)
|
||||
assert result == "https://new-host.com/org/repo.git"
|
||||
# #endregion test_replace_host_basic
|
||||
|
||||
# #region test_replace_host_preserves_auth [C:2] [TYPE Function]
|
||||
def test_replace_host_preserves_auth(self):
|
||||
"""Credentials from source URL preserved on new host."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._replace_host_in_url(
|
||||
"https://oauth2:tok123@old-host.com/org/repo.git",
|
||||
"https://new-host.com:3000",
|
||||
)
|
||||
assert "oauth2:tok123@new-host.com:3000" in result
|
||||
assert "/org/repo.git" in result
|
||||
# #endregion test_replace_host_preserves_auth
|
||||
|
||||
# #region test_replace_host_empty_inputs [C:2] [TYPE Function]
|
||||
def test_replace_host_empty_inputs(self):
|
||||
"""Empty source or config returns None."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._replace_host_in_url(None, "https://x.com") is None
|
||||
assert svc._replace_host_in_url("https://x.com", None) is None
|
||||
assert svc._replace_host_in_url("", "") is None
|
||||
# #endregion test_replace_host_empty_inputs
|
||||
|
||||
# #region test_replace_host_non_http [C:2] [TYPE Function]
|
||||
def test_replace_host_non_http(self):
|
||||
"""Non-HTTP source or config returns None."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._replace_host_in_url("git@old:x.git", "https://new.com") is None
|
||||
assert svc._replace_host_in_url("https://old.com/x", "ftp://new.com") is None
|
||||
# #endregion test_replace_host_non_http
|
||||
|
||||
|
||||
# ── _parse_remote_repo_identity ──
|
||||
|
||||
class TestParseRemoteRepoIdentity:
|
||||
"""_parse_remote_repo_identity — extract owner/repo from remote URL."""
|
||||
|
||||
# #region test_parse_https_url [C:2] [TYPE Function]
|
||||
def test_parse_https_url(self):
|
||||
"""HTTPS URL parsed into owner, repo, namespace."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._parse_remote_repo_identity("https://gitea.example.com/org/repo.git")
|
||||
assert result["owner"] == "org"
|
||||
assert result["repo"] == "repo"
|
||||
assert result["namespace"] == "org"
|
||||
assert result["full_name"] == "org/repo"
|
||||
# #endregion test_parse_https_url
|
||||
|
||||
# #region test_parse_ssh_url [C:2] [TYPE Function]
|
||||
def test_parse_ssh_url(self):
|
||||
"""SSH git@ URL parsed via colon split."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._parse_remote_repo_identity("git@github.com:myorg/myrepo.git")
|
||||
assert result["owner"] == "myorg"
|
||||
assert result["repo"] == "myrepo"
|
||||
assert result["namespace"] == "myorg"
|
||||
# #endregion test_parse_ssh_url
|
||||
|
||||
# #region test_parse_nested_namespace [C:2] [TYPE Function]
|
||||
def test_parse_nested_namespace(self):
|
||||
"""Nested namespace (subgroups) preserved."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._parse_remote_repo_identity("https://gitlab.com/group/subgroup/project.git")
|
||||
assert result["owner"] == "group"
|
||||
assert result["repo"] == "project"
|
||||
assert result["namespace"] == "group/subgroup"
|
||||
assert result["full_name"] == "group/subgroup/project"
|
||||
# #endregion test_parse_nested_namespace
|
||||
|
||||
# #region test_parse_empty_url_raises [C:2] [TYPE Function]
|
||||
def test_parse_empty_url_raises(self):
|
||||
"""Empty URL raises HTTPException 400."""
|
||||
svc = TestableGitUrl()
|
||||
with pytest.raises(HTTPException, match="empty"):
|
||||
svc._parse_remote_repo_identity("")
|
||||
# #endregion test_parse_empty_url_raises
|
||||
|
||||
# #region test_parse_single_segment_raises [C:2] [TYPE Function]
|
||||
def test_parse_single_segment_raises(self):
|
||||
"""URL with <2 path segments raises HTTPException 400."""
|
||||
svc = TestableGitUrl()
|
||||
with pytest.raises(HTTPException, match="Cannot parse"):
|
||||
svc._parse_remote_repo_identity("https://host.com/onlyone")
|
||||
# #endregion test_parse_single_segment_raises
|
||||
|
||||
# #region test_parse_no_git_suffix [C:2] [TYPE Function]
|
||||
def test_parse_no_git_suffix(self):
|
||||
"""URL without .git suffix parsed correctly."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._parse_remote_repo_identity("https://host.com/owner/repo")
|
||||
assert result["repo"] == "repo"
|
||||
# #endregion test_parse_no_git_suffix
|
||||
|
||||
|
||||
# ── _derive_server_url_from_remote ──
|
||||
|
||||
class TestDeriveServerUrlFromRemote:
|
||||
"""_derive_server_url_from_remote — build API base URL."""
|
||||
|
||||
# #region test_derive_https [C:2] [TYPE Function]
|
||||
def test_derive_https(self):
|
||||
"""HTTPS URL → scheme://host base."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._derive_server_url_from_remote("https://gitea.example.com/org/repo.git")
|
||||
assert result == "https://gitea.example.com"
|
||||
# #endregion test_derive_https
|
||||
|
||||
# #region test_derive_with_port [C:2] [TYPE Function]
|
||||
def test_derive_with_port(self):
|
||||
"""Port preserved in derived URL."""
|
||||
svc = TestableGitUrl()
|
||||
result = svc._derive_server_url_from_remote("http://gitea.local:3000/repo")
|
||||
assert result == "http://gitea.local:3000"
|
||||
# #endregion test_derive_with_port
|
||||
|
||||
# #region test_derive_ssh_returns_none [C:2] [TYPE Function]
|
||||
def test_derive_ssh_returns_none(self):
|
||||
"""SSH URL returns None."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._derive_server_url_from_remote("git@host:org/repo.git") is None
|
||||
# #endregion test_derive_ssh_returns_none
|
||||
|
||||
# #region test_derive_empty_returns_none [C:2] [TYPE Function]
|
||||
def test_derive_empty_returns_none(self):
|
||||
"""Empty input returns None."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._derive_server_url_from_remote("") is None
|
||||
assert svc._derive_server_url_from_remote(None) is None
|
||||
# #endregion test_derive_empty_returns_none
|
||||
|
||||
|
||||
# ── _normalize_git_server_url ──
|
||||
|
||||
class TestNormalizeGitServerUrl:
|
||||
"""_normalize_git_server_url — strip trailing slash."""
|
||||
|
||||
# #region test_normalize_strips_slash [C:2] [TYPE Function]
|
||||
def test_normalize_strips_slash(self):
|
||||
"""Trailing slash removed."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._normalize_git_server_url("https://gitea.com/") == "https://gitea.com"
|
||||
# #endregion test_normalize_strips_slash
|
||||
|
||||
# #region test_normalize_no_change [C:2] [TYPE Function]
|
||||
def test_normalize_no_change(self):
|
||||
"""URL without trailing slash unchanged."""
|
||||
svc = TestableGitUrl()
|
||||
assert svc._normalize_git_server_url("https://gitea.com") == "https://gitea.com"
|
||||
# #endregion test_normalize_no_change
|
||||
|
||||
# #region test_normalize_empty_raises [C:2] [TYPE Function]
|
||||
def test_normalize_empty_raises(self):
|
||||
"""Empty URL raises HTTPException 400."""
|
||||
svc = TestableGitUrl()
|
||||
with pytest.raises(HTTPException, match="required"):
|
||||
svc._normalize_git_server_url("")
|
||||
# #endregion test_normalize_empty_raises
|
||||
|
||||
|
||||
# ── _align_origin_host_with_config ──
|
||||
|
||||
class TestAlignOriginHostWithConfig:
|
||||
"""_align_origin_host_with_config — auto-align origin host drift."""
|
||||
|
||||
# #region test_align_no_drift [C:2] [TYPE Function]
|
||||
def test_align_no_drift(self):
|
||||
"""Matching hosts → no action, returns None."""
|
||||
svc = TestableGitUrl()
|
||||
origin = MagicMock()
|
||||
result = svc._align_origin_host_with_config(
|
||||
dashboard_id=1,
|
||||
origin=origin,
|
||||
config_url="https://gitea.com",
|
||||
current_origin_url="https://gitea.com/org/repo.git",
|
||||
binding_remote_url=None,
|
||||
)
|
||||
assert result is None
|
||||
origin.set_url.assert_not_called()
|
||||
# #endregion test_align_no_drift
|
||||
|
||||
# #region test_align_host_mismatch [C:2] [TYPE Function]
|
||||
@patch("src.services.git._url.SessionLocal")
|
||||
def test_align_host_mismatch(self, mock_session_cls):
|
||||
"""Mismatched hosts → origin.set_url called, DB updated."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
svc = TestableGitUrl()
|
||||
origin = MagicMock()
|
||||
result = svc._align_origin_host_with_config(
|
||||
dashboard_id=1,
|
||||
origin=origin,
|
||||
config_url="https://new-host.com",
|
||||
current_origin_url="https://old-host.com/org/repo.git",
|
||||
binding_remote_url=None,
|
||||
)
|
||||
assert result is not None
|
||||
assert "new-host.com" in result
|
||||
origin.set_url.assert_called_once()
|
||||
# #endregion test_align_host_mismatch
|
||||
|
||||
# #region test_align_missing_config [C:2] [TYPE Function]
|
||||
def test_align_missing_config(self):
|
||||
"""No config URL → returns None, no action."""
|
||||
svc = TestableGitUrl()
|
||||
origin = MagicMock()
|
||||
result = svc._align_origin_host_with_config(
|
||||
dashboard_id=1, origin=origin, config_url=None,
|
||||
current_origin_url="https://host.com/repo", binding_remote_url=None,
|
||||
)
|
||||
assert result is None
|
||||
# #endregion test_align_missing_config
|
||||
# #endregion Test.Git.Url
|
||||
Reference in New Issue
Block a user