Files
ss-tools/backend/tests/services/git/test_git_base.py
busya ce0369ae5c 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)
2026-06-15 13:55:57 +03:00

469 lines
23 KiB
Python

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