687 lines
30 KiB
Python
687 lines
30 KiB
Python
# #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
|
|
|
|
# #region test_pr_404_with_branch_detail [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pr_404_with_branch_detail(self):
|
|
"""404 with branch missing → HTTPException 400 with 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="Gitea branch not found: source branch 'feature' in owner/repo"):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.create_gitea_pull_request(
|
|
"https://gitea.com", "pat", "https://gitea.com/owner/repo.git",
|
|
"feature", "main", "PR title",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
# #endregion test_pr_404_with_branch_detail
|
|
|
|
# #region test_pr_404_retry_with_fallback [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pr_404_retry_with_fallback(self):
|
|
"""404 with different fallback URL → retries with fallback."""
|
|
svc = TestableGitGitea()
|
|
call_count = 0
|
|
async def mock_request(method, url, pat, endpoint, payload=None):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
if call_count == 2:
|
|
raise HTTPException(status_code=404, detail="not found on fallback")
|
|
return {"number": 1, "html_url": "http://fallback/pr/1", "state": "open"}
|
|
# _derive_server_url_from_remote returns different URL than primary
|
|
with patch.object(svc, "_gitea_request", side_effect=mock_request), \
|
|
patch.object(svc, "_derive_server_url_from_remote", return_value="http://fallback.example.com"), \
|
|
patch.object(svc, "_build_gitea_pr_404_detail", new_callable=AsyncMock, return_value=None):
|
|
with pytest.raises(HTTPException):
|
|
await svc.create_gitea_pull_request(
|
|
"http://primary.example.com", "pat", "http://different.example.com/owner/repo.git",
|
|
"feature", "main", "PR title",
|
|
)
|
|
assert call_count == 2
|
|
# #endregion test_pr_404_retry_with_fallback
|
|
|
|
# #region test_pr_404_retry_with_branch_detail [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_pr_404_retry_with_branch_detail(self):
|
|
"""Fallback retry 404 → builds branch detail."""
|
|
svc = TestableGitGitea()
|
|
call_count = 0
|
|
async def mock_request(method, url, pat, endpoint, payload=None):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
raise HTTPException(status_code=404, detail="not found on fallback")
|
|
with patch.object(svc, "_gitea_request", side_effect=mock_request), \
|
|
patch.object(svc, "_derive_server_url_from_remote", return_value="http://fallback.example.com"), \
|
|
patch.object(svc, "_build_gitea_pr_404_detail", new_callable=AsyncMock, return_value="Gitea branch not found: source branch 'feature' in owner/repo"):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.create_gitea_pull_request(
|
|
"http://primary.example.com", "pat", "http://different.example.com/owner/repo.git",
|
|
"feature", "main", "PR title",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
assert "branch not found" in exc_info.value.detail
|
|
# #endregion test_pr_404_retry_with_branch_detail
|
|
|
|
|
|
# ── _build_gitea_pr_404_detail ──
|
|
|
|
class TestBuildGiteaPR404Detail:
|
|
"""_build_gitea_pr_404_detail — specific branch missing message."""
|
|
|
|
# #region test_source_branch_missing [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_source_branch_missing(self):
|
|
"""Source branch not found → specific message."""
|
|
svc = TestableGitGitea()
|
|
with patch.object(svc, "_gitea_branch_exists", new_callable=AsyncMock, side_effect=[False, True]):
|
|
result = await svc._build_gitea_pr_404_detail(
|
|
"https://gitea.com", "pat", "owner", "repo", "feature", "main"
|
|
)
|
|
assert result is not None
|
|
assert "source branch" in result
|
|
assert "feature" in result
|
|
# #endregion test_source_branch_missing
|
|
|
|
# #region test_target_branch_missing [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_target_branch_missing(self):
|
|
"""Target branch not found → specific message."""
|
|
svc = TestableGitGitea()
|
|
with patch.object(svc, "_gitea_branch_exists", new_callable=AsyncMock, side_effect=[True, False]):
|
|
result = await svc._build_gitea_pr_404_detail(
|
|
"https://gitea.com", "pat", "owner", "repo", "feature", "main"
|
|
)
|
|
assert result is not None
|
|
assert "target branch" in result
|
|
assert "main" in result
|
|
# #endregion test_target_branch_missing
|
|
|
|
# #region test_both_branches_exist [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_both_branches_exist(self):
|
|
"""Both branches exist → returns None."""
|
|
svc = TestableGitGitea()
|
|
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
|
|
# #endregion test_both_branches_exist
|
|
|
|
|
|
# ── _gitea_request edge ──
|
|
|
|
class TestGiteaRequestEdge:
|
|
"""_gitea_request — error response handling edges."""
|
|
|
|
# #region test_request_error_json_parse_fallback [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_request_error_json_parse_fallback(self):
|
|
"""Error response with unparseable JSON → falls back to response.text."""
|
|
svc = TestableGitGitea()
|
|
resp = MagicMock()
|
|
resp.status_code = 500
|
|
resp.text = "Internal Server Error"
|
|
resp.json.side_effect = Exception("invalid json")
|
|
svc._http_client.request.return_value = resp
|
|
with pytest.raises(HTTPException, match="Gitea API error: Internal Server Error"):
|
|
await svc._gitea_request("GET", "https://gitea.com", "pat", "/user")
|
|
# #endregion test_request_error_json_parse_fallback
|
|
|
|
|
|
# ── create_gitea_repository edge ──
|
|
|
|
class TestCreateGiteaRepositoryEdge:
|
|
"""create_gitea_repository — optional params and raise paths."""
|
|
|
|
# #region test_create_repo_with_description [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_create_repo_with_description(self):
|
|
"""Description and default_branch passed in payload."""
|
|
svc = TestableGitGitea()
|
|
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",
|
|
private=True, description="My repo", default_branch="main"
|
|
)
|
|
assert result["name"] == "my-repo"
|
|
call_kwargs = mock_req.call_args[1]
|
|
assert call_kwargs["payload"]["description"] == "My repo"
|
|
assert call_kwargs["payload"]["default_branch"] == "main"
|
|
# #endregion test_create_repo_with_description
|
|
|
|
# #region test_create_repo_conflict_non_dict_response [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_create_repo_conflict_non_dict_response(self):
|
|
"""409 conflict but existing fetch returns non-dict → raises original."""
|
|
svc = TestableGitGitea()
|
|
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 call_count <= 2:
|
|
return {"login": "user"}
|
|
return ["not", "a", "dict"] # non-dict from GET /repos/{owner}/{name}
|
|
with patch.object(svc, "_gitea_request", side_effect=mock_request):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.create_gitea_repository("https://gitea.com", "pat", "existing-repo")
|
|
assert exc_info.value.status_code == 409
|
|
# #endregion test_create_repo_conflict_non_dict_response
|
|
|
|
# #region test_create_repo_without_description [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_create_repo_without_description(self):
|
|
"""No description/default_branch → not in payload."""
|
|
svc = TestableGitGitea()
|
|
created = {"name": "simple-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", "simple-repo"
|
|
)
|
|
assert result["name"] == "simple-repo"
|
|
call_kwargs = mock_req.call_args[1]
|
|
assert "description" not in call_kwargs.get("payload", {})
|
|
# #endregion test_create_repo_without_description
|
|
|
|
|
|
# ── _gitea_branch_exists edge ──
|
|
|
|
class TestGiteaBranchExistsEdge:
|
|
"""_gitea_branch_exists — non-404 error re-raises."""
|
|
|
|
# #region test_branch_exists_raises_non_404 [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_branch_exists_raises_non_404(self):
|
|
"""Non-404 HTTPException → re-raised."""
|
|
svc = TestableGitGitea()
|
|
with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=500, detail="server error")):
|
|
with pytest.raises(HTTPException, match="server error"):
|
|
await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "dev")
|
|
# #endregion test_branch_exists_raises_non_404
|
|
|
|
# #endregion Test.Git.Gitea
|