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:
2026-01-06 20:48:20 +01:00
parent 4ad3d20cf2
commit 9dfa76aa41
19 changed files with 9544 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Tests for Git Operations MCP Server."""

View 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"

View 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

View 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

View 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"

View 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