226 lines
9.9 KiB
Python
226 lines
9.9 KiB
Python
# #region Test.Git.Gitea.Coverage [C:3] [TYPE Module] [SEMANTICS test, git, gitea, coverage, branch, pr, error]
|
|
# @BRIEF Additional edge coverage for GitServiceGiteaMixin — error JSON parse, description params, non-dict existing, branch re-raise, PR detail, fallback failure.
|
|
# @RELATION BINDS_TO -> [GitServiceGiteaMixin]
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from src.models.git import GitProvider
|
|
from src.services.git._gitea import GitServiceGiteaMixin
|
|
|
|
|
|
class TestableGitGitea(GitServiceGiteaMixin):
|
|
"""Concrete wrapper that inherits mixin methods and provides URL 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}"
|
|
|
|
|
|
@pytest.fixture
|
|
def svc():
|
|
return TestableGitGitea()
|
|
|
|
|
|
class TestGiteaRequestCoverage:
|
|
"""Cover JSON parse exception branch in _gitea_request."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_response_no_json(self, svc):
|
|
"""4xx response, JSON parse fails → uses raw text for detail."""
|
|
resp = MagicMock()
|
|
resp.status_code = 403
|
|
resp.text = "forbidden"
|
|
resp.json.side_effect = ValueError("not json")
|
|
svc._http_client.request.return_value = resp
|
|
with pytest.raises(HTTPException, match="forbidden"):
|
|
await svc._gitea_request("GET", "https://gitea.com", "pat", "/user")
|
|
|
|
|
|
class TestCreateGiteaRepositoryCoverage:
|
|
"""Cover description param and non-dict existing repo response."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_description(self, svc):
|
|
"""description provided → sent in payload."""
|
|
created = {"name": "my-repo"}
|
|
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=created) as mock_req:
|
|
result = await svc.create_gitea_repository(
|
|
"https://gitea.com", "pat", "my-repo",
|
|
description="Test description",
|
|
)
|
|
assert result["name"] == "my-repo"
|
|
call_payload = mock_req.call_args[1]["payload"]
|
|
assert call_payload["description"] == "Test description"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_description_default_empty(self, svc):
|
|
"""No description → not in payload."""
|
|
created = {"name": "repo"}
|
|
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=created) as mock_req:
|
|
await svc.create_gitea_repository("https://gitea.com", "pat", "repo")
|
|
call_payload = mock_req.call_args[1]["payload"]
|
|
assert "description" not in call_payload
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conflict_existing_not_dict_raises(self, svc):
|
|
"""409 conflict, existing GET returns non-dict → raises original 409."""
|
|
call_log = []
|
|
async def mock_request(method, url, pat, endpoint, payload=None):
|
|
call_log.append((method, endpoint))
|
|
if method == "POST":
|
|
raise HTTPException(status_code=409, detail="conflict")
|
|
if method == "GET" and endpoint == "/user":
|
|
return {"login": "owner"}
|
|
return ["not_a_dict"]
|
|
with patch.object(svc, "_gitea_request", side_effect=mock_request):
|
|
with pytest.raises(HTTPException, match="409"):
|
|
await svc.create_gitea_repository("https://gitea.com", "pat", "existing-repo")
|
|
|
|
|
|
class TestGiteaBranchExistsCoverage:
|
|
"""Cover non-404 re-raise in _gitea_branch_exists."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_404_raises(self, svc):
|
|
"""Non-404 HTTPException → re-raised."""
|
|
with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=403, detail="forbidden")):
|
|
with pytest.raises(HTTPException, match="forbidden"):
|
|
await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "dev")
|
|
|
|
|
|
class TestBuildGiteaPR404Detail:
|
|
"""_build_gitea_pr_404_detail — all branch-combination paths."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_branch_missing(self, svc):
|
|
"""Source branch not found → specific detail."""
|
|
with patch.object(svc, "_gitea_branch_exists", new_callable=AsyncMock) as mock_exists:
|
|
async def exists_side(server_url, pat, owner, repo, branch):
|
|
return branch == "main"
|
|
mock_exists.side_effect = exists_side
|
|
result = await svc._build_gitea_pr_404_detail(
|
|
"https://gitea.com", "pat", "owner", "repo", "feature", "main",
|
|
)
|
|
assert "source branch" in (result or "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_target_branch_missing(self, svc):
|
|
"""Target branch not found → specific detail."""
|
|
with patch.object(svc, "_gitea_branch_exists", new_callable=AsyncMock) as mock_exists:
|
|
async def exists_side(server_url, pat, owner, repo, branch):
|
|
return branch == "feature"
|
|
mock_exists.side_effect = exists_side
|
|
result = await svc._build_gitea_pr_404_detail(
|
|
"https://gitea.com", "pat", "owner", "repo", "feature", "main",
|
|
)
|
|
assert "target branch" in (result or "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_both_branches_exist(self, svc):
|
|
"""Both branches exist → returns None."""
|
|
with patch.object(svc, "_gitea_branch_exists", new_callable=AsyncMock, return_value=True):
|
|
result = await svc._build_gitea_pr_404_detail(
|
|
"https://gitea.com", "pat", "owner", "repo", "feature", "main",
|
|
)
|
|
assert result is None
|
|
|
|
|
|
class TestCreateGiteaPRCoverage:
|
|
"""Cover fallback retry still 404 path."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_also_404_with_detail(self, svc):
|
|
"""Fallback URL also returns 404, branch detail found → HTTP 400."""
|
|
call_count = [0]
|
|
async def mock_request(method, url, pat, endpoint, payload=None):
|
|
call_count[0] += 1
|
|
if method == "POST":
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
raise HTTPException(status_code=999, detail="unexpected")
|
|
with patch.object(svc, "_gitea_request", side_effect=mock_request), \
|
|
patch.object(svc, "_build_gitea_pr_404_detail", new_callable=AsyncMock, return_value="source branch 'feature' not found"):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.create_gitea_pull_request(
|
|
"https://gitea.com/owner", "pat",
|
|
"https://gitea.com/owner/repo.git",
|
|
"feature", "main", "PR title",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
assert "source" in exc_info.value.detail
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_also_404_no_detail(self, svc):
|
|
"""Fallback also 404, branch detail None → raises original."""
|
|
async def mock_request(method, url, pat, endpoint, payload=None):
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
with patch.object(svc, "_gitea_request", side_effect=mock_request), \
|
|
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/owner", "pat",
|
|
"https://gitea.com/owner/repo.git",
|
|
"feature", "main", "PR title",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_fallback_no_retry(self, svc):
|
|
"""No fallback URL → no retry on 404."""
|
|
async def mock_request(method, url, pat, endpoint, payload=None):
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
with patch.object(svc, "_gitea_request", side_effect=mock_request), \
|
|
patch.object(svc, "_derive_server_url_from_remote", return_value=None), \
|
|
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/owner", "pat",
|
|
"https://gitea.com/owner/repo.git",
|
|
"feature", "main", "PR title",
|
|
)
|
|
# #endregion Test.Git.Gitea.Coverage
|