- Dockerfile.agent: fix CMD (python -m src.agent.run), use backend/requirements.txt, minimal COPY (only src.agent + src.core.cot_logger), add GRACE contract - docker-compose.yml: SERVICE_TOKEN_SECRET -> SERVICE_JWT (match code) - docker-compose.enterprise-clean.yml: same env var fix - docker/.env.agent.example: same env var fix - build.sh: same env var fix - chore: semantics-testing SKILL.md, backend tests, pyproject.toml
340 lines
13 KiB
Python
340 lines
13 KiB
Python
# #region Test.Git.Branch [C:3] [TYPE Module] [SEMANTICS test,git,branch,checkout,commit,gitflow]
|
|
# @BRIEF Tests for GitServiceBranchMixin — gitflow bootstrap, list/create/checkout branches, commit changes.
|
|
# @RELATION BINDS_TO -> [GitServiceBranchMixin]
|
|
# @TEST_EDGE: empty_repo -> initial commit created for branching
|
|
# @TEST_EDGE: no_commits -> gitflow bootstrap skipped
|
|
# @TEST_EDGE: checkout_local_changes -> HTTPException 409
|
|
# @TEST_EDGE: checkout_generic_error -> HTTPException 500
|
|
# @TEST_EDGE: commit_dirty -> stages all and commits
|
|
# @TEST_EDGE: commit_clean -> no commit when not dirty
|
|
# @TEST_EDGE: commit_specific_files -> only listed files staged
|
|
# @TEST_EDGE: list_branches_filters_tags -> refs/tags/ excluded
|
|
# @TEST_EDGE: create_branch_from_missing -> falls back to HEAD
|
|
|
|
import contextlib
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch, call, PropertyMock
|
|
|
|
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 ──
|
|
|
|
class TestEnsureGitflowBranches:
|
|
"""_ensure_gitflow_branches — main/dev/preprod bootstrap."""
|
|
|
|
# #region test_gitflow_no_commits [C:2] [TYPE Function]
|
|
def test_gitflow_no_commits(self):
|
|
"""Empty repo (no commits) → skip bootstrap, no error."""
|
|
repo = MagicMock()
|
|
type(repo.head).commit = property(lambda self: (_ for _ in StopIteration()).throw(ValueError("no commits")))
|
|
repo.heads = []
|
|
svc = TestableGitBranch()
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
repo.create_head.assert_not_called()
|
|
# #endregion test_gitflow_no_commits
|
|
|
|
# #region test_gitflow_creates_missing_branches [C:2] [TYPE Function]
|
|
def test_gitflow_creates_missing_branches(self):
|
|
"""Missing dev/preprod → created from main."""
|
|
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 = []
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitBranch()
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
created_names = [c.args[0] for c in repo.create_head.call_args_list]
|
|
assert "dev" in created_names
|
|
assert "preprod" in created_names
|
|
# #endregion test_gitflow_creates_missing_branches
|
|
|
|
# #region test_gitflow_no_origin [C:2] [TYPE Function]
|
|
def test_gitflow_no_origin(self):
|
|
"""No origin remote → skip remote push, no error."""
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
repo.heads = [main_head]
|
|
repo.head.commit = MagicMock()
|
|
repo.remote.side_effect = ValueError("no origin")
|
|
svc = TestableGitBranch()
|
|
# Should not raise
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
# #endregion test_gitflow_no_origin
|
|
|
|
# #region test_gitflow_already_on_dev [C:2] [TYPE Function]
|
|
def test_gitflow_already_on_dev(self):
|
|
"""Already on dev → no checkout call."""
|
|
repo = MagicMock()
|
|
main_head = MagicMock()
|
|
main_head.name = "main"
|
|
main_head.commit = MagicMock()
|
|
dev_head = MagicMock()
|
|
dev_head.name = "dev"
|
|
dev_head.commit = MagicMock()
|
|
preprod_head = MagicMock()
|
|
preprod_head.name = "preprod"
|
|
preprod_head.commit = MagicMock()
|
|
repo.heads = [main_head, dev_head, preprod_head]
|
|
repo.head.commit = MagicMock()
|
|
repo.active_branch.name = "dev"
|
|
origin = MagicMock()
|
|
origin.refs = [MagicMock(remote_head="main"), MagicMock(remote_head="dev"), MagicMock(remote_head="preprod")]
|
|
repo.remote.return_value = origin
|
|
svc = TestableGitBranch()
|
|
svc._ensure_gitflow_branches(repo, 1)
|
|
repo.git.checkout.assert_not_called()
|
|
# #endregion test_gitflow_already_on_dev
|
|
|
|
|
|
# ── list_branches ──
|
|
|
|
class TestListBranches:
|
|
"""list_branches — branch listing with tag filtering."""
|
|
|
|
# #region test_list_branches_basic [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_list_branches_basic(self):
|
|
"""Returns branch dicts with name, commit_hash, is_remote."""
|
|
repo = MagicMock()
|
|
ref1 = MagicMock()
|
|
ref1.name = "refs/heads/dev"
|
|
ref1.commit.hexsha = "abc123"
|
|
ref1.commit.committed_date = 1700000000
|
|
ref1.is_remote.return_value = False
|
|
repo.refs = [ref1]
|
|
repo.active_branch.name = "dev"
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
assert any(b["name"] == "dev" for b in result)
|
|
assert result[0]["commit_hash"] == "abc123"
|
|
# #endregion test_list_branches_basic
|
|
|
|
# #region test_list_branches_filters_tags [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_list_branches_filters_tags(self):
|
|
"""refs/tags/ entries excluded from result."""
|
|
repo = MagicMock()
|
|
tag_ref = MagicMock()
|
|
tag_ref.name = "refs/tags/v1.0"
|
|
tag_ref.commit.hexsha = "tag123"
|
|
tag_ref.commit.committed_date = 1700000000
|
|
branch_ref = MagicMock()
|
|
branch_ref.name = "refs/heads/main"
|
|
branch_ref.commit.hexsha = "main123"
|
|
branch_ref.commit.committed_date = 1700000000
|
|
branch_ref.is_remote.return_value = False
|
|
repo.refs = [tag_ref, branch_ref]
|
|
repo.active_branch.name = "main"
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
names = [b["name"] for b in result]
|
|
assert "v1.0" not in names
|
|
assert "main" in names
|
|
# #endregion test_list_branches_filters_tags
|
|
|
|
# #region test_list_branches_deduplicates [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_list_branches_deduplicates(self):
|
|
"""Same name from heads and remotes → only one entry."""
|
|
repo = MagicMock()
|
|
local_ref = MagicMock()
|
|
local_ref.name = "refs/heads/dev"
|
|
local_ref.commit.hexsha = "abc"
|
|
local_ref.commit.committed_date = 1700000000
|
|
local_ref.is_remote.return_value = False
|
|
remote_ref = MagicMock()
|
|
remote_ref.name = "refs/remotes/origin/dev"
|
|
remote_ref.commit.hexsha = "abc"
|
|
remote_ref.commit.committed_date = 1700000000
|
|
remote_ref.is_remote.return_value = True
|
|
repo.refs = [local_ref, remote_ref]
|
|
repo.active_branch.name = "dev"
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
dev_entries = [b for b in result if b["name"] == "dev"]
|
|
assert len(dev_entries) == 1
|
|
# #endregion test_list_branches_deduplicates
|
|
|
|
# #region test_list_branches_detached_head [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_list_branches_detached_head(self):
|
|
"""Detached HEAD → fallback 'dev' branch added if empty."""
|
|
repo = MagicMock()
|
|
repo.refs = []
|
|
type(repo.active_branch).name = PropertyMock(side_effect=TypeError("detached HEAD"))
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.list_branches(1)
|
|
assert any(b["name"] == "dev" for b in result)
|
|
# #endregion test_list_branches_detached_head
|
|
|
|
|
|
# ── create_branch ──
|
|
|
|
class TestCreateBranch:
|
|
"""create_branch — new branch from existing."""
|
|
|
|
# #region test_create_branch_success [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_create_branch_success(self):
|
|
"""Branch created from specified source."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.remotes = [MagicMock()]
|
|
repo.commit.return_value = MagicMock()
|
|
new_branch = MagicMock()
|
|
repo.create_head.return_value = new_branch
|
|
svc = TestableGitBranch(repo)
|
|
result = await svc.create_branch(1, "feature-x", "main")
|
|
repo.create_head.assert_called_with("feature-x", "main")
|
|
assert result == new_branch
|
|
# #endregion test_create_branch_success
|
|
|
|
# #region test_create_branch_empty_repo [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_create_branch_empty_repo(self):
|
|
"""Empty repo → initial commit created, then branch."""
|
|
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=False), \
|
|
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")
|
|
# #endregion test_create_branch_empty_repo
|
|
|
|
# #region test_create_branch_missing_source [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_create_branch_missing_source(self):
|
|
"""Source branch not found → falls back to repo.head."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.remotes = [MagicMock()]
|
|
repo.commit.side_effect = Exception("not found")
|
|
repo.head = MagicMock()
|
|
repo.create_head.return_value = MagicMock()
|
|
svc = TestableGitBranch(repo)
|
|
await svc.create_branch(1, "feature", "nonexistent")
|
|
repo.create_head.assert_called_with("feature", repo.head)
|
|
# #endregion test_create_branch_missing_source
|
|
|
|
|
|
# ── checkout_branch ──
|
|
|
|
class TestCheckoutBranch:
|
|
"""checkout_branch — switch active branch."""
|
|
|
|
# #region test_checkout_success [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_success(self):
|
|
"""Successful checkout → git.checkout called."""
|
|
repo = MagicMock()
|
|
svc = TestableGitBranch(repo)
|
|
await svc.checkout_branch(1, "main")
|
|
repo.git.checkout.assert_called_with("main")
|
|
# #endregion test_checkout_success
|
|
|
|
# #region test_checkout_local_changes [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_local_changes(self):
|
|
"""Local changes conflict → HTTPException 409."""
|
|
repo = MagicMock()
|
|
error = GitCommandError("checkout", "error")
|
|
error.stderr = "error: Your local changes to the following files would be overwritten by checkout:\n\tconfig.yaml"
|
|
repo.git.checkout.side_effect = error
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.checkout_branch(1, "main")
|
|
assert exc_info.value.status_code == 409
|
|
detail = exc_info.value.detail
|
|
assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES"
|
|
# #endregion test_checkout_local_changes
|
|
|
|
# #region test_checkout_generic_error [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_generic_error(self):
|
|
"""Other GitCommandError → HTTPException 500."""
|
|
repo = MagicMock()
|
|
repo.git.checkout.side_effect = GitCommandError("checkout", "fatal: bad ref")
|
|
svc = TestableGitBranch(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.checkout_branch(1, "bad-branch")
|
|
assert exc_info.value.status_code == 500
|
|
# #endregion test_checkout_generic_error
|
|
|
|
|
|
# ── commit_changes ──
|
|
|
|
class TestCommitChanges:
|
|
"""commit_changes — stage and commit."""
|
|
|
|
# #region test_commit_dirty_all [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_commit_dirty_all(self):
|
|
"""Dirty repo, no files → stage all and commit."""
|
|
repo = MagicMock()
|
|
repo.is_dirty.return_value = True
|
|
svc = TestableGitBranch(repo)
|
|
await svc.commit_changes(1, "update config")
|
|
repo.git.add.assert_called_with(A=True)
|
|
repo.index.commit.assert_called_with("update config")
|
|
# #endregion test_commit_dirty_all
|
|
|
|
# #region test_commit_clean [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_commit_clean(self):
|
|
"""Clean repo, no files → no commit."""
|
|
repo = MagicMock()
|
|
repo.is_dirty.return_value = False
|
|
svc = TestableGitBranch(repo)
|
|
await svc.commit_changes(1, "nothing")
|
|
repo.index.commit.assert_not_called()
|
|
# #endregion test_commit_clean
|
|
|
|
# #region test_commit_specific_files [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_commit_specific_files(self):
|
|
"""Files list → only those files staged."""
|
|
repo = MagicMock()
|
|
repo.is_dirty.return_value = False
|
|
svc = TestableGitBranch(repo)
|
|
await svc.commit_changes(1, "fix", files=["a.yaml", "b.yaml"])
|
|
repo.index.add.assert_called_with(["a.yaml", "b.yaml"])
|
|
repo.index.commit.assert_called_with("fix")
|
|
# #endregion test_commit_specific_files
|
|
# #endregion Test.Git.Branch
|