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