feat(mcp): implement Git Operations MCP server with Gitea provider
Implements the Git Operations MCP server (Issue #58) providing: Core features: - GitPython wrapper for local repository operations (clone, commit, push, pull, diff, log) - Branch management (create, delete, list, checkout) - Workspace isolation per project with file-based locking - Gitea provider for remote PR operations MCP Tools (17 registered): - clone_repository, git_status, create_branch, list_branches - checkout, commit, push, pull, diff, log - create_pull_request, get_pull_request, list_pull_requests - merge_pull_request, get_workspace, lock_workspace, unlock_workspace Technical details: - FastMCP + FastAPI with JSON-RPC 2.0 protocol - pydantic-settings for configuration (env prefix: GIT_OPS_) - Comprehensive error hierarchy with structured codes - 131 tests passing with 67% coverage - Async operations via ThreadPoolExecutor Closes: #105, #106, #107, #108, #109 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
mcp-servers/git-ops/tests/__init__.py
Normal file
1
mcp-servers/git-ops/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for Git Operations MCP Server."""
|
||||
299
mcp-servers/git-ops/tests/conftest.py
Normal file
299
mcp-servers/git-ops/tests/conftest.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Test configuration and fixtures for Git Operations MCP Server.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from git import Repo as GitRepo
|
||||
|
||||
# Set test environment
|
||||
os.environ["IS_TEST"] = "true"
|
||||
os.environ["GIT_OPS_WORKSPACE_BASE_PATH"] = "/tmp/test-workspaces"
|
||||
os.environ["GIT_OPS_GITEA_BASE_URL"] = "https://gitea.test.com"
|
||||
os.environ["GIT_OPS_GITEA_TOKEN"] = "test-token"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def reset_settings_session():
|
||||
"""Reset settings at start and end of test session."""
|
||||
from config import reset_settings
|
||||
|
||||
reset_settings()
|
||||
yield
|
||||
reset_settings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_settings():
|
||||
"""Reset settings before each test that needs it."""
|
||||
from config import reset_settings
|
||||
|
||||
reset_settings()
|
||||
yield
|
||||
reset_settings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_settings():
|
||||
"""Get test settings."""
|
||||
from config import Settings
|
||||
|
||||
return Settings(
|
||||
workspace_base_path=Path("/tmp/test-workspaces"),
|
||||
gitea_base_url="https://gitea.test.com",
|
||||
gitea_token="test-token",
|
||||
github_token="github-test-token",
|
||||
git_author_name="Test Agent",
|
||||
git_author_email="test@syndarix.ai",
|
||||
enable_force_push=False,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir() -> Iterator[Path]:
|
||||
"""Create a temporary directory for tests."""
|
||||
temp_path = Path(tempfile.mkdtemp())
|
||||
yield temp_path
|
||||
if temp_path.exists():
|
||||
shutil.rmtree(temp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace(temp_dir: Path) -> Path:
|
||||
"""Create a temporary workspace directory."""
|
||||
workspace = temp_dir / "workspace"
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
return workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo(temp_workspace: Path) -> GitRepo:
|
||||
"""Create a git repository in the temp workspace."""
|
||||
# Initialize with main branch (Git 2.28+)
|
||||
repo = GitRepo.init(temp_workspace, initial_branch="main")
|
||||
|
||||
# Configure git
|
||||
with repo.config_writer() as cw:
|
||||
cw.set_value("user", "name", "Test User")
|
||||
cw.set_value("user", "email", "test@example.com")
|
||||
|
||||
# Create initial commit
|
||||
test_file = temp_workspace / "README.md"
|
||||
test_file.write_text("# Test Repository\n")
|
||||
repo.index.add(["README.md"])
|
||||
repo.index.commit("Initial commit")
|
||||
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo_with_remote(git_repo: GitRepo, temp_dir: Path) -> tuple[GitRepo, GitRepo]:
|
||||
"""Create a git repository with a 'remote' (bare repo)."""
|
||||
# Create bare repo as remote
|
||||
remote_path = temp_dir / "remote.git"
|
||||
remote_repo = GitRepo.init(remote_path, bare=True)
|
||||
|
||||
# Add remote to main repo
|
||||
git_repo.create_remote("origin", str(remote_path))
|
||||
|
||||
# Push initial commit
|
||||
git_repo.remotes.origin.push("main:main")
|
||||
|
||||
# Set up tracking
|
||||
git_repo.heads.main.set_tracking_branch(git_repo.remotes.origin.refs.main)
|
||||
|
||||
return git_repo, remote_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_manager(temp_dir: Path, test_settings):
|
||||
"""Create a WorkspaceManager with test settings."""
|
||||
from workspace import WorkspaceManager
|
||||
|
||||
test_settings.workspace_base_path = temp_dir / "workspaces"
|
||||
return WorkspaceManager(test_settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_wrapper(temp_workspace: Path, test_settings):
|
||||
"""Create a GitWrapper for the temp workspace."""
|
||||
from git_wrapper import GitWrapper
|
||||
|
||||
return GitWrapper(temp_workspace, test_settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_wrapper_with_repo(git_repo: GitRepo, test_settings):
|
||||
"""Create a GitWrapper for a repo that's already initialized."""
|
||||
from git_wrapper import GitWrapper
|
||||
|
||||
return GitWrapper(Path(git_repo.working_dir), test_settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitea_provider():
|
||||
"""Create a mock Gitea provider."""
|
||||
provider = AsyncMock()
|
||||
provider.name = "gitea"
|
||||
provider.is_connected = AsyncMock(return_value=True)
|
||||
provider.get_authenticated_user = AsyncMock(return_value="test-user")
|
||||
provider.parse_repo_url = MagicMock(return_value=("owner", "repo"))
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client():
|
||||
"""Create a mock httpx client for provider tests."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json = MagicMock(return_value={})
|
||||
mock_response.text = ""
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.patch = AsyncMock(return_value=mock_response)
|
||||
mock_client.delete = AsyncMock(return_value=mock_response)
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def gitea_provider(test_settings, mock_httpx_client):
|
||||
"""Create a GiteaProvider with mocked HTTP client."""
|
||||
from providers.gitea import GiteaProvider
|
||||
|
||||
provider = GiteaProvider(
|
||||
base_url=test_settings.gitea_base_url,
|
||||
token=test_settings.gitea_token,
|
||||
settings=test_settings,
|
||||
)
|
||||
provider._client = mock_httpx_client
|
||||
|
||||
yield provider
|
||||
|
||||
await provider.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pr_data():
|
||||
"""Sample PR data from Gitea API."""
|
||||
return {
|
||||
"number": 42,
|
||||
"title": "Test PR",
|
||||
"body": "This is a test pull request",
|
||||
"state": "open",
|
||||
"head": {"ref": "feature-branch"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "test-user"},
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T12:00:00Z",
|
||||
"merged_at": None,
|
||||
"closed_at": None,
|
||||
"html_url": "https://gitea.test.com/owner/repo/pull/42",
|
||||
"labels": [{"name": "enhancement"}],
|
||||
"assignees": [{"login": "assignee1"}],
|
||||
"requested_reviewers": [{"login": "reviewer1"}],
|
||||
"mergeable": True,
|
||||
"draft": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_commit_data():
|
||||
"""Sample commit data."""
|
||||
return {
|
||||
"sha": "abc123def456",
|
||||
"short_sha": "abc123d",
|
||||
"message": "Test commit message",
|
||||
"author": {
|
||||
"name": "Test Author",
|
||||
"email": "author@test.com",
|
||||
"date": "2024-01-15T10:00:00Z",
|
||||
},
|
||||
"committer": {
|
||||
"name": "Test Committer",
|
||||
"email": "committer@test.com",
|
||||
"date": "2024-01-15T10:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_fastapi_app():
|
||||
"""Create a test FastAPI app."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy"}
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# Async fixtures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_workspace_manager(
|
||||
temp_dir: Path, test_settings
|
||||
) -> AsyncIterator:
|
||||
"""Async fixture for workspace manager."""
|
||||
from workspace import WorkspaceManager
|
||||
|
||||
test_settings.workspace_base_path = temp_dir / "workspaces"
|
||||
manager = WorkspaceManager(test_settings)
|
||||
yield manager
|
||||
|
||||
|
||||
# Test data fixtures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_project_id() -> str:
|
||||
"""Valid project ID for tests."""
|
||||
return "test-project-123"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_agent_id() -> str:
|
||||
"""Valid agent ID for tests."""
|
||||
return "agent-456"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_ids() -> list[str]:
|
||||
"""Invalid IDs for validation tests."""
|
||||
return [
|
||||
"",
|
||||
" ",
|
||||
"a" * 200, # Too long
|
||||
"test@invalid", # Invalid character
|
||||
"test!invalid",
|
||||
"../path/traversal",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_repo_url() -> str:
|
||||
"""Sample repository URL."""
|
||||
return "https://gitea.test.com/owner/repo.git"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_ssh_repo_url() -> str:
|
||||
"""Sample SSH repository URL."""
|
||||
return "git@gitea.test.com:owner/repo.git"
|
||||
434
mcp-servers/git-ops/tests/test_git_wrapper.py
Normal file
434
mcp-servers/git-ops/tests/test_git_wrapper.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Tests for the git_wrapper module.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from exceptions import (
|
||||
BranchExistsError,
|
||||
BranchNotFoundError,
|
||||
CheckoutError,
|
||||
CommitError,
|
||||
GitError,
|
||||
)
|
||||
from git_wrapper import GitWrapper
|
||||
from models import FileChangeType
|
||||
|
||||
|
||||
class TestGitWrapperInit:
|
||||
"""Tests for GitWrapper initialization."""
|
||||
|
||||
def test_init_with_valid_path(self, temp_workspace, test_settings):
|
||||
"""Test initialization with a valid path."""
|
||||
wrapper = GitWrapper(temp_workspace, test_settings)
|
||||
assert wrapper.workspace_path == temp_workspace
|
||||
assert wrapper.settings == test_settings
|
||||
|
||||
def test_repo_property_raises_on_non_git(self, temp_workspace, test_settings):
|
||||
"""Test that accessing repo on non-git dir raises error."""
|
||||
wrapper = GitWrapper(temp_workspace, test_settings)
|
||||
with pytest.raises(GitError, match="Not a git repository"):
|
||||
_ = wrapper.repo
|
||||
|
||||
def test_repo_property_works_on_git_dir(self, git_repo, test_settings):
|
||||
"""Test that repo property works for git directory."""
|
||||
wrapper = GitWrapper(Path(git_repo.working_dir), test_settings)
|
||||
assert wrapper.repo is not None
|
||||
assert wrapper.repo.head is not None
|
||||
|
||||
|
||||
class TestGitWrapperStatus:
|
||||
"""Tests for git status operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_clean_repo(self, git_wrapper_with_repo):
|
||||
"""Test status on a clean repository."""
|
||||
result = await git_wrapper_with_repo.status()
|
||||
|
||||
assert result.branch == "main"
|
||||
assert result.is_clean is True
|
||||
assert len(result.staged) == 0
|
||||
assert len(result.unstaged) == 0
|
||||
assert len(result.untracked) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_with_untracked(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test status with untracked files."""
|
||||
# Create untracked file
|
||||
untracked_file = Path(git_repo.working_dir) / "untracked.txt"
|
||||
untracked_file.write_text("untracked content")
|
||||
|
||||
result = await git_wrapper_with_repo.status()
|
||||
|
||||
assert result.is_clean is False
|
||||
assert "untracked.txt" in result.untracked
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_with_modified(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test status with modified files."""
|
||||
# Modify existing file
|
||||
readme = Path(git_repo.working_dir) / "README.md"
|
||||
readme.write_text("# Modified content\n")
|
||||
|
||||
result = await git_wrapper_with_repo.status()
|
||||
|
||||
assert result.is_clean is False
|
||||
assert len(result.unstaged) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_with_staged(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test status with staged changes."""
|
||||
# Create and stage a file
|
||||
new_file = Path(git_repo.working_dir) / "staged.txt"
|
||||
new_file.write_text("staged content")
|
||||
git_repo.index.add(["staged.txt"])
|
||||
|
||||
result = await git_wrapper_with_repo.status()
|
||||
|
||||
assert result.is_clean is False
|
||||
assert len(result.staged) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_exclude_untracked(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test status without untracked files."""
|
||||
untracked_file = Path(git_repo.working_dir) / "untracked.txt"
|
||||
untracked_file.write_text("untracked")
|
||||
|
||||
result = await git_wrapper_with_repo.status(include_untracked=False)
|
||||
|
||||
assert len(result.untracked) == 0
|
||||
|
||||
|
||||
class TestGitWrapperBranch:
|
||||
"""Tests for branch operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch(self, git_wrapper_with_repo):
|
||||
"""Test creating a new branch."""
|
||||
result = await git_wrapper_with_repo.create_branch("feature-test")
|
||||
|
||||
assert result.success is True
|
||||
assert result.branch == "feature-test"
|
||||
assert result.is_current is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_without_checkout(self, git_wrapper_with_repo):
|
||||
"""Test creating branch without checkout."""
|
||||
result = await git_wrapper_with_repo.create_branch("feature-no-checkout", checkout=False)
|
||||
|
||||
assert result.success is True
|
||||
assert result.branch == "feature-no-checkout"
|
||||
assert result.is_current is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_exists_error(self, git_wrapper_with_repo):
|
||||
"""Test error when branch already exists."""
|
||||
await git_wrapper_with_repo.create_branch("existing-branch", checkout=False)
|
||||
|
||||
with pytest.raises(BranchExistsError):
|
||||
await git_wrapper_with_repo.create_branch("existing-branch")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_branch(self, git_wrapper_with_repo):
|
||||
"""Test deleting a branch."""
|
||||
# Create branch first
|
||||
await git_wrapper_with_repo.create_branch("to-delete", checkout=False)
|
||||
|
||||
# Delete it
|
||||
result = await git_wrapper_with_repo.delete_branch("to-delete")
|
||||
|
||||
assert result.success is True
|
||||
assert result.branch == "to-delete"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_branch_not_found(self, git_wrapper_with_repo):
|
||||
"""Test error when deleting non-existent branch."""
|
||||
with pytest.raises(BranchNotFoundError):
|
||||
await git_wrapper_with_repo.delete_branch("nonexistent")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_current_branch_error(self, git_wrapper_with_repo):
|
||||
"""Test error when deleting current branch."""
|
||||
with pytest.raises(GitError, match="Cannot delete current branch"):
|
||||
await git_wrapper_with_repo.delete_branch("main")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches(self, git_wrapper_with_repo):
|
||||
"""Test listing branches."""
|
||||
# Create some branches
|
||||
await git_wrapper_with_repo.create_branch("branch-a", checkout=False)
|
||||
await git_wrapper_with_repo.create_branch("branch-b", checkout=False)
|
||||
|
||||
result = await git_wrapper_with_repo.list_branches()
|
||||
|
||||
assert result.current_branch == "main"
|
||||
branch_names = [b["name"] for b in result.local_branches]
|
||||
assert "main" in branch_names
|
||||
assert "branch-a" in branch_names
|
||||
assert "branch-b" in branch_names
|
||||
|
||||
|
||||
class TestGitWrapperCheckout:
|
||||
"""Tests for checkout operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_existing_branch(self, git_wrapper_with_repo):
|
||||
"""Test checkout of existing branch."""
|
||||
# Create branch first
|
||||
await git_wrapper_with_repo.create_branch("test-branch", checkout=False)
|
||||
|
||||
result = await git_wrapper_with_repo.checkout("test-branch")
|
||||
|
||||
assert result.success is True
|
||||
assert result.ref == "test-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_create_new(self, git_wrapper_with_repo):
|
||||
"""Test checkout with branch creation."""
|
||||
result = await git_wrapper_with_repo.checkout("new-branch", create_branch=True)
|
||||
|
||||
assert result.success is True
|
||||
assert result.ref == "new-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_nonexistent_error(self, git_wrapper_with_repo):
|
||||
"""Test error when checking out non-existent ref."""
|
||||
with pytest.raises(CheckoutError):
|
||||
await git_wrapper_with_repo.checkout("nonexistent-branch")
|
||||
|
||||
|
||||
class TestGitWrapperCommit:
|
||||
"""Tests for commit operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_staged_changes(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test committing staged changes."""
|
||||
# Create and stage a file
|
||||
new_file = Path(git_repo.working_dir) / "newfile.txt"
|
||||
new_file.write_text("new content")
|
||||
git_repo.index.add(["newfile.txt"])
|
||||
|
||||
result = await git_wrapper_with_repo.commit("Add new file")
|
||||
|
||||
assert result.success is True
|
||||
assert result.message == "Add new file"
|
||||
assert result.files_changed == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_all_changes(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test committing all changes (auto-stage)."""
|
||||
# Create a file without staging
|
||||
new_file = Path(git_repo.working_dir) / "unstaged.txt"
|
||||
new_file.write_text("content")
|
||||
|
||||
result = await git_wrapper_with_repo.commit("Commit unstaged")
|
||||
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_nothing_to_commit(self, git_wrapper_with_repo):
|
||||
"""Test error when nothing to commit."""
|
||||
with pytest.raises(CommitError, match="Nothing to commit"):
|
||||
await git_wrapper_with_repo.commit("Empty commit")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_with_author(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test commit with custom author."""
|
||||
new_file = Path(git_repo.working_dir) / "authored.txt"
|
||||
new_file.write_text("authored content")
|
||||
|
||||
result = await git_wrapper_with_repo.commit(
|
||||
"Custom author commit",
|
||||
author_name="Custom Author",
|
||||
author_email="custom@test.com",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
|
||||
class TestGitWrapperDiff:
|
||||
"""Tests for diff operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_no_changes(self, git_wrapper_with_repo):
|
||||
"""Test diff with no changes."""
|
||||
result = await git_wrapper_with_repo.diff()
|
||||
|
||||
assert result.files_changed == 0
|
||||
assert result.total_additions == 0
|
||||
assert result.total_deletions == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_with_changes(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test diff with modified files."""
|
||||
# Modify a file
|
||||
readme = Path(git_repo.working_dir) / "README.md"
|
||||
readme.write_text("# Modified\nNew line\n")
|
||||
|
||||
result = await git_wrapper_with_repo.diff()
|
||||
|
||||
assert result.files_changed > 0
|
||||
|
||||
|
||||
class TestGitWrapperLog:
|
||||
"""Tests for log operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_basic(self, git_wrapper_with_repo):
|
||||
"""Test basic log."""
|
||||
result = await git_wrapper_with_repo.log()
|
||||
|
||||
assert result.total_commits > 0
|
||||
assert len(result.commits) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_with_limit(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test log with limit."""
|
||||
# Create more commits
|
||||
for i in range(5):
|
||||
file_path = Path(git_repo.working_dir) / f"file{i}.txt"
|
||||
file_path.write_text(f"content {i}")
|
||||
git_repo.index.add([f"file{i}.txt"])
|
||||
git_repo.index.commit(f"Commit {i}")
|
||||
|
||||
result = await git_wrapper_with_repo.log(limit=3)
|
||||
|
||||
assert len(result.commits) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_commit_info(self, git_wrapper_with_repo):
|
||||
"""Test that log returns proper commit info."""
|
||||
result = await git_wrapper_with_repo.log(limit=1)
|
||||
|
||||
commit = result.commits[0]
|
||||
assert "sha" in commit
|
||||
assert "message" in commit
|
||||
assert "author_name" in commit
|
||||
assert "author_email" in commit
|
||||
|
||||
|
||||
class TestGitWrapperUtilities:
|
||||
"""Tests for utility methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_valid_ref_true(self, git_wrapper_with_repo):
|
||||
"""Test valid ref detection."""
|
||||
is_valid = await git_wrapper_with_repo.is_valid_ref("main")
|
||||
assert is_valid is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_valid_ref_false(self, git_wrapper_with_repo):
|
||||
"""Test invalid ref detection."""
|
||||
is_valid = await git_wrapper_with_repo.is_valid_ref("nonexistent")
|
||||
assert is_valid is False
|
||||
|
||||
def test_diff_to_change_type(self, git_wrapper_with_repo):
|
||||
"""Test change type conversion."""
|
||||
wrapper = git_wrapper_with_repo
|
||||
|
||||
assert wrapper._diff_to_change_type("A") == FileChangeType.ADDED
|
||||
assert wrapper._diff_to_change_type("M") == FileChangeType.MODIFIED
|
||||
assert wrapper._diff_to_change_type("D") == FileChangeType.DELETED
|
||||
assert wrapper._diff_to_change_type("R") == FileChangeType.RENAMED
|
||||
|
||||
|
||||
class TestGitWrapperStage:
|
||||
"""Tests for staging operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_specific_files(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test staging specific files."""
|
||||
# Create files
|
||||
file1 = Path(git_repo.working_dir) / "file1.txt"
|
||||
file2 = Path(git_repo.working_dir) / "file2.txt"
|
||||
file1.write_text("content 1")
|
||||
file2.write_text("content 2")
|
||||
|
||||
count = await git_wrapper_with_repo.stage(["file1.txt"])
|
||||
|
||||
assert count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_all(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test staging all files."""
|
||||
file1 = Path(git_repo.working_dir) / "all1.txt"
|
||||
file2 = Path(git_repo.working_dir) / "all2.txt"
|
||||
file1.write_text("content 1")
|
||||
file2.write_text("content 2")
|
||||
|
||||
count = await git_wrapper_with_repo.stage()
|
||||
|
||||
assert count >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unstage_files(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test unstaging files."""
|
||||
# Create and stage file
|
||||
file1 = Path(git_repo.working_dir) / "unstage.txt"
|
||||
file1.write_text("to unstage")
|
||||
git_repo.index.add(["unstage.txt"])
|
||||
|
||||
count = await git_wrapper_with_repo.unstage()
|
||||
|
||||
assert count >= 1
|
||||
|
||||
|
||||
class TestGitWrapperReset:
|
||||
"""Tests for reset operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_soft(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test soft reset."""
|
||||
# Create a commit to reset
|
||||
file1 = Path(git_repo.working_dir) / "reset_soft.txt"
|
||||
file1.write_text("content")
|
||||
git_repo.index.add(["reset_soft.txt"])
|
||||
git_repo.index.commit("Commit to reset")
|
||||
|
||||
result = await git_wrapper_with_repo.reset("HEAD~1", mode="soft")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_mixed(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test mixed reset (default)."""
|
||||
file1 = Path(git_repo.working_dir) / "reset_mixed.txt"
|
||||
file1.write_text("content")
|
||||
git_repo.index.add(["reset_mixed.txt"])
|
||||
git_repo.index.commit("Commit to reset")
|
||||
|
||||
result = await git_wrapper_with_repo.reset("HEAD~1", mode="mixed")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_invalid_mode(self, git_wrapper_with_repo):
|
||||
"""Test error on invalid reset mode."""
|
||||
with pytest.raises(GitError, match="Invalid reset mode"):
|
||||
await git_wrapper_with_repo.reset("HEAD", mode="invalid")
|
||||
|
||||
|
||||
class TestGitWrapperStash:
|
||||
"""Tests for stash operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stash_changes(self, git_wrapper_with_repo, git_repo):
|
||||
"""Test stashing changes."""
|
||||
# Make changes
|
||||
readme = Path(git_repo.working_dir) / "README.md"
|
||||
readme.write_text("Modified for stash")
|
||||
|
||||
result = await git_wrapper_with_repo.stash("Test stash")
|
||||
|
||||
# Result should be stash ref or None if nothing to stash
|
||||
# (depends on whether changes were already staged)
|
||||
assert result is None or result.startswith("stash@")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stash_nothing(self, git_wrapper_with_repo):
|
||||
"""Test stash with no changes."""
|
||||
result = await git_wrapper_with_repo.stash()
|
||||
|
||||
assert result is None
|
||||
484
mcp-servers/git-ops/tests/test_providers.py
Normal file
484
mcp-servers/git-ops/tests/test_providers.py
Normal file
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
Tests for git provider implementations.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from exceptions import APIError, AuthenticationError
|
||||
from models import MergeStrategy, PRState
|
||||
from providers.gitea import GiteaProvider
|
||||
|
||||
|
||||
class TestBaseProvider:
|
||||
"""Tests for BaseProvider interface."""
|
||||
|
||||
def test_parse_repo_url_https(self, mock_gitea_provider):
|
||||
"""Test parsing HTTPS repo URL."""
|
||||
# The mock needs parse_repo_url to work
|
||||
provider = GiteaProvider(
|
||||
base_url="https://gitea.test.com",
|
||||
token="test-token"
|
||||
)
|
||||
|
||||
owner, repo = provider.parse_repo_url("https://gitea.test.com/owner/repo.git")
|
||||
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_parse_repo_url_https_no_git(self):
|
||||
"""Test parsing HTTPS URL without .git suffix."""
|
||||
provider = GiteaProvider(
|
||||
base_url="https://gitea.test.com",
|
||||
token="test-token"
|
||||
)
|
||||
|
||||
owner, repo = provider.parse_repo_url("https://gitea.test.com/owner/repo")
|
||||
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_parse_repo_url_ssh(self):
|
||||
"""Test parsing SSH repo URL."""
|
||||
provider = GiteaProvider(
|
||||
base_url="https://gitea.test.com",
|
||||
token="test-token"
|
||||
)
|
||||
|
||||
owner, repo = provider.parse_repo_url("git@gitea.test.com:owner/repo.git")
|
||||
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
|
||||
def test_parse_repo_url_invalid(self):
|
||||
"""Test error on invalid URL."""
|
||||
provider = GiteaProvider(
|
||||
base_url="https://gitea.test.com",
|
||||
token="test-token"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unable to parse"):
|
||||
provider.parse_repo_url("invalid-url")
|
||||
|
||||
|
||||
class TestGiteaProvider:
|
||||
"""Tests for GiteaProvider."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_connected(self, gitea_provider, mock_httpx_client):
|
||||
"""Test connection check."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={"login": "test-user"}
|
||||
)
|
||||
|
||||
result = await gitea_provider.is_connected()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_connected_no_token(self, test_settings):
|
||||
"""Test connection fails without token."""
|
||||
provider = GiteaProvider(
|
||||
base_url="https://gitea.test.com",
|
||||
token="",
|
||||
settings=test_settings,
|
||||
)
|
||||
|
||||
result = await provider.is_connected()
|
||||
assert result is False
|
||||
|
||||
await provider.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authenticated_user(self, gitea_provider, mock_httpx_client):
|
||||
"""Test getting authenticated user."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={"login": "test-user"}
|
||||
)
|
||||
|
||||
user = await gitea_provider.get_authenticated_user()
|
||||
|
||||
assert user == "test-user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_repo_info(self, gitea_provider, mock_httpx_client):
|
||||
"""Test getting repository info."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={
|
||||
"name": "repo",
|
||||
"full_name": "owner/repo",
|
||||
"default_branch": "main",
|
||||
}
|
||||
)
|
||||
|
||||
result = await gitea_provider.get_repo_info("owner", "repo")
|
||||
|
||||
assert result["name"] == "repo"
|
||||
assert result["default_branch"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_default_branch(self, gitea_provider, mock_httpx_client):
|
||||
"""Test getting default branch."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={"default_branch": "develop"}
|
||||
)
|
||||
|
||||
branch = await gitea_provider.get_default_branch("owner", "repo")
|
||||
|
||||
assert branch == "develop"
|
||||
|
||||
|
||||
class TestGiteaPROperations:
|
||||
"""Tests for Gitea PR operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr(self, gitea_provider, mock_httpx_client):
|
||||
"""Test creating a pull request."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={
|
||||
"number": 42,
|
||||
"html_url": "https://gitea.test.com/owner/repo/pull/42",
|
||||
}
|
||||
)
|
||||
|
||||
result = await gitea_provider.create_pr(
|
||||
owner="owner",
|
||||
repo="repo",
|
||||
title="Test PR",
|
||||
body="Test body",
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.pr_number == 42
|
||||
assert result.pr_url == "https://gitea.test.com/owner/repo/pull/42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_with_options(self, gitea_provider, mock_httpx_client):
|
||||
"""Test creating PR with labels, assignees, reviewers."""
|
||||
# Use side_effect for multiple API calls:
|
||||
# 1. POST create PR
|
||||
# 2. GET labels (for "enhancement") - in add_labels -> _get_or_create_label
|
||||
# 3. POST add labels to PR - in add_labels
|
||||
# 4. GET issue to return labels - in add_labels
|
||||
# 5. PATCH add assignees
|
||||
# 6. POST request reviewers
|
||||
mock_responses = [
|
||||
{"number": 43, "html_url": "https://gitea.test.com/owner/repo/pull/43"}, # Create PR
|
||||
[{"id": 1, "name": "enhancement"}], # GET labels (found)
|
||||
{}, # POST add labels to PR
|
||||
{"labels": [{"name": "enhancement"}]}, # GET issue to return current labels
|
||||
{}, # PATCH add assignees
|
||||
{}, # POST request reviewers
|
||||
]
|
||||
mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
|
||||
|
||||
result = await gitea_provider.create_pr(
|
||||
owner="owner",
|
||||
repo="repo",
|
||||
title="Test PR",
|
||||
body="Test body",
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
labels=["enhancement"],
|
||||
assignees=["user1"],
|
||||
reviewers=["reviewer1"],
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pr(self, gitea_provider, mock_httpx_client, sample_pr_data):
|
||||
"""Test getting a pull request."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value=sample_pr_data
|
||||
)
|
||||
|
||||
result = await gitea_provider.get_pr("owner", "repo", 42)
|
||||
|
||||
assert result.success is True
|
||||
assert result.pr["number"] == 42
|
||||
assert result.pr["title"] == "Test PR"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pr_not_found(self, gitea_provider, mock_httpx_client):
|
||||
"""Test getting non-existent PR."""
|
||||
mock_httpx_client.request.return_value.status_code = 404
|
||||
mock_httpx_client.request.return_value.json = MagicMock(return_value=None)
|
||||
|
||||
result = await gitea_provider.get_pr("owner", "repo", 999)
|
||||
|
||||
assert result.success is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_prs(self, gitea_provider, mock_httpx_client, sample_pr_data):
|
||||
"""Test listing pull requests."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value=[sample_pr_data, sample_pr_data]
|
||||
)
|
||||
|
||||
result = await gitea_provider.list_prs("owner", "repo")
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.pull_requests) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_prs_with_state_filter(self, gitea_provider, mock_httpx_client, sample_pr_data):
|
||||
"""Test listing PRs with state filter."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value=[sample_pr_data]
|
||||
)
|
||||
|
||||
result = await gitea_provider.list_prs(
|
||||
"owner", "repo", state=PRState.OPEN
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr(self, gitea_provider, mock_httpx_client):
|
||||
"""Test merging a pull request."""
|
||||
# First call returns merge result
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={"sha": "merge-commit-sha"}
|
||||
)
|
||||
|
||||
result = await gitea_provider.merge_pr(
|
||||
"owner", "repo", 42,
|
||||
merge_strategy=MergeStrategy.SQUASH,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.merge_commit_sha == "merge-commit-sha"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pr(self, gitea_provider, mock_httpx_client, sample_pr_data):
|
||||
"""Test updating a pull request."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value=sample_pr_data
|
||||
)
|
||||
|
||||
result = await gitea_provider.update_pr(
|
||||
"owner", "repo", 42,
|
||||
title="Updated Title",
|
||||
body="Updated body",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_pr(self, gitea_provider, mock_httpx_client, sample_pr_data):
|
||||
"""Test closing a pull request."""
|
||||
sample_pr_data["state"] = "closed"
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value=sample_pr_data
|
||||
)
|
||||
|
||||
result = await gitea_provider.close_pr("owner", "repo", 42)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
|
||||
class TestGiteaBranchOperations:
|
||||
"""Tests for Gitea branch operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branch(self, gitea_provider, mock_httpx_client):
|
||||
"""Test getting branch info."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={
|
||||
"name": "main",
|
||||
"commit": {"sha": "abc123"},
|
||||
}
|
||||
)
|
||||
|
||||
result = await gitea_provider.get_branch("owner", "repo", "main")
|
||||
|
||||
assert result["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_remote_branch(self, gitea_provider, mock_httpx_client):
|
||||
"""Test deleting a remote branch."""
|
||||
mock_httpx_client.request.return_value.status_code = 204
|
||||
|
||||
result = await gitea_provider.delete_remote_branch("owner", "repo", "old-branch")
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestGiteaCommentOperations:
|
||||
"""Tests for Gitea comment operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_pr_comment(self, gitea_provider, mock_httpx_client):
|
||||
"""Test adding a comment to a PR."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={"id": 1, "body": "Test comment"}
|
||||
)
|
||||
|
||||
result = await gitea_provider.add_pr_comment(
|
||||
"owner", "repo", 42, "Test comment"
|
||||
)
|
||||
|
||||
assert result["body"] == "Test comment"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pr_comments(self, gitea_provider, mock_httpx_client):
|
||||
"""Test listing PR comments."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value=[
|
||||
{"id": 1, "body": "Comment 1"},
|
||||
{"id": 2, "body": "Comment 2"},
|
||||
]
|
||||
)
|
||||
|
||||
result = await gitea_provider.list_pr_comments("owner", "repo", 42)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestGiteaLabelOperations:
|
||||
"""Tests for Gitea label operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_labels(self, gitea_provider, mock_httpx_client):
|
||||
"""Test adding labels to a PR."""
|
||||
# Use side_effect to return different values for different calls
|
||||
# 1. GET labels (for "bug") - returns existing labels
|
||||
# 2. POST to create "bug" label
|
||||
# 3. GET labels (for "urgent")
|
||||
# 4. POST to create "urgent" label
|
||||
# 5. POST labels to PR
|
||||
# 6. GET issue to return final labels
|
||||
mock_responses = [
|
||||
[{"id": 1, "name": "existing"}], # GET labels (bug not found)
|
||||
{"id": 2, "name": "bug"}, # POST create bug
|
||||
[{"id": 1, "name": "existing"}, {"id": 2, "name": "bug"}], # GET labels (urgent not found)
|
||||
{"id": 3, "name": "urgent"}, # POST create urgent
|
||||
{}, # POST add labels to PR
|
||||
{"labels": [{"name": "bug"}, {"name": "urgent"}]}, # GET issue
|
||||
]
|
||||
mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
|
||||
|
||||
result = await gitea_provider.add_labels(
|
||||
"owner", "repo", 42, ["bug", "urgent"]
|
||||
)
|
||||
|
||||
# Should return updated label list
|
||||
assert isinstance(result, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_label(self, gitea_provider, mock_httpx_client):
|
||||
"""Test removing a label from a PR."""
|
||||
# Use side_effect for multiple calls
|
||||
# 1. GET labels to find the label ID
|
||||
# 2. DELETE the label from the PR
|
||||
# 3. GET issue to return remaining labels
|
||||
mock_responses = [
|
||||
[{"id": 1, "name": "bug"}], # GET labels
|
||||
{}, # DELETE label
|
||||
{"labels": []}, # GET issue
|
||||
]
|
||||
mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
|
||||
|
||||
result = await gitea_provider.remove_label(
|
||||
"owner", "repo", 42, "bug"
|
||||
)
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestGiteaReviewerOperations:
|
||||
"""Tests for Gitea reviewer operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_review(self, gitea_provider, mock_httpx_client):
|
||||
"""Test requesting review from users."""
|
||||
mock_httpx_client.request.return_value.json = MagicMock(return_value={})
|
||||
|
||||
result = await gitea_provider.request_review(
|
||||
"owner", "repo", 42, ["reviewer1", "reviewer2"]
|
||||
)
|
||||
|
||||
assert result == ["reviewer1", "reviewer2"]
|
||||
|
||||
|
||||
class TestGiteaErrorHandling:
|
||||
"""Tests for error handling in Gitea provider."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_error(self, gitea_provider, mock_httpx_client):
|
||||
"""Test handling authentication errors."""
|
||||
mock_httpx_client.request.return_value.status_code = 401
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
await gitea_provider._request("GET", "/user")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_denied(self, gitea_provider, mock_httpx_client):
|
||||
"""Test handling permission denied errors."""
|
||||
mock_httpx_client.request.return_value.status_code = 403
|
||||
|
||||
with pytest.raises(AuthenticationError, match="Insufficient permissions"):
|
||||
await gitea_provider._request("GET", "/protected")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_error(self, gitea_provider, mock_httpx_client):
|
||||
"""Test handling general API errors."""
|
||||
mock_httpx_client.request.return_value.status_code = 500
|
||||
mock_httpx_client.request.return_value.text = "Internal Server Error"
|
||||
mock_httpx_client.request.return_value.json = MagicMock(
|
||||
return_value={"message": "Server error"}
|
||||
)
|
||||
|
||||
with pytest.raises(APIError):
|
||||
await gitea_provider._request("GET", "/error")
|
||||
|
||||
|
||||
class TestGiteaPRParsing:
|
||||
"""Tests for PR data parsing."""
|
||||
|
||||
def test_parse_pr_open(self, gitea_provider, sample_pr_data):
|
||||
"""Test parsing open PR."""
|
||||
pr_info = gitea_provider._parse_pr(sample_pr_data)
|
||||
|
||||
assert pr_info.number == 42
|
||||
assert pr_info.state == PRState.OPEN
|
||||
assert pr_info.title == "Test PR"
|
||||
assert pr_info.source_branch == "feature-branch"
|
||||
assert pr_info.target_branch == "main"
|
||||
|
||||
def test_parse_pr_merged(self, gitea_provider, sample_pr_data):
|
||||
"""Test parsing merged PR."""
|
||||
sample_pr_data["merged"] = True
|
||||
sample_pr_data["merged_at"] = "2024-01-16T10:00:00Z"
|
||||
|
||||
pr_info = gitea_provider._parse_pr(sample_pr_data)
|
||||
|
||||
assert pr_info.state == PRState.MERGED
|
||||
|
||||
def test_parse_pr_closed(self, gitea_provider, sample_pr_data):
|
||||
"""Test parsing closed PR."""
|
||||
sample_pr_data["state"] = "closed"
|
||||
sample_pr_data["closed_at"] = "2024-01-16T10:00:00Z"
|
||||
|
||||
pr_info = gitea_provider._parse_pr(sample_pr_data)
|
||||
|
||||
assert pr_info.state == PRState.CLOSED
|
||||
|
||||
def test_parse_datetime_iso(self, gitea_provider):
|
||||
"""Test parsing ISO datetime strings."""
|
||||
dt = gitea_provider._parse_datetime("2024-01-15T10:30:00Z")
|
||||
|
||||
assert dt.year == 2024
|
||||
assert dt.month == 1
|
||||
assert dt.day == 15
|
||||
|
||||
def test_parse_datetime_none(self, gitea_provider):
|
||||
"""Test parsing None datetime returns now."""
|
||||
dt = gitea_provider._parse_datetime(None)
|
||||
|
||||
assert dt is not None
|
||||
assert dt.tzinfo is not None
|
||||
514
mcp-servers/git-ops/tests/test_server.py
Normal file
514
mcp-servers/git-ops/tests/test_server.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Tests for the MCP server and tools.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from exceptions import ErrorCode
|
||||
|
||||
|
||||
class TestInputValidation:
|
||||
"""Tests for input validation functions."""
|
||||
|
||||
def test_validate_id_valid(self):
|
||||
"""Test valid IDs pass validation."""
|
||||
from server import _validate_id
|
||||
|
||||
assert _validate_id("test-123", "project_id") is None
|
||||
assert _validate_id("my_project", "project_id") is None
|
||||
assert _validate_id("Agent-001", "agent_id") is None
|
||||
|
||||
def test_validate_id_empty(self):
|
||||
"""Test empty ID fails validation."""
|
||||
from server import _validate_id
|
||||
|
||||
error = _validate_id("", "project_id")
|
||||
assert error is not None
|
||||
assert "required" in error.lower()
|
||||
|
||||
def test_validate_id_too_long(self):
|
||||
"""Test too-long ID fails validation."""
|
||||
from server import _validate_id
|
||||
|
||||
error = _validate_id("a" * 200, "project_id")
|
||||
assert error is not None
|
||||
assert "1-128" in error
|
||||
|
||||
def test_validate_id_invalid_chars(self):
|
||||
"""Test invalid characters fail validation."""
|
||||
from server import _validate_id
|
||||
|
||||
assert _validate_id("test@invalid", "project_id") is not None
|
||||
assert _validate_id("test!project", "project_id") is not None
|
||||
assert _validate_id("test project", "project_id") is not None
|
||||
|
||||
def test_validate_branch_valid(self):
|
||||
"""Test valid branch names."""
|
||||
from server import _validate_branch
|
||||
|
||||
assert _validate_branch("main") is None
|
||||
assert _validate_branch("feature/new-thing") is None
|
||||
assert _validate_branch("release-1.0.0") is None
|
||||
assert _validate_branch("hotfix.urgent") is None
|
||||
|
||||
def test_validate_branch_invalid(self):
|
||||
"""Test invalid branch names."""
|
||||
from server import _validate_branch
|
||||
|
||||
assert _validate_branch("") is not None
|
||||
assert _validate_branch("a" * 300) is not None
|
||||
|
||||
def test_validate_url_valid(self):
|
||||
"""Test valid repository URLs."""
|
||||
from server import _validate_url
|
||||
|
||||
assert _validate_url("https://github.com/owner/repo.git") is None
|
||||
assert _validate_url("https://gitea.example.com/owner/repo") is None
|
||||
assert _validate_url("git@github.com:owner/repo.git") is None
|
||||
|
||||
def test_validate_url_invalid(self):
|
||||
"""Test invalid repository URLs."""
|
||||
from server import _validate_url
|
||||
|
||||
assert _validate_url("") is not None
|
||||
assert _validate_url("not-a-url") is not None
|
||||
assert _validate_url("ftp://invalid.com/repo") is not None
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
"""Tests for health check endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_structure(self):
|
||||
"""Test health check returns proper structure."""
|
||||
from server import health_check
|
||||
|
||||
with patch("server._gitea_provider", None), \
|
||||
patch("server._workspace_manager", None):
|
||||
result = await health_check()
|
||||
|
||||
assert "status" in result
|
||||
assert "service" in result
|
||||
assert "version" in result
|
||||
assert "timestamp" in result
|
||||
assert "dependencies" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_no_providers(self):
|
||||
"""Test health check without providers configured."""
|
||||
from server import health_check
|
||||
|
||||
with patch("server._gitea_provider", None), \
|
||||
patch("server._workspace_manager", None):
|
||||
result = await health_check()
|
||||
|
||||
assert result["dependencies"]["gitea"] == "not configured"
|
||||
|
||||
|
||||
class TestToolRegistry:
|
||||
"""Tests for tool registration."""
|
||||
|
||||
def test_tool_registry_populated(self):
|
||||
"""Test that tools are registered."""
|
||||
from server import _tool_registry
|
||||
|
||||
assert len(_tool_registry) > 0
|
||||
assert "clone_repository" in _tool_registry
|
||||
assert "git_status" in _tool_registry
|
||||
assert "create_branch" in _tool_registry
|
||||
assert "commit" in _tool_registry
|
||||
|
||||
def test_tool_schema_structure(self):
|
||||
"""Test tool schemas have proper structure."""
|
||||
from server import _tool_registry
|
||||
|
||||
for name, info in _tool_registry.items():
|
||||
assert "func" in info
|
||||
assert "description" in info
|
||||
assert "schema" in info
|
||||
assert info["schema"]["type"] == "object"
|
||||
assert "properties" in info["schema"]
|
||||
|
||||
|
||||
class TestCloneRepository:
|
||||
"""Tests for clone_repository tool."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_invalid_project_id(self):
|
||||
"""Test clone with invalid project ID."""
|
||||
from server import clone_repository
|
||||
|
||||
# Access the underlying function via .fn
|
||||
result = await clone_repository.fn(
|
||||
project_id="invalid@id",
|
||||
agent_id="agent-1",
|
||||
repo_url="https://github.com/owner/repo.git",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "project_id" in result["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_invalid_repo_url(self):
|
||||
"""Test clone with invalid repo URL."""
|
||||
from server import clone_repository
|
||||
|
||||
result = await clone_repository.fn(
|
||||
project_id="valid-project",
|
||||
agent_id="agent-1",
|
||||
repo_url="not-a-valid-url",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "url" in result["error"].lower()
|
||||
|
||||
|
||||
class TestGitStatus:
|
||||
"""Tests for git_status tool."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_workspace_not_found(self):
|
||||
"""Test status when workspace doesn't exist."""
|
||||
from server import git_status
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=None)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await git_status.fn(
|
||||
project_id="nonexistent",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == ErrorCode.WORKSPACE_NOT_FOUND.value
|
||||
|
||||
|
||||
class TestBranchOperations:
|
||||
"""Tests for branch operation tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_invalid_name(self):
|
||||
"""Test creating branch with invalid name."""
|
||||
from server import create_branch
|
||||
|
||||
result = await create_branch.fn(
|
||||
project_id="test-project",
|
||||
agent_id="agent-1",
|
||||
branch_name="", # Invalid
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_workspace_not_found(self):
|
||||
"""Test listing branches when workspace doesn't exist."""
|
||||
from server import list_branches
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=None)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await list_branches.fn(
|
||||
project_id="nonexistent",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_invalid_project(self):
|
||||
"""Test checkout with invalid project ID."""
|
||||
from server import checkout
|
||||
|
||||
result = await checkout.fn(
|
||||
project_id="inv@lid",
|
||||
agent_id="agent-1",
|
||||
ref="main",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
class TestCommitOperations:
|
||||
"""Tests for commit operation tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_invalid_project(self):
|
||||
"""Test commit with invalid project ID."""
|
||||
from server import commit
|
||||
|
||||
result = await commit.fn(
|
||||
project_id="inv@lid",
|
||||
agent_id="agent-1",
|
||||
message="Test commit",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
class TestPushPullOperations:
|
||||
"""Tests for push/pull operation tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_workspace_not_found(self):
|
||||
"""Test push when workspace doesn't exist."""
|
||||
from server import push
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=None)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await push.fn(
|
||||
project_id="nonexistent",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_workspace_not_found(self):
|
||||
"""Test pull when workspace doesn't exist."""
|
||||
from server import pull
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=None)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await pull.fn(
|
||||
project_id="nonexistent",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
class TestDiffLogOperations:
|
||||
"""Tests for diff and log operation tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_workspace_not_found(self):
|
||||
"""Test diff when workspace doesn't exist."""
|
||||
from server import diff
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=None)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await diff.fn(
|
||||
project_id="nonexistent",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_workspace_not_found(self):
|
||||
"""Test log when workspace doesn't exist."""
|
||||
from server import log
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=None)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await log.fn(
|
||||
project_id="nonexistent",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
class TestPROperations:
|
||||
"""Tests for pull request operation tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_no_repo_url(self):
|
||||
"""Test create PR when workspace has no repo URL."""
|
||||
from models import WorkspaceInfo, WorkspaceState
|
||||
from server import create_pull_request
|
||||
|
||||
mock_workspace = WorkspaceInfo(
|
||||
project_id="test-project",
|
||||
path="/tmp/test",
|
||||
state=WorkspaceState.READY,
|
||||
repo_url=None, # No repo URL
|
||||
)
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=mock_workspace)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await create_pull_request.fn(
|
||||
project_id="test-project",
|
||||
agent_id="agent-1",
|
||||
title="Test PR",
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "repository URL" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_prs_invalid_state(self):
|
||||
"""Test list PRs with invalid state filter."""
|
||||
from models import WorkspaceInfo, WorkspaceState
|
||||
from server import list_pull_requests
|
||||
|
||||
mock_workspace = WorkspaceInfo(
|
||||
project_id="test-project",
|
||||
path="/tmp/test",
|
||||
state=WorkspaceState.READY,
|
||||
repo_url="https://gitea.test.com/owner/repo.git",
|
||||
)
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=mock_workspace)
|
||||
|
||||
mock_provider = AsyncMock()
|
||||
mock_provider.parse_repo_url = MagicMock(return_value=("owner", "repo"))
|
||||
|
||||
with patch("server._workspace_manager", mock_manager), \
|
||||
patch("server._get_provider_for_url", return_value=mock_provider):
|
||||
result = await list_pull_requests.fn(
|
||||
project_id="test-project",
|
||||
agent_id="agent-1",
|
||||
state="invalid-state",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid state" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr_invalid_strategy(self):
|
||||
"""Test merge PR with invalid strategy."""
|
||||
from models import WorkspaceInfo, WorkspaceState
|
||||
from server import merge_pull_request
|
||||
|
||||
mock_workspace = WorkspaceInfo(
|
||||
project_id="test-project",
|
||||
path="/tmp/test",
|
||||
state=WorkspaceState.READY,
|
||||
repo_url="https://gitea.test.com/owner/repo.git",
|
||||
)
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=mock_workspace)
|
||||
|
||||
mock_provider = AsyncMock()
|
||||
mock_provider.parse_repo_url = MagicMock(return_value=("owner", "repo"))
|
||||
|
||||
with patch("server._workspace_manager", mock_manager), \
|
||||
patch("server._get_provider_for_url", return_value=mock_provider):
|
||||
result = await merge_pull_request.fn(
|
||||
project_id="test-project",
|
||||
agent_id="agent-1",
|
||||
pr_number=42,
|
||||
merge_strategy="invalid-strategy",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid strategy" in result["error"]
|
||||
|
||||
|
||||
class TestWorkspaceOperations:
|
||||
"""Tests for workspace operation tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace_not_found(self):
|
||||
"""Test get workspace when it doesn't exist."""
|
||||
from server import get_workspace
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.get_workspace = AsyncMock(return_value=None)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await get_workspace.fn(
|
||||
project_id="nonexistent",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_workspace_success(self):
|
||||
"""Test successful workspace locking."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from models import WorkspaceInfo, WorkspaceState
|
||||
from server import lock_workspace
|
||||
|
||||
mock_workspace = WorkspaceInfo(
|
||||
project_id="test-project",
|
||||
path="/tmp/test",
|
||||
state=WorkspaceState.LOCKED,
|
||||
lock_holder="agent-1",
|
||||
lock_expires=datetime.now(UTC) + timedelta(seconds=300),
|
||||
)
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.lock_workspace = AsyncMock(return_value=True)
|
||||
mock_manager.get_workspace = AsyncMock(return_value=mock_workspace)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await lock_workspace.fn(
|
||||
project_id="test-project",
|
||||
agent_id="agent-1",
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["lock_holder"] == "agent-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlock_workspace_success(self):
|
||||
"""Test successful workspace unlocking."""
|
||||
from server import unlock_workspace
|
||||
|
||||
mock_manager = AsyncMock()
|
||||
mock_manager.unlock_workspace = AsyncMock(return_value=True)
|
||||
|
||||
with patch("server._workspace_manager", mock_manager):
|
||||
result = await unlock_workspace.fn(
|
||||
project_id="test-project",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestJSONRPCEndpoint:
|
||||
"""Tests for the JSON-RPC endpoint."""
|
||||
|
||||
def test_python_type_to_json_schema_str(self):
|
||||
"""Test string type conversion."""
|
||||
from server import _python_type_to_json_schema
|
||||
|
||||
result = _python_type_to_json_schema(str)
|
||||
assert result["type"] == "string"
|
||||
|
||||
def test_python_type_to_json_schema_int(self):
|
||||
"""Test int type conversion."""
|
||||
from server import _python_type_to_json_schema
|
||||
|
||||
result = _python_type_to_json_schema(int)
|
||||
assert result["type"] == "integer"
|
||||
|
||||
def test_python_type_to_json_schema_bool(self):
|
||||
"""Test bool type conversion."""
|
||||
from server import _python_type_to_json_schema
|
||||
|
||||
result = _python_type_to_json_schema(bool)
|
||||
assert result["type"] == "boolean"
|
||||
|
||||
def test_python_type_to_json_schema_list(self):
|
||||
"""Test list type conversion."""
|
||||
|
||||
from server import _python_type_to_json_schema
|
||||
|
||||
result = _python_type_to_json_schema(list[str])
|
||||
assert result["type"] == "array"
|
||||
assert result["items"]["type"] == "string"
|
||||
334
mcp-servers/git-ops/tests/test_workspace.py
Normal file
334
mcp-servers/git-ops/tests/test_workspace.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Tests for the workspace management module.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from exceptions import WorkspaceLockedError, WorkspaceNotFoundError
|
||||
from models import WorkspaceState
|
||||
from workspace import FileLockManager, WorkspaceLock
|
||||
|
||||
|
||||
class TestWorkspaceManager:
|
||||
"""Tests for WorkspaceManager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace(self, workspace_manager, valid_project_id):
|
||||
"""Test creating a new workspace."""
|
||||
workspace = await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
assert workspace.project_id == valid_project_id
|
||||
assert workspace.state == WorkspaceState.INITIALIZING
|
||||
assert Path(workspace.path).exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_with_repo_url(self, workspace_manager, valid_project_id, sample_repo_url):
|
||||
"""Test creating workspace with repository URL."""
|
||||
workspace = await workspace_manager.create_workspace(
|
||||
valid_project_id, repo_url=sample_repo_url
|
||||
)
|
||||
|
||||
assert workspace.repo_url == sample_repo_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace(self, workspace_manager, valid_project_id):
|
||||
"""Test getting an existing workspace."""
|
||||
# Create first
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
# Get it
|
||||
workspace = await workspace_manager.get_workspace(valid_project_id)
|
||||
|
||||
assert workspace is not None
|
||||
assert workspace.project_id == valid_project_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace_not_found(self, workspace_manager):
|
||||
"""Test getting non-existent workspace."""
|
||||
workspace = await workspace_manager.get_workspace("nonexistent")
|
||||
assert workspace is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workspace(self, workspace_manager, valid_project_id):
|
||||
"""Test deleting a workspace."""
|
||||
# Create first
|
||||
workspace = await workspace_manager.create_workspace(valid_project_id)
|
||||
workspace_path = Path(workspace.path)
|
||||
assert workspace_path.exists()
|
||||
|
||||
# Delete
|
||||
result = await workspace_manager.delete_workspace(valid_project_id)
|
||||
|
||||
assert result is True
|
||||
assert not workspace_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_workspace(self, workspace_manager):
|
||||
"""Test deleting non-existent workspace returns True."""
|
||||
result = await workspace_manager.delete_workspace("nonexistent")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workspaces(self, workspace_manager):
|
||||
"""Test listing workspaces."""
|
||||
# Create multiple workspaces
|
||||
await workspace_manager.create_workspace("project-1")
|
||||
await workspace_manager.create_workspace("project-2")
|
||||
await workspace_manager.create_workspace("project-3")
|
||||
|
||||
workspaces = await workspace_manager.list_workspaces()
|
||||
|
||||
assert len(workspaces) >= 3
|
||||
project_ids = [w.project_id for w in workspaces]
|
||||
assert "project-1" in project_ids
|
||||
assert "project-2" in project_ids
|
||||
assert "project-3" in project_ids
|
||||
|
||||
|
||||
class TestWorkspaceLocking:
|
||||
"""Tests for workspace locking."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_workspace(self, workspace_manager, valid_project_id, valid_agent_id):
|
||||
"""Test locking a workspace."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
result = await workspace_manager.lock_workspace(
|
||||
valid_project_id, valid_agent_id, timeout=60
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
workspace = await workspace_manager.get_workspace(valid_project_id)
|
||||
assert workspace.state == WorkspaceState.LOCKED
|
||||
assert workspace.lock_holder == valid_agent_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_already_locked(self, workspace_manager, valid_project_id):
|
||||
"""Test locking already-locked workspace by different holder."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
await workspace_manager.lock_workspace(valid_project_id, "agent-1", timeout=60)
|
||||
|
||||
with pytest.raises(WorkspaceLockedError):
|
||||
await workspace_manager.lock_workspace(valid_project_id, "agent-2", timeout=60)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_same_holder(self, workspace_manager, valid_project_id, valid_agent_id):
|
||||
"""Test re-locking by same holder extends lock."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
await workspace_manager.lock_workspace(valid_project_id, valid_agent_id, timeout=60)
|
||||
|
||||
# Same holder can re-lock
|
||||
result = await workspace_manager.lock_workspace(
|
||||
valid_project_id, valid_agent_id, timeout=120
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlock_workspace(self, workspace_manager, valid_project_id, valid_agent_id):
|
||||
"""Test unlocking a workspace."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
await workspace_manager.lock_workspace(valid_project_id, valid_agent_id)
|
||||
|
||||
result = await workspace_manager.unlock_workspace(valid_project_id, valid_agent_id)
|
||||
|
||||
assert result is True
|
||||
workspace = await workspace_manager.get_workspace(valid_project_id)
|
||||
assert workspace.state == WorkspaceState.READY
|
||||
assert workspace.lock_holder is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlock_wrong_holder(self, workspace_manager, valid_project_id):
|
||||
"""Test unlock fails with wrong holder."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
await workspace_manager.lock_workspace(valid_project_id, "agent-1")
|
||||
|
||||
with pytest.raises(WorkspaceLockedError):
|
||||
await workspace_manager.unlock_workspace(valid_project_id, "agent-2")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_unlock(self, workspace_manager, valid_project_id):
|
||||
"""Test force unlock works regardless of holder."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
await workspace_manager.lock_workspace(valid_project_id, "agent-1")
|
||||
|
||||
result = await workspace_manager.unlock_workspace(
|
||||
valid_project_id, "admin", force=True
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_nonexistent_workspace(self, workspace_manager, valid_agent_id):
|
||||
"""Test locking non-existent workspace raises error."""
|
||||
with pytest.raises(WorkspaceNotFoundError):
|
||||
await workspace_manager.lock_workspace("nonexistent", valid_agent_id)
|
||||
|
||||
|
||||
class TestWorkspaceLockContextManager:
|
||||
"""Tests for WorkspaceLock context manager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_context_manager(self, workspace_manager, valid_project_id, valid_agent_id):
|
||||
"""Test using WorkspaceLock as context manager."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
async with WorkspaceLock(
|
||||
workspace_manager, valid_project_id, valid_agent_id
|
||||
) as lock:
|
||||
workspace = await workspace_manager.get_workspace(valid_project_id)
|
||||
assert workspace.state == WorkspaceState.LOCKED
|
||||
|
||||
# After exiting context, should be unlocked
|
||||
workspace = await workspace_manager.get_workspace(valid_project_id)
|
||||
assert workspace.lock_holder is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_context_manager_error(self, workspace_manager, valid_project_id, valid_agent_id):
|
||||
"""Test WorkspaceLock releases on exception."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
try:
|
||||
async with WorkspaceLock(
|
||||
workspace_manager, valid_project_id, valid_agent_id
|
||||
):
|
||||
raise ValueError("Test error")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
workspace = await workspace_manager.get_workspace(valid_project_id)
|
||||
assert workspace.lock_holder is None
|
||||
|
||||
|
||||
class TestWorkspaceMetadata:
|
||||
"""Tests for workspace metadata operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_touch_workspace(self, workspace_manager, valid_project_id):
|
||||
"""Test updating workspace access time."""
|
||||
workspace = await workspace_manager.create_workspace(valid_project_id)
|
||||
original_time = workspace.last_accessed
|
||||
|
||||
await workspace_manager.touch_workspace(valid_project_id)
|
||||
|
||||
updated = await workspace_manager.get_workspace(valid_project_id)
|
||||
assert updated.last_accessed >= original_time
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_workspace_branch(self, workspace_manager, valid_project_id):
|
||||
"""Test updating workspace branch."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
await workspace_manager.update_workspace_branch(valid_project_id, "feature-branch")
|
||||
|
||||
workspace = await workspace_manager.get_workspace(valid_project_id)
|
||||
assert workspace.current_branch == "feature-branch"
|
||||
|
||||
|
||||
class TestWorkspaceSize:
|
||||
"""Tests for workspace size management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_size_within_limit(self, workspace_manager, valid_project_id):
|
||||
"""Test size check passes for small workspace."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
# Should not raise
|
||||
result = await workspace_manager.check_size_limit(valid_project_id)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_total_size(self, workspace_manager, valid_project_id):
|
||||
"""Test getting total workspace size."""
|
||||
workspace = await workspace_manager.create_workspace(valid_project_id)
|
||||
|
||||
# Add some content
|
||||
content_file = Path(workspace.path) / "content.txt"
|
||||
content_file.write_text("x" * 1000)
|
||||
|
||||
total_size = await workspace_manager.get_total_size()
|
||||
assert total_size >= 1000
|
||||
|
||||
|
||||
class TestFileLockManager:
|
||||
"""Tests for file-based locking."""
|
||||
|
||||
def test_acquire_lock(self, temp_dir):
|
||||
"""Test acquiring a file lock."""
|
||||
manager = FileLockManager(temp_dir / "locks")
|
||||
|
||||
result = manager.acquire("test-key")
|
||||
assert result is True
|
||||
|
||||
# Cleanup
|
||||
manager.release("test-key")
|
||||
|
||||
def test_release_lock(self, temp_dir):
|
||||
"""Test releasing a file lock."""
|
||||
manager = FileLockManager(temp_dir / "locks")
|
||||
manager.acquire("test-key")
|
||||
|
||||
result = manager.release("test-key")
|
||||
assert result is True
|
||||
|
||||
def test_is_locked(self, temp_dir):
|
||||
"""Test checking if locked."""
|
||||
manager = FileLockManager(temp_dir / "locks")
|
||||
|
||||
assert manager.is_locked("test-key") is False
|
||||
|
||||
manager.acquire("test-key")
|
||||
assert manager.is_locked("test-key") is True
|
||||
|
||||
manager.release("test-key")
|
||||
|
||||
def test_release_nonexistent_lock(self, temp_dir):
|
||||
"""Test releasing a lock that doesn't exist."""
|
||||
manager = FileLockManager(temp_dir / "locks")
|
||||
|
||||
# Should not raise
|
||||
result = manager.release("nonexistent")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestWorkspaceCleanup:
|
||||
"""Tests for workspace cleanup operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_stale_workspaces(self, workspace_manager, test_settings):
|
||||
"""Test cleaning up stale workspaces."""
|
||||
# Create workspace
|
||||
workspace = await workspace_manager.create_workspace("stale-project")
|
||||
|
||||
# Manually set it as stale by updating metadata
|
||||
await workspace_manager._update_metadata(
|
||||
"stale-project",
|
||||
last_accessed=(datetime.now(UTC) - timedelta(days=30)).isoformat(),
|
||||
)
|
||||
|
||||
# Run cleanup
|
||||
cleaned = await workspace_manager.cleanup_stale_workspaces()
|
||||
|
||||
assert cleaned >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_locked_workspace_blocked(self, workspace_manager, valid_project_id, valid_agent_id):
|
||||
"""Test deleting locked workspace is blocked without force."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
await workspace_manager.lock_workspace(valid_project_id, valid_agent_id)
|
||||
|
||||
with pytest.raises(WorkspaceLockedError):
|
||||
await workspace_manager.delete_workspace(valid_project_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_locked_workspace_force(self, workspace_manager, valid_project_id, valid_agent_id):
|
||||
"""Test force deleting locked workspace."""
|
||||
await workspace_manager.create_workspace(valid_project_id)
|
||||
await workspace_manager.lock_workspace(valid_project_id, valid_agent_id)
|
||||
|
||||
result = await workspace_manager.delete_workspace(valid_project_id, force=True)
|
||||
assert result is True
|
||||
Reference in New Issue
Block a user