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:
2026-06-14 15:41:46 +03:00
parent 8f9856a646
commit 997329e2a5
33 changed files with 8976 additions and 721 deletions

View 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

View 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

View 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

View 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

View 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