fix(test): resolve 3 remaining git_base test interaction failures

Root cause: unittest.mock.patch with new_callable=AsyncMock fails in
full-suite context due to asyncio event loop state from earlier tests.
Fix: use direct module dict patching (gb_mod.run_blocking = AsyncMock())
instead of patch() context manager. This bypasses the mock machinery
interaction with inherited event loop state.

Also add conftest.py in tests/services/git/ with event_loop fixture
for per-function isolation.

Result: 2602 passed, 0 failed, 3 skipped, 1 xpassed
This commit is contained in:
2026-06-15 15:05:26 +03:00
parent ce0369ae5c
commit 654f92e7ad
2 changed files with 100 additions and 51 deletions

View File

@@ -0,0 +1,27 @@
"""conftest.py for tests/services/git/
When `test_git_base.py` async tests run in the full suite after other
modules that modify the asyncio event loop, AsyncMock and patch-based
mocks for async functions may not work correctly.
This conftest provides a fixture that forces a fresh asyncio event loop
per test function, avoiding cross-test contamination.
"""
import asyncio
import pytest
@pytest.fixture(scope="function")
def event_loop():
"""Create a fresh event loop for each test function.
Overrides the default pytest-asyncio event_loop fixture to ensure
that async git_base tests are isolated from event loop state left
by other test modules in the full suite.
"""
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()

View File

@@ -87,14 +87,16 @@ class TestGitServiceBase:
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
# 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")
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
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]
@@ -105,10 +107,14 @@ class TestGitServiceBase:
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
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
assert result == mock_repo
finally:
gb_mod.run_blocking = orig_blocking
# #endregion test_clone_with_auth_no_pat
@@ -121,10 +127,10 @@ class TestEnsureBasePathExists:
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 = 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
@@ -132,11 +138,11 @@ class TestEnsureBasePathExists:
# #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 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
@@ -144,11 +150,11 @@ class TestEnsureBasePathExists:
# #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 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
@@ -199,12 +205,18 @@ class TestUpdateRepoLocalPath:
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="")
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]
@@ -307,7 +319,7 @@ class TestInitRepo:
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:
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()
@@ -432,18 +444,24 @@ class TestDeleteRepo:
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()
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]
@@ -453,16 +471,20 @@ class TestDeleteRepo:
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()
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