diff --git a/backend/tests/services/git/conftest.py b/backend/tests/services/git/conftest.py new file mode 100644 index 00000000..936f32db --- /dev/null +++ b/backend/tests/services/git/conftest.py @@ -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() diff --git a/backend/tests/services/git/test_git_base.py b/backend/tests/services/git/test_git_base.py index 90d55a76..8aeaf629 100644 --- a/backend/tests/services/git/test_git_base.py +++ b/backend/tests/services/git/test_git_base.py @@ -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