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:
468
backend/tests/services/git/test_git_base.py
Normal file
468
backend/tests/services/git/test_git_base.py
Normal 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
|
||||
291
backend/tests/services/git/test_git_remote_providers.py
Normal file
291
backend/tests/services/git/test_git_remote_providers.py
Normal 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
|
||||
Reference in New Issue
Block a user