# #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