- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
298 lines
12 KiB
Python
298 lines
12 KiB
Python
# #region Test.Git.Branch.Coverage [C:3] [TYPE Module] [SEMANTICS test, git, branch, coverage, error, edge]
|
|
# @BRIEF Additional edge coverage for GitServiceBranchMixin — origin fetch failure, push failure, ref exception, create_head failure, stderr parsing, gitflow prod-present, ahead-of-dev counts, delete_branch failure paths.
|
|
# @RELATION BINDS_TO -> [Services.Branch.GitServiceBranchMixin]
|
|
|
|
import contextlib
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
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 TestableBranch(GitServiceBranchMixin):
|
|
"""Minimal test wrapper matching test_git_branch.py pattern."""
|
|
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()
|
|
|
|
|
|
class TestEnsureGitflowBranchesCoverage:
|
|
"""Cover origin.fetch failure and push failure paths."""
|
|
|
|
def test_fetch_failure_logged(self):
|
|
"""origin.fetch() raises → fetch error logged, push still attempted."""
|
|
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 = TestableBranch()
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
# Should have pushed branches (dev, preprod)
|
|
assert origin.push.call_count >= 2
|
|
|
|
def test_push_failure_raises_500(self):
|
|
"""Branch push to origin fails → HTTPException 500."""
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
repo.heads = [main_head]
|
|
repo.head.commit = MagicMock()
|
|
origin = MagicMock()
|
|
origin.push.side_effect = Exception("push denied")
|
|
repo.remote.return_value = origin
|
|
svc = TestableBranch()
|
|
with pytest.raises(HTTPException, match="Failed to create default branch"):
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
|
|
|
|
class TestListBranchesCoverage:
|
|
"""Cover ref processing exception and active-branch-not-listed paths."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ref_processing_exception(self):
|
|
"""Ref processing raises → skipped with log."""
|
|
repo = MagicMock()
|
|
bad_ref = MagicMock()
|
|
bad_ref.name = "refs/heads/dev"
|
|
type(bad_ref).commit = PropertyMock(side_effect=Exception("no commit"))
|
|
repo.refs = [bad_ref]
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
svc = TestableBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
# 'dev' should appear from active branch fallback
|
|
assert any(b["name"] == "dev" for b in result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_branch_not_in_list(self):
|
|
"""Active branch not in refs list → explicitly added."""
|
|
repo = MagicMock()
|
|
repo.refs = []
|
|
type(repo.active_branch).name = PropertyMock(return_value="main")
|
|
svc = TestableBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
assert any(b["name"] == "main" for b in result)
|
|
|
|
|
|
class TestCreateBranchCoverage:
|
|
"""Cover create_head failure path."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_head_failure(self):
|
|
"""create_head raises → exception propagates."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.remotes = [MagicMock()]
|
|
repo.commit.return_value = MagicMock()
|
|
repo.create_head.side_effect = Exception("name conflict")
|
|
svc = TestableBranch(repo)
|
|
with pytest.raises(Exception, match="name conflict"):
|
|
await svc.create_branch(1, "feature", "main")
|
|
|
|
|
|
class TestCheckoutBranchCoverage:
|
|
"""Cover stderr file-parsing path."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_stderr_parses_files(self):
|
|
"""stderr with file paths → GIT_CHECKOUT_LOCAL_CHANGES raised."""
|
|
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"
|
|
"\tsrc/app.py\n"
|
|
)
|
|
repo.git.checkout.side_effect = error
|
|
svc = TestableBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.checkout_branch(1, "other")
|
|
detail = exc_info.value.detail
|
|
assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES"
|
|
|
|
|
|
class TestGitflowProdAlreadyExists:
|
|
"""_ensure_gitflow_branches — prod head already present."""
|
|
|
|
def test_prod_present_uses_prod_commit_and_skips_creation(self):
|
|
"""prod in local heads → base is prod commit; prod not re-created."""
|
|
repo = MagicMock()
|
|
prod_head = MagicMock()
|
|
prod_head.name = "prod"
|
|
prod_head.commit = MagicMock()
|
|
repo.heads = [prod_head]
|
|
repo.head.commit = MagicMock()
|
|
origin = MagicMock()
|
|
origin.refs = []
|
|
repo.remote.return_value = origin
|
|
svc = TestableBranch()
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
created_names = [c.args[0] for c in repo.create_head.call_args_list]
|
|
assert "prod" not in created_names
|
|
assert "dev" in created_names
|
|
assert "preprod" in created_names
|
|
repo.create_head.assert_any_call("dev", prod_head.commit)
|
|
|
|
def test_remote_ref_without_remote_head_skipped(self):
|
|
"""Ref with falsy remote_head → ignored, other remote names collected."""
|
|
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 = [MagicMock(remote_head="main"), MagicMock(remote_head=None)]
|
|
repo.remote.return_value = origin
|
|
svc = TestableBranch()
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
# Only "main" was collected as a remote branch name → prod/dev/preprod pushed
|
|
origin.push.assert_any_call(refspec="prod:prod")
|
|
origin.push.assert_any_call(refspec="dev:dev")
|
|
origin.push.assert_any_call(refspec="preprod:preprod")
|
|
|
|
|
|
class TestBranchCommitsAheadOfDev:
|
|
"""_branch_commits_ahead_of_dev — feature/hotfix comparison counts."""
|
|
|
|
def test_feature_returns_commit_count(self):
|
|
"""Local feature branch → rev_list count returned."""
|
|
repo = MagicMock()
|
|
repo.git.rev_list.return_value = "3\n"
|
|
result = GitServiceBranchMixin._branch_commits_ahead_of_dev(repo, "feature/x", False)
|
|
assert result == 3
|
|
repo.git.rev_list.assert_called_once_with("--count", "dev..feature/x")
|
|
|
|
def test_hotfix_returns_zero_count(self):
|
|
"""Local hotfix branch → rev_list count returned (may be 0)."""
|
|
repo = MagicMock()
|
|
repo.git.rev_list.return_value = "0\n"
|
|
result = GitServiceBranchMixin._branch_commits_ahead_of_dev(repo, "hotfix/bug", False)
|
|
assert result == 0
|
|
|
|
def test_rev_list_failure_returns_none(self):
|
|
"""rev_list raises → None (comparison is best-effort)."""
|
|
repo = MagicMock()
|
|
repo.git.rev_list.side_effect = Exception("rev-list failed")
|
|
result = GitServiceBranchMixin._branch_commits_ahead_of_dev(repo, "feature/x", False)
|
|
assert result is None
|
|
|
|
def test_remote_branch_skips_comparison(self):
|
|
"""Remote branch → None without invoking git."""
|
|
repo = MagicMock()
|
|
result = GitServiceBranchMixin._branch_commits_ahead_of_dev(repo, "feature/x", True)
|
|
assert result is None
|
|
repo.git.rev_list.assert_not_called()
|
|
|
|
|
|
class TestClassifyBranchTypeCoverage:
|
|
"""_classify_branch_type — empty-name edge."""
|
|
|
|
def test_empty_name_returns_other(self):
|
|
"""Empty or whitespace-only name → 'other'."""
|
|
assert GitServiceBranchMixin._classify_branch_type("") == "other"
|
|
assert GitServiceBranchMixin._classify_branch_type(" ") == "other"
|
|
|
|
|
|
class TestDeleteBranchCoverage:
|
|
"""delete_branch — error and edge paths."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_name_raises_400(self):
|
|
"""Empty/whitespace-only branch name → HTTPException 400."""
|
|
repo = MagicMock()
|
|
svc = TestableBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.delete_branch(1, "")
|
|
assert exc_info.value.status_code == 400
|
|
assert "must not be empty" in exc_info.value.detail
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.delete_branch(1, " ")
|
|
assert exc_info.value.status_code == 400
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_branch_access_raises_continues(self):
|
|
"""active_branch.name raises → treated as not active, delete proceeds."""
|
|
repo = MagicMock()
|
|
head_feature = MagicMock()
|
|
head_feature.name = "feature/x"
|
|
repo.heads = [head_feature]
|
|
type(repo.active_branch).name = PropertyMock(side_effect=Exception("detached"))
|
|
repo.remote.side_effect = ValueError("no origin")
|
|
svc = TestableBranch(repo)
|
|
result = await svc.delete_branch(1, "feature/x")
|
|
assert result["status"] == "deleted"
|
|
repo.delete_head.assert_called_once_with("feature/x")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_branch_no_fallback_raises_409(self):
|
|
"""Active branch being deleted and no dev/prod fallback → HTTPException 409."""
|
|
repo = MagicMock()
|
|
head_feature = MagicMock()
|
|
head_feature.name = "feature/only"
|
|
repo.heads = [head_feature]
|
|
repo.active_branch.name = "feature/only"
|
|
svc = TestableBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.delete_branch(1, "feature/only")
|
|
assert exc_info.value.status_code == 409
|
|
assert "Cannot delete active branch" in exc_info.value.detail
|
|
repo.delete_head.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_delete_failure_raises_500(self):
|
|
"""repo.delete_head raises → HTTPException 500."""
|
|
repo = MagicMock()
|
|
head_feature = MagicMock()
|
|
head_feature.name = "feature/x"
|
|
head_dev = MagicMock()
|
|
head_dev.name = "dev"
|
|
repo.heads = [head_feature, head_dev]
|
|
repo.active_branch.name = "dev"
|
|
repo.delete_head.side_effect = Exception("branch locked")
|
|
svc = TestableBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.delete_branch(1, "feature/x")
|
|
assert exc_info.value.status_code == 500
|
|
assert "Failed to delete local branch" in exc_info.value.detail
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remote_push_other_error_non_fatal(self):
|
|
"""origin.push raises non-ValueError → logged, local delete still reported."""
|
|
repo = MagicMock()
|
|
head_feature = MagicMock()
|
|
head_feature.name = "feature/x"
|
|
head_dev = MagicMock()
|
|
head_dev.name = "dev"
|
|
repo.heads = [head_feature, head_dev]
|
|
repo.active_branch.name = "dev"
|
|
origin = MagicMock()
|
|
origin.push.side_effect = Exception("network down")
|
|
repo.remote.return_value = origin
|
|
svc = TestableBranch(repo)
|
|
result = await svc.delete_branch(1, "feature/x")
|
|
assert result["status"] == "deleted"
|
|
repo.delete_head.assert_called_once_with("feature/x")
|
|
origin.push.assert_called_once_with(refspec=":feature/x")
|
|
# #endregion Test.Git.Branch.Coverage
|