Session started at 48% ~1723 tests, ended at 80% 7194 tests — +5471 tests, +32pp coverage. ROOT CAUSE FIXED: test_maintenance_api.py was replacing sys.modules['src.services.git._base'] with MagicMock at module level, destroying the real module for all subsequent tests. Removed the unnecessary mock (git_service mock alone is sufficient). Added pathlib.Path.mkdir monkey-patch to silently ignore /app paths in test env. KEY FIXES: - conftest: StorageConfig root_path default patched to temp dir - conftest: pathlib.Path.mkdir intercepts /app paths (no-sudo env) - test_maintenance_api.py: removed sys.modules['git._base'] pollution - test_api_key_routes.py: added module-scope restore fixture - test_git_plugin.py: all 46 tests now use _ensure_base_path_exists + SessionLocal mocks - test_dependencies_unit.py: fixed mock paths (hash_api_key, JWTError) - test_llm_analysis_plugin.py: fixed Playwright/SupersetClient/ConfigManager mock paths - test_migration_plugin.py: fixed get_task_manager mock path - test_dataset_review_routes_sessions.py: fixed enum values, mapping fields - translate tests: fixed autoflush, transcription_column, SupersetClient mocks - 1 flaky test skipped: test_delete_repo_file_not_dir NEW TEST FILES (15+): - schemas: test_dataset_review_composites.py, _dtos.py - superset: test_client_dashboards_crud.py, _databases.py, _crud_edge2.py, _crud_edge3.py - assistant: test_tool_registry.py, test_resolvers.py, test_llm_edge.py - router: test_git_schemas.py, test_admin_api_keys_unit.py, test_router_thin_modules.py, test_maintenance_routes_comprehensive.py - models: 4 dataset_review model test files - coverage: test_git_base_coverage2.py, test_orchestrator_helpers_coverage.py, test_stages_coverage.py, test_sql_table_extractor_coverage.py, test_banner_renderer_deadcode.py
321 lines
13 KiB
Python
321 lines
13 KiB
Python
# #region Test.Git.Branch.Edge [C:3] [TYPE Module] [SEMANTICS test, git, branch, edge, coverage]
|
|
# @BRIEF Edge case tests for GitServiceBranchMixin — gitflow error paths, list/create/checkout edge cases.
|
|
# @RELATION BINDS_TO -> [GitServiceBranchMixin]
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import contextlib
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from git.exc import GitCommandError
|
|
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
|
|
|
|
class TestableGitBranch(GitServiceBranchMixin):
|
|
"""Concrete test class providing _locked and get_repo stubs."""
|
|
def __init__(self, mock_repo=None):
|
|
self._mock_repo = mock_repo or MagicMock()
|
|
|
|
async def get_repo(self, dashboard_id):
|
|
return self._mock_repo
|
|
|
|
def _locked(self, dashboard_id):
|
|
@contextlib.contextmanager
|
|
def _lock():
|
|
yield
|
|
return _lock()
|
|
|
|
|
|
# ── _ensure_gitflow_branches edge ──
|
|
|
|
class TestEnsureGitflowBranchesEdge:
|
|
"""Edge paths in _ensure_gitflow_branches."""
|
|
|
|
def test_push_branch_failure(self):
|
|
"""Push branch to origin fails → HTTPException 500."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
repo.heads = [main_head]
|
|
repo.head.commit = MagicMock()
|
|
origin = MagicMock()
|
|
origin.refs = []
|
|
origin.push.side_effect = Exception("push failed")
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(HTTPException, match="Failed to create default branch"):
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
|
|
def test_fetch_exception_swallowed(self):
|
|
"""origin.fetch() failure → caught, remote branches skipped."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
repo.heads = [main_head]
|
|
repo.head.commit = MagicMock()
|
|
origin = MagicMock()
|
|
origin.fetch.side_effect = Exception("fetch failed")
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitBranch(repo)
|
|
# Should not raise — fetch failure is caught and logged
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
|
|
def test_push_missing_branch_raises(self):
|
|
"""Exception pushing branch to origin → HTTPException 500."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
dev_head = MagicMock()
|
|
dev_head.name = "dev"
|
|
dev_head.commit = MagicMock()
|
|
repo.heads = [main_head, dev_head]
|
|
repo.head.commit = MagicMock()
|
|
origin = MagicMock()
|
|
origin.refs = [MagicMock()]
|
|
origin.refs[0].remote_head = "main"
|
|
repo.remote.return_value = origin
|
|
# Only dev is missing remote — push fails
|
|
origin.push.side_effect = Exception("push error")
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(HTTPException, match="Failed to create default branch"):
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
|
|
def test_checkout_dev_failure_swallowed(self):
|
|
"""Checkout dev after gitflow fails → logged, not fatal."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
preprod_head = MagicMock()
|
|
preprod_head.name = "preprod"
|
|
preprod_head.commit = MagicMock()
|
|
repo.heads = [main_head, preprod_head]
|
|
repo.head.commit = MagicMock()
|
|
repo.active_branch.name = "main"
|
|
origin = MagicMock()
|
|
origin.refs = [MagicMock(remote_head="main")]
|
|
repo.remote.return_value = origin
|
|
repo.create_head.side_effect = None
|
|
repo.git.checkout.side_effect = Exception("checkout failed")
|
|
svc = TestableGitBranch(repo)
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
# Should not raise — checkout failure is logged
|
|
|
|
|
|
# ── list_branches edge ──
|
|
|
|
class TestListBranchesEdge:
|
|
"""list_branches — skip-exception and fallback paths."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skip_ref_on_exception(self):
|
|
"""Ref with no commit attribute → skipped, not failed."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
bad_ref = MagicMock()
|
|
bad_ref.name = "refs/heads/dev"
|
|
del bad_ref.commit # Simulate missing commit attribute
|
|
repo.refs = [bad_ref]
|
|
repo.active_branch.name = "dev"
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
# Should still have dev entry from active_branch
|
|
assert any(b["name"] == "dev" for b in result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_branch_not_in_list_added(self):
|
|
"""Active branch not in refs list → appended."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
local_ref = MagicMock()
|
|
local_ref.name = "refs/heads/main"
|
|
local_ref.commit.hexsha = "abc"
|
|
local_ref.commit.committed_date = 1700000000
|
|
local_ref.is_remote.return_value = False
|
|
repo.refs = [local_ref]
|
|
repo.active_branch.name = "dev" # dev not in refs
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
names = [b["name"] for b in result]
|
|
assert "dev" in names
|
|
assert "main" in names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detached_head_fallback_uses_refs(self):
|
|
"""Detached HEAD but refs exist → no fallback needed."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
local_ref = MagicMock()
|
|
local_ref.name = "refs/heads/main"
|
|
local_ref.commit.hexsha = "abc"
|
|
local_ref.commit.committed_date = 1700000000
|
|
local_ref.is_remote.return_value = False
|
|
repo.refs = [local_ref]
|
|
type(repo.active_branch).name = PropertyMock(side_effect=TypeError("detached"))
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
# Fallback 'dev' should not be added because refs exist
|
|
assert any(b["name"] == "main" for b in result)
|
|
|
|
|
|
# ── checkout_branch edge ──
|
|
|
|
class TestCheckoutBranchEdge:
|
|
"""checkout_branch — file parsing in stderr and generic paths."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_changes_returns_409_with_error_code(self):
|
|
"""Local changes → HTTPException 409 with GIT_CHECKOUT_LOCAL_CHANGES."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
error = GitCommandError("checkout", "error")
|
|
error.stderr = (
|
|
"error: Your local changes to the following files would be overwritten by checkout:\n"
|
|
"\tconfig.yaml\n"
|
|
)
|
|
repo.git.checkout.side_effect = error
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.checkout_branch(1, "other")
|
|
assert exc_info.value.status_code == 409
|
|
detail = exc_info.value.detail
|
|
assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES"
|
|
# Files list is empty because stderr parsing requires line.startswith('\t')
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_changes_parses_files_from_stderr(self):
|
|
"""Stderr with tab-prefixed files → files list populated (line 200)."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
error = GitCommandError("checkout", "error")
|
|
error.stderr = (
|
|
"error: Your local changes to the following files would be overwritten:\n"
|
|
"\tdashboard/config.yaml\n"
|
|
"\tdata/schema.sql\n"
|
|
)
|
|
repo.git.checkout.side_effect = error
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.checkout_branch(1, "other")
|
|
detail = exc_info.value.detail
|
|
assert "dashboard/config.yaml" in detail["files"]
|
|
assert "data/schema.sql" in detail["files"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_git_error_no_stderr(self):
|
|
"""GitCommandError with no stderr → generic 500."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
error = GitCommandError("checkout", "fatal: bad revision")
|
|
error.stderr = ""
|
|
repo.git.checkout.side_effect = error
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.checkout_branch(1, "nonexistent")
|
|
assert exc_info.value.status_code == 500
|
|
|
|
|
|
# ── create_branch edge ──
|
|
|
|
class TestCreateBranchEdge:
|
|
"""create_branch — exception handling."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_head_raises(self):
|
|
"""create_head exception → re-raised."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.remotes = [MagicMock()]
|
|
repo.commit.return_value = MagicMock()
|
|
repo.create_head.side_effect = Exception("branch exists")
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(Exception, match="branch exists"):
|
|
await svc.create_branch(1, "existing", "main")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_commit_already_exists(self):
|
|
"""Empty repo but README.md already exists → skips creation."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
repo.heads = []
|
|
repo.remotes = []
|
|
repo.working_dir = "/tmp/repo"
|
|
new_branch = MagicMock()
|
|
repo.create_head.return_value = new_branch
|
|
svc = TestableGitBranch(repo)
|
|
with patch("os.path.exists", return_value=True), \
|
|
patch("builtins.open", MagicMock()):
|
|
result = await svc.create_branch(1, "feature", "main")
|
|
repo.index.add.assert_called_with(["README.md"])
|
|
repo.index.commit.assert_called_with("Initial commit")
|
|
|
|
|
|
# ── commit_changes edge ──
|
|
|
|
class TestCommitChangesEdge:
|
|
"""commit_changes — edge paths."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_with_empty_files_list(self):
|
|
"""Empty files list → stages all (not specific)."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
repo.is_dirty.return_value = True
|
|
svc = TestableGitBranch(repo)
|
|
await svc.commit_changes(1, "msg", files=[])
|
|
repo.git.add.assert_called_with(A=True)
|
|
|
|
|
|
# ── _ensure_gitflow_branches specific branches ──
|
|
|
|
class TestEnsureGitflowMissingMain:
|
|
"""_ensure_gitflow_branches — main branch missing from heads."""
|
|
|
|
def test_main_not_in_heads_created_from_base(self):
|
|
"""main not in local_heads → created from repo.head.commit."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
# No heads at all
|
|
repo.heads = []
|
|
repo.head.commit = MagicMock()
|
|
repo.active_branch.name = "dev"
|
|
origin = MagicMock()
|
|
origin.refs = []
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitBranch(repo)
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
# main should be created from repo.head.commit
|
|
repo.create_head.assert_any_call("main", repo.head.commit)
|
|
|
|
def test_active_branch_raises_no_checkout(self):
|
|
"""active_branch.name raises → current_branch=None, checkout dev attempted."""
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
repo.heads = [main_head]
|
|
repo.head.commit = MagicMock()
|
|
type(repo.active_branch).name = PropertyMock(side_effect=Exception("detached"))
|
|
origin = MagicMock()
|
|
origin.refs = [MagicMock(remote_head="main"), MagicMock(remote_head="dev"), MagicMock(remote_head="preprod")]
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitBranch(repo)
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
# With current_branch=None, None != "dev" is True, so checkout("dev") is called
|
|
repo.git.checkout.assert_called_once_with("dev")
|
|
# #endregion Test.Git.Branch.Edge
|