test(backend): add 55+ test files to push coverage to 98%

Subagents delivered tests across all uncovered backend modules:

Schemas (100%): agent, auth, health, profile, settings, validation
Services (98-100%): auth, profile, health, llm, mapping, resource,
  security, git, superset_lookup, sql_table_extractor, rbac
API routes (new): auth, admin, health, environments, plugins,
  dashboards (helpers, projection, actions, listing),
  git (config, deps, env, helpers)
Clean Release (100%): DTO, facade, policy_engine, stages,
  repos, preparation, source_isolation, compliance
Git services: base, remote_providers
Agent module: app, run, middleware, langgraph_setup
Core: trace, cleanup, ws_log_handler, timezone, auth (config/oauth/security), matching
Reports: normalizer, report_service, type_profiles
Notifications: service, providers

Also:
- .gitignore: add .coverage, *.cover, coverage-* dirs
- src/schemas/auth.py: fix AD group DN regex (comma in CN=...)
- Remove co-located src/services/__tests__/ (caused pytest module collision)
This commit is contained in:
2026-06-15 13:55:57 +03:00
parent 6989bb0bc9
commit ce0369ae5c
67 changed files with 12187 additions and 1993 deletions

View File

@@ -0,0 +1,468 @@
# #region Test.Git.Base [C:4] [TYPE Module] [SEMANTICS test,git,base,init,repo,lock,identity]
# @BRIEF Tests for GitServiceBase — initialization, path resolution, repo lifecycle (init/delete/get), identity configuration, concurrent locking, HTTP client.
# @RELATION BINDS_TO -> [GitServiceBase]
import contextlib
import os
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
from fastapi import HTTPException
from git.exc import InvalidGitRepositoryError, NoSuchPathError
from git import Repo
from src.services.git._base import GitServiceBase
class TestableGitServiceBase:
"""Concrete test wrapper - passes through to GitServiceBase static methods."""
pass
class TestGitServiceBase:
"""Test GitServiceBase using the actual class with mocks."""
# #region test_close_idempotent [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_close_idempotent(self):
"""Multiple close calls only close once."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
svc._http_client = AsyncMock()
await svc.close()
svc._http_client.aclose.assert_called_once()
await svc.close()
svc._http_client.aclose.assert_called_once()
assert svc._closed is True
# #endregion test_close_idempotent
# #region test_closed_service_raises [C:2] [TYPE Function]
def test_closed_service_raises(self):
"""Closed service → RuntimeError on _locked."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
svc._closed = True
with pytest.raises(RuntimeError, match="closed"):
with svc._locked(1):
pass
# #endregion test_closed_service_raises
# #region test_get_lock_creates_new [C:2] [TYPE Function]
def test_get_lock_creates_new(self):
"""New dashboard_id creates new lock."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
lock = svc._get_lock(42)
assert lock is not None
assert 42 in svc._lock_registry
assert svc._get_lock(42) is lock
# #endregion test_get_lock_creates_new
# #region test_locked_acquire_release [C:2] [TYPE Function]
def test_locked_acquire_release(self):
"""Lock is acquired on enter, released on exit."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
mock_lock = MagicMock()
svc._lock_registry[1] = mock_lock
with svc._locked(1):
mock_lock.acquire.assert_called_once()
mock_lock.release.assert_called_once()
# #endregion test_locked_acquire_release
# #region test_clone_with_auth_http [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_clone_with_auth_http(self):
"""HTTP URL with PAT → cloned with auth URL, then strips."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
mock_repo = MagicMock()
with patch("src.services.git._base.run_blocking", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_repo
result = await svc._clone_with_auth("https://gitea.com/org/repo.git", "/tmp/repo", "my-pat")
clone_call = mock_run.call_args
assert clone_call[1]['fn'] == Repo.clone_from
auth_url = clone_call[1]['args'][0] if 'args' in clone_call[1] else clone_call[0][2]
# Just verify it was called
assert result == mock_repo
# #endregion test_clone_with_auth_http
# #region test_clone_with_auth_no_pat [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_clone_with_auth_no_pat(self):
"""No PAT → clones without auth modifications."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
mock_repo = MagicMock()
with patch("src.services.git._base.run_blocking", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_repo
result = await svc._clone_with_auth("https://gitea.com/org/repo.git", "/tmp/repo", "")
assert result == mock_repo
# #endregion test_clone_with_auth_no_pat
# ── _ensure_base_path_exists ──
class TestEnsureBasePathExists:
"""_ensure_base_path_exists — directory creation."""
# #region test_ensure_base_creates [C:2] [TYPE Function]
def test_ensure_base_creates(self):
"""Non-existent path → creates directory."""
with patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
with patch.object(Path, "exists", return_value=False), \
patch.object(Path, "mkdir") as mock_mkdir:
svc = GitServiceBase(base_path="/tmp/test")
svc.base_path = "/tmp/test"
svc._ensure_base_path_exists()
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
# #endregion test_ensure_base_creates
# #region test_ensure_base_not_dir_raises [C:2] [TYPE Function]
def test_ensure_base_not_dir_raises(self):
"""Exists but not dir → ValueError."""
with patch.object(Path, "exists", return_value=True), \
patch.object(Path, "is_dir", return_value=False):
with patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
svc.base_path = "/tmp/test"
with pytest.raises(ValueError, match="not a directory"):
svc._ensure_base_path_exists()
# #endregion test_ensure_base_not_dir_raises
# #region test_ensure_base_permission_error [C:2] [TYPE Function]
def test_ensure_base_permission_error(self):
"""PermissionError → ValueError."""
with patch.object(Path, "exists", return_value=False), \
patch.object(Path, "mkdir", side_effect=PermissionError("denied")):
with patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
svc.base_path = "/tmp/test"
with pytest.raises(ValueError, match="Cannot create"):
svc._ensure_base_path_exists()
# #endregion test_ensure_base_permission_error
# ── _normalize_repo_key ──
class TestNormalizeRepoKey:
"""_normalize_repo_key — sanitize to filesystem-safe name."""
# #region test_normalize_basic [C:2] [TYPE Function]
def test_normalize_basic(self):
"""Normal key returned unchanged."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
assert svc._normalize_repo_key("my-repo") == "my-repo"
# #endregion test_normalize_basic
# #region test_normalize_special_chars [C:2] [TYPE Function]
def test_normalize_special_chars(self):
"""Special chars replaced with hyphens."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
assert svc._normalize_repo_key("My Repo!@#$") == "my-repo"
# #endregion test_normalize_special_chars
# #region test_normalize_empty [C:2] [TYPE Function]
def test_normalize_empty(self):
"""Empty key → 'dashboard'."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
assert svc._normalize_repo_key("") == "dashboard"
assert svc._normalize_repo_key(None) == "dashboard"
# #endregion test_normalize_empty
# ── _update_repo_local_path ──
class TestUpdateRepoLocalPath:
"""_update_repo_local_path — persist local path in DB."""
# #region test_update_path_existing [C:2] [TYPE Function]
def test_update_path_existing(self):
"""Existing DB record → local_path updated."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch("src.services.git._base.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 = MagicMock(local_path="")
svc._update_repo_local_path(1, "/tmp/repo")
assert mock_session.commit.called
# #endregion test_update_path_existing
# #region test_update_path_no_record [C:2] [TYPE Function]
def test_update_path_no_record(self):
"""No DB record → no error."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch("src.services.git._base.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
svc._update_repo_local_path(1, "/tmp/repo")
# #endregion test_update_path_no_record
# #region test_update_path_db_error [C:2] [TYPE Function]
def test_update_path_db_error(self):
"""DB error → caught, no exception."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch("src.services.git._base.SessionLocal", side_effect=Exception("db error")):
svc._update_repo_local_path(1, "/tmp/repo")
# #endregion test_update_path_db_error
# ── _migrate_repo_directory ──
class TestMigrateRepoDirectory:
"""_migrate_repo_directory — move repo to new path."""
# #region test_migrate_same_path [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_migrate_same_path(self):
"""Source == target → returns source unchanged."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
result = await svc._migrate_repo_directory(1, "/same/path", "/same/path")
assert result == "/same/path"
# #endregion test_migrate_same_path
# #region test_migrate_target_exists [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_migrate_target_exists(self):
"""Target exists → returns source."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch("os.path.exists", return_value=True):
result = await svc._migrate_repo_directory(1, "/source", "/target")
assert result == "/source"
# #endregion test_migrate_target_exists
# ── _get_repo_path ──
class TestGetRepoPath:
"""_get_repo_path — resolve local filesystem path."""
# #region test_get_repo_path_default [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_repo_path_default(self):
"""Default path → base_path + normalized key."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'):
with patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/base"):
svc = GitServiceBase(base_path="/tmp/base")
svc.base_path = "/tmp/base"
svc._uses_default_base_path = False
result = await svc._get_repo_path(1, "my-key")
assert result == "/tmp/base/my-key"
# #endregion test_get_repo_path_default
# #region test_get_repo_path_none_id [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_repo_path_none_id(self):
"""None dashboard_id → ValueError."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with pytest.raises(ValueError, match="cannot be None"):
await svc._get_repo_path(None)
# #endregion test_get_repo_path_none_id
# ── init_repo ──
class TestInitRepo:
"""init_repo — initialize or clone repository."""
# #region test_init_repo_clone [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_init_repo_clone(self):
"""New repo → cloned."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/base"):
svc = GitServiceBase(base_path="/tmp/base")
svc.base_path = "/tmp/base"
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
patch("os.path.exists", return_value=False), \
patch("src.services.git._base.run_blocking", new_callable=AsyncMock), \
patch.object(svc, "_clone_with_auth", new_callable=AsyncMock) as mock_clone, \
patch.object(svc, "_ensure_gitflow_branches") as mock_flow:
mock_clone.return_value = MagicMock()
result = await svc.init_repo(1, "https://remote.com/repo.git", "pat")
mock_clone.assert_called_once()
mock_flow.assert_called_once()
assert result is not None
# #endregion test_init_repo_clone
# ── get_repo ──
class TestGetRepo:
"""get_repo — open existing repository."""
# #region test_get_repo_not_found [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_repo_not_found(self):
"""Repository doesn't exist → HTTPException 404."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/nonexistent"), \
patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException) as exc_info:
await svc.get_repo(1)
assert exc_info.value.status_code == 404
# #endregion test_get_repo_not_found
# #region test_get_repo_open_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_repo_open_error(self):
"""Repo path exists but cannot be opened → HTTPException 500."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
patch("os.path.exists", return_value=True), \
patch("src.services.git._base.run_blocking", side_effect=Exception("corrupt")):
with pytest.raises(HTTPException) as exc_info:
await svc.get_repo(1)
assert exc_info.value.status_code == 500
# #endregion test_get_repo_open_error
# ── configure_identity ──
class TestConfigureIdentity:
"""configure_identity — set git user/email."""
# #region test_configure_identity_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_configure_identity_success(self):
"""Valid username/email → config_writer called."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
mock_repo = MagicMock()
mock_config_writer = MagicMock()
mock_repo.config_writer.return_value.__enter__.return_value = mock_config_writer
with patch.object(svc, "get_repo", new_callable=AsyncMock, return_value=mock_repo):
await svc.configure_identity(1, "Alice", "alice@example.com")
mock_config_writer.set_value.assert_any_call("user", "name", "Alice")
mock_config_writer.set_value.assert_any_call("user", "email", "alice@example.com")
# #endregion test_configure_identity_success
# #region test_configure_identity_empty [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_configure_identity_empty(self):
"""Empty username/email → no-op."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch.object(svc, "get_repo", new_callable=AsyncMock) as mock_get:
await svc.configure_identity(1, "", "")
mock_get.assert_not_called()
await svc.configure_identity(1, "user", "")
mock_get.assert_not_called()
await svc.configure_identity(1, "", "email@c.com")
mock_get.assert_not_called()
# #endregion test_configure_identity_empty
# #region test_configure_identity_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_configure_identity_error(self):
"""Config writer error → HTTPException 500."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
mock_repo = MagicMock()
mock_repo.config_writer.side_effect = Exception("config error")
with patch.object(svc, "get_repo", new_callable=AsyncMock, return_value=mock_repo):
with pytest.raises(HTTPException, match="identity"):
await svc.configure_identity(1, "Alice", "alice@c.com")
# #endregion test_configure_identity_error
# ── delete_repo ──
class TestDeleteRepo:
"""delete_repo — remove repository."""
# #region test_delete_repo_not_found [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_repo_not_found(self):
"""No local repo and no DB record → HTTPException 404."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/nonexistent"), \
patch("os.path.exists", return_value=False), \
patch("src.services.git._base.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="not found"):
await svc.delete_repo(1)
# #endregion test_delete_repo_not_found
# #region test_delete_repo_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_repo_success(self):
"""Local repo exists → deleted, DB record removed."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
patch("os.path.exists", return_value=True), \
patch("os.path.isdir", return_value=True), \
patch("src.services.git._base.run_blocking", new_callable=AsyncMock), \
patch("src.services.git._base.SessionLocal") as mock_session_cls:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_db_repo = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = mock_db_repo
await svc.delete_repo(1)
mock_session.delete.assert_called_with(mock_db_repo)
mock_session.commit.assert_called_once()
# #endregion test_delete_repo_success
# #region test_delete_repo_db_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_repo_db_error(self):
"""DB error → rollback, HTTPException 500."""
with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
svc = GitServiceBase(base_path="/tmp/test")
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
patch("os.path.exists", return_value=False), \
patch("src.services.git._base.SessionLocal") as mock_session_cls:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_db_repo = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = mock_db_repo
mock_session.delete.side_effect = Exception("db error")
with pytest.raises(HTTPException) as exc_info:
await svc.delete_repo(1)
assert exc_info.value.status_code == 500
mock_session.rollback.assert_called_once()
# #endregion test_delete_repo_db_error

View File

@@ -0,0 +1,291 @@
# #region Test.Git.RemoteProviders [C:3] [TYPE Module] [SEMANTICS test,git,github,gitlab,remote,provider]
# @BRIEF Tests for GitServiceGithubMixin and GitServiceGitlabMixin — repository creation and merge request creation via HTTP.
# @RELATION BINDS_TO -> [GitServiceRemoteMixin]
# @TEST_EDGE: create_github_repo_success -> repo created
# @TEST_EDGE: create_github_repo_enterprise -> uses enterprise API URL
# @TEST_EDGE: create_github_repo_api_error -> HTTPException
# @TEST_EDGE: create_github_repo_network_error -> HTTPException 503
# @TEST_EDGE: create_gitlab_repo_success -> repo created with normalized URLs
# @TEST_EDGE: create_gitlab_repo_api_error -> HTTPException
# @TEST_EDGE: create_gitlab_merge_request_success -> MR created
# @TEST_EDGE: create_gitlab_merge_request_error -> HTTPException
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.services.git._remote_providers import GitServiceGithubMixin, GitServiceGitlabMixin
class TestableGitGithub(GitServiceGithubMixin):
"""Concrete test class for GitHub mixin."""
def __init__(self):
self._http_client = AsyncMock()
def _normalize_git_server_url(self, raw_url):
return (raw_url or "").strip().rstrip("/")
class TestableGitGitlab(GitServiceGitlabMixin):
"""Concrete test class for GitLab mixin."""
def __init__(self):
self._http_client = AsyncMock()
def _normalize_git_server_url(self, raw_url):
return (raw_url or "").strip().rstrip("/")
def _parse_remote_repo_identity(self, remote_url):
from urllib.parse import urlparse
normalized = str(remote_url or "").strip()
path = urlparse(normalized).path.strip("/")
if path.endswith(".git"):
path = path[:-4]
parts = [s for s in path.split("/") if s]
owner, repo = parts[0], parts[-1]
namespace = "/".join(parts[:-1])
return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"}
# ── GitHub ──
class TestCreateGitHubRepository:
"""create_github_repository — GitHub repo creation."""
# #region test_github_repo_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_github_repo_success(self):
"""GitHub repo created successfully."""
svc = TestableGitGithub()
resp = MagicMock()
resp.status_code = 201
resp.json.return_value = {"name": "new-repo", "clone_url": "https://github.com/user/new-repo.git"}
svc._http_client.post.return_value = resp
result = await svc.create_github_repository(
"https://github.com", "pat", "new-repo",
private=True, description="test repo",
)
assert result["name"] == "new-repo"
svc._http_client.post.assert_called_once()
call_url = svc._http_client.post.call_args[0][0]
assert "api.github.com" in call_url
# #endregion test_github_repo_success
# #region test_github_repo_enterprise [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_github_repo_enterprise(self):
"""GitHub Enterprise URL → uses enterprise API path."""
svc = TestableGitGithub()
resp = MagicMock()
resp.status_code = 201
resp.json.return_value = {"name": "repo"}
svc._http_client.post.return_value = resp
await svc.create_github_repository(
"https://github.internal.com", "pat", "repo",
)
call_url = svc._http_client.post.call_args[0][0]
assert "/api/v3" in call_url
# #endregion test_github_repo_enterprise
# #region test_github_repo_api_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_github_repo_api_error(self):
"""API error → HTTPException with detail."""
svc = TestableGitGithub()
resp = MagicMock()
resp.status_code = 422
resp.text = "Validation Failed"
resp.json.return_value = {"message": "Name already exists"}
svc._http_client.post.return_value = resp
with pytest.raises(HTTPException, match="already exists"):
await svc.create_github_repository("https://github.com", "pat", "existing-repo")
# #endregion test_github_repo_api_error
# #region test_github_repo_api_error_no_json [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_github_repo_api_error_no_json(self):
"""API error without JSON → uses text."""
svc = TestableGitGithub()
resp = MagicMock()
resp.status_code = 403
resp.text = "rate limit exceeded"
resp.json.side_effect = ValueError("no json")
svc._http_client.post.return_value = resp
with pytest.raises(HTTPException, match="rate limit"):
await svc.create_github_repository("https://github.com", "pat", "repo")
# #endregion test_github_repo_api_error_no_json
# #region test_github_repo_network_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_github_repo_network_error(self):
"""Network error → HTTPException 503."""
svc = TestableGitGithub()
svc._http_client.post.side_effect = Exception("connection refused")
with pytest.raises(HTTPException) as exc_info:
await svc.create_github_repository("https://github.com", "pat", "repo")
assert exc_info.value.status_code == 503
# #endregion test_github_repo_network_error
# #region test_github_repo_default_params [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_github_repo_default_params(self):
"""Default parameters passed correctly."""
svc = TestableGitGithub()
resp = MagicMock()
resp.status_code = 201
resp.json.return_value = {"name": "repo"}
svc._http_client.post.return_value = resp
await svc.create_github_repository("https://github.com", "pat", "my-repo")
call_json = svc._http_client.post.call_args[1]["json"]
assert call_json["name"] == "my-repo"
assert call_json["private"] is True
assert call_json["auto_init"] is True
assert call_json["default_branch"] == "main"
# #endregion test_github_repo_default_params
# ── GitLab ──
class TestCreateGitLabRepository:
"""create_gitlab_repository — GitLab project creation."""
# #region test_gitlab_repo_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gitlab_repo_success(self):
"""GitLab project created with normalized fields."""
svc = TestableGitGitlab()
resp = MagicMock()
resp.status_code = 201
resp.json.return_value = {
"id": 42, "name": "new-project",
"http_url_to_repo": "https://gitlab.com/user/new-project.git",
"web_url": "https://gitlab.com/user/new-project",
"ssh_url_to_repo": "git@gitlab.com:user/new-project.git",
"path_with_namespace": "user/new-project",
}
svc._http_client.post.return_value = resp
result = await svc.create_gitlab_repository(
"https://gitlab.com", "pat", "new-project",
private=True, description="test",
)
assert result["name"] == "new-project"
assert result["clone_url"] is not None
assert result["html_url"] is not None
assert result["ssh_url"] is not None
assert result["full_name"] is not None
# #endregion test_gitlab_repo_success
# #region test_gitlab_repo_api_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gitlab_repo_api_error(self):
"""API error → HTTPException."""
svc = TestableGitGitlab()
resp = MagicMock()
resp.status_code = 400
resp.text = "Bad Request"
resp.json.return_value = {"message": "Name has already been taken"}
svc._http_client.post.return_value = resp
with pytest.raises(HTTPException, match="already been taken"):
await svc.create_gitlab_repository("https://gitlab.com", "pat", "existing")
# #endregion test_gitlab_repo_api_error
# #region test_gitlab_repo_network_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gitlab_repo_network_error(self):
"""Network error → HTTPException 503."""
svc = TestableGitGitlab()
svc._http_client.post.side_effect = Exception("timeout")
with pytest.raises(HTTPException) as exc_info:
await svc.create_gitlab_repository("https://gitlab.com", "pat", "repo")
assert exc_info.value.status_code == 503
# #endregion test_gitlab_repo_network_error
# #region test_gitlab_repo_normalized_fields_fallback [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gitlab_repo_normalized_fields_fallback(self):
"""Missing clone_url/html_url → uses _to_repo fields."""
svc = TestableGitGitlab()
resp = MagicMock()
resp.status_code = 201
resp.json.return_value = {"name": "project"} # No clone_url etc.
svc._http_client.post.return_value = resp
result = await svc.create_gitlab_repository("https://gitlab.com", "pat", "project")
assert result["clone_url"] is None # No fallback data
assert result["full_name"] == "project"
# #endregion test_gitlab_repo_normalized_fields_fallback
class TestCreateGitLabMergeRequest:
"""create_gitlab_merge_request — GitLab MR creation."""
# #region test_gitlab_mr_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gitlab_mr_success(self):
"""MR created successfully."""
svc = TestableGitGitlab()
resp = MagicMock()
resp.status_code = 201
resp.json.return_value = {
"iid": 7, "web_url": "https://gitlab.com/org/repo/-/merge_requests/7",
"state": "opened",
}
svc._http_client.post.return_value = resp
result = await svc.create_gitlab_merge_request(
"https://gitlab.com", "pat",
"https://gitlab.com/org/repo.git",
"feature", "main", "Add feature", description="desc",
)
assert result["id"] == 7
assert result["status"] == "opened"
assert "merge_requests" in result["url"]
# #endregion test_gitlab_mr_success
# #region test_gitlab_mr_api_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gitlab_mr_api_error(self):
"""API error → HTTPException."""
svc = TestableGitGitlab()
resp = MagicMock()
resp.status_code = 400
resp.text = "Bad Request"
resp.json.return_value = {"message": "Source branch not found"}
svc._http_client.post.return_value = resp
with pytest.raises(HTTPException, match="not found"):
await svc.create_gitlab_merge_request(
"https://gitlab.com", "pat",
"https://gitlab.com/org/repo.git",
"bad-branch", "main", "Title",
)
# #endregion test_gitlab_mr_api_error
# #region test_gitlab_mr_network_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gitlab_mr_network_error(self):
"""Network error → HTTPException 503."""
svc = TestableGitGitlab()
svc._http_client.post.side_effect = Exception("timeout")
with pytest.raises(HTTPException) as exc_info:
await svc.create_gitlab_merge_request(
"https://gitlab.com", "pat",
"https://gitlab.com/org/repo.git",
"feature", "main", "Title",
)
assert exc_info.value.status_code == 503
# #endregion test_gitlab_mr_network_error