# #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() # Directly patch the imported name in the module dict to avoid # AsyncMock interaction issues when running in full suite. import src.services.git._base as gb_mod orig_blocking = gb_mod.run_blocking try: gb_mod.run_blocking = AsyncMock(return_value=mock_repo) result = await svc._clone_with_auth("https://gitea.com/org/repo.git", "/tmp/repo", "my-pat") assert result == mock_repo finally: gb_mod.run_blocking = orig_blocking # #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() import src.services.git._base as gb_mod orig_blocking = gb_mod.run_blocking try: gb_mod.run_blocking = AsyncMock(return_value=mock_repo) result = await svc._clone_with_auth("https://gitea.com/org/repo.git", "/tmp/repo", "") assert result == mock_repo finally: gb_mod.run_blocking = orig_blocking # #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"): svc = GitServiceBase(base_path="/tmp/test") svc.base_path = "/tmp/test" with patch.object(Path, "exists", return_value=False), \ patch.object(Path, "mkdir") as mock_mkdir: 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(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") svc.base_path = "/tmp/test" with patch.object(Path, "exists", return_value=True), \ patch.object(Path, "is_dir", return_value=False): 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(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"): svc = GitServiceBase(base_path="/tmp/test") svc.base_path = "/tmp/test" with patch.object(Path, "exists", return_value=False), \ patch.object(Path, "mkdir", side_effect=PermissionError("denied")): 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") mock_session = MagicMock() mock_session.query.return_value.filter.return_value.first.return_value = MagicMock(local_path="") # Patch SessionLocal at the module where it's imported (_base.py), # using the actual import reference to ensure it works even in full suite import src.services.git._base as git_base_mod original_session_local = git_base_mod.SessionLocal try: git_base_mod.SessionLocal = MagicMock(return_value=mock_session) svc._update_repo_local_path(1, "/tmp/repo") assert mock_session.commit.called finally: git_base_mod.SessionLocal = original_session_local # #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", create=True) 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") mock_session = MagicMock() mock_db_repo = MagicMock() mock_session.query.return_value.filter.return_value.first.return_value = mock_db_repo import src.services.git._base as gb_mod orig_sl = gb_mod.SessionLocal orig_blocking = gb_mod.run_blocking try: gb_mod.SessionLocal = MagicMock(return_value=mock_session) gb_mod.run_blocking = AsyncMock() 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): await svc.delete_repo(1) mock_session.delete.assert_called_with(mock_db_repo) mock_session.commit.assert_called_once() finally: gb_mod.SessionLocal = orig_sl gb_mod.run_blocking = orig_blocking # #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") mock_session = MagicMock() 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") import src.services.git._base as git_base_mod original_sl = git_base_mod.SessionLocal try: git_base_mod.SessionLocal = MagicMock(return_value=mock_session) with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \ patch("os.path.exists", return_value=False): 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() finally: git_base_mod.SessionLocal = original_sl # #endregion test_delete_repo_db_error