first commit
This commit is contained in:
269
tests/test_workspace.py
Normal file
269
tests/test_workspace.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Tests for WorkspaceManager."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import docker as docker_mod
|
||||
import git
|
||||
import pytest
|
||||
|
||||
from app_factory.core.workspace import (
|
||||
DockerProvisionError,
|
||||
GitWorktreeError,
|
||||
WorkspaceError,
|
||||
WorkspaceManager,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_git_repo(tmp_path):
|
||||
"""Create a real temporary git repository."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
repo = git.Repo.init(repo_dir)
|
||||
readme = repo_dir / "README.md"
|
||||
readme.write_text("# Test Repo")
|
||||
repo.index.add(["README.md"])
|
||||
repo.index.commit("Initial commit")
|
||||
if repo.active_branch.name != "main":
|
||||
repo.git.branch("-M", "main")
|
||||
return str(repo_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client():
|
||||
"""Create a mock Docker client."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.images.pull.return_value = MagicMock()
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "abc123container"
|
||||
mock_client.containers.create.return_value = mock_container
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_manager(temp_git_repo, mock_docker_client):
|
||||
"""Create a WorkspaceManager with a real git repo and mocked Docker."""
|
||||
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
|
||||
wm = WorkspaceManager(temp_git_repo)
|
||||
return wm
|
||||
|
||||
|
||||
class TestWorkspaceManagerInit:
|
||||
def test_init_valid_repo(self, temp_git_repo, mock_docker_client):
|
||||
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
|
||||
wm = WorkspaceManager(temp_git_repo)
|
||||
assert wm.repo_path == Path(temp_git_repo).resolve()
|
||||
assert wm.docker_image == "python:3.11-slim"
|
||||
assert wm.active_workspaces == {}
|
||||
|
||||
def test_init_custom_image(self, temp_git_repo, mock_docker_client):
|
||||
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
|
||||
wm = WorkspaceManager(temp_git_repo, docker_image="node:20-slim")
|
||||
assert wm.docker_image == "node:20-slim"
|
||||
|
||||
def test_init_invalid_repo(self, tmp_path, mock_docker_client):
|
||||
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
|
||||
with pytest.raises(GitWorktreeError, match="Invalid git repository"):
|
||||
WorkspaceManager(str(tmp_path))
|
||||
|
||||
def test_init_nonexistent_path(self, mock_docker_client):
|
||||
with patch("app_factory.core.workspace.docker.from_env", return_value=mock_docker_client):
|
||||
with pytest.raises(GitWorktreeError, match="Repository path not found"):
|
||||
WorkspaceManager("/nonexistent/path/to/repo")
|
||||
|
||||
def test_init_docker_unavailable(self, temp_git_repo):
|
||||
with patch(
|
||||
"app_factory.core.workspace.docker.from_env",
|
||||
side_effect=docker_mod.errors.DockerException("Connection refused"),
|
||||
):
|
||||
with pytest.raises(DockerProvisionError, match="Failed to connect to Docker"):
|
||||
WorkspaceManager(temp_git_repo)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateWorktree:
|
||||
async def test_create_worktree_success(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
path = await wm.create_worktree("task-001")
|
||||
assert os.path.isdir(path)
|
||||
assert "task-001" in path
|
||||
assert "feature/task-task-001" in [b.name for b in wm.repo.branches]
|
||||
# Cleanup
|
||||
wm.repo.git.worktree("remove", path, "--force")
|
||||
|
||||
async def test_create_worktree_invalid_base_branch(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
with pytest.raises(GitWorktreeError, match="does not exist"):
|
||||
await wm.create_worktree("task-002", base_branch="nonexistent")
|
||||
|
||||
async def test_create_worktree_branch_already_exists(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
wm.repo.git.branch("feature/task-task-003")
|
||||
with pytest.raises(GitWorktreeError, match="Branch already exists"):
|
||||
await wm.create_worktree("task-003")
|
||||
|
||||
async def test_create_worktree_path_already_exists(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
worktree_dir = wm.repo_path.parent / "worktrees" / "task-004"
|
||||
worktree_dir.mkdir(parents=True)
|
||||
with pytest.raises(GitWorktreeError, match="Worktree path already exists"):
|
||||
await wm.create_worktree("task-004")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSpinUpCleanRoom:
|
||||
async def test_spin_up_clean_room_success(self, workspace_manager, mock_docker_client):
|
||||
wm = workspace_manager
|
||||
container = await wm.spin_up_clean_room("/tmp/fake_worktree", "task-010")
|
||||
|
||||
assert container.id == "abc123container"
|
||||
mock_docker_client.images.pull.assert_called_once_with("python:3.11-slim")
|
||||
mock_docker_client.containers.create.assert_called_once_with(
|
||||
image="python:3.11-slim",
|
||||
name="appfactory-task-task-010",
|
||||
volumes={"/tmp/fake_worktree": {"bind": "/workspace", "mode": "rw"}},
|
||||
working_dir="/workspace",
|
||||
network_mode="none",
|
||||
auto_remove=False,
|
||||
detach=True,
|
||||
command="sleep infinity",
|
||||
)
|
||||
assert "task-010" in wm.active_workspaces
|
||||
info = wm.active_workspaces["task-010"]
|
||||
assert info["worktree_path"] == "/tmp/fake_worktree"
|
||||
assert info["container_id"] == "abc123container"
|
||||
|
||||
async def test_spin_up_image_pull_failure(self, workspace_manager, mock_docker_client):
|
||||
wm = workspace_manager
|
||||
mock_docker_client.images.pull.side_effect = docker_mod.errors.APIError("pull failed")
|
||||
with pytest.raises(DockerProvisionError, match="Failed to pull image"):
|
||||
await wm.spin_up_clean_room("/tmp/fake", "task-011")
|
||||
|
||||
async def test_spin_up_container_create_failure(self, workspace_manager, mock_docker_client):
|
||||
wm = workspace_manager
|
||||
mock_docker_client.containers.create.side_effect = docker_mod.errors.APIError("create failed")
|
||||
with pytest.raises(DockerProvisionError, match="Failed to create container"):
|
||||
await wm.spin_up_clean_room("/tmp/fake", "task-012")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCleanupWorkspace:
|
||||
async def test_cleanup_workspace_success(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
path = await wm.create_worktree("task-020")
|
||||
mock_container = MagicMock()
|
||||
wm.active_workspaces["task-020"] = {
|
||||
"task_id": "task-020",
|
||||
"worktree_path": path,
|
||||
"container_id": "cont123",
|
||||
"container": mock_container,
|
||||
}
|
||||
|
||||
await wm.cleanup_workspace("task-020")
|
||||
|
||||
mock_container.stop.assert_called_once_with(timeout=5)
|
||||
mock_container.remove.assert_called_once_with(force=True)
|
||||
assert "task-020" not in wm.active_workspaces
|
||||
assert not os.path.exists(path)
|
||||
|
||||
async def test_cleanup_already_stopped_container(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
mock_container = MagicMock()
|
||||
mock_container.stop.side_effect = Exception("already stopped")
|
||||
mock_container.remove.return_value = None
|
||||
|
||||
wm.active_workspaces["task-021"] = {
|
||||
"task_id": "task-021",
|
||||
"worktree_path": "/tmp/nonexistent",
|
||||
"container_id": "cont456",
|
||||
"container": mock_container,
|
||||
}
|
||||
|
||||
# Should not raise even if stop fails
|
||||
await wm.cleanup_workspace("task-021")
|
||||
assert "task-021" not in wm.active_workspaces
|
||||
|
||||
async def test_cleanup_no_container(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
path = await wm.create_worktree("task-022")
|
||||
wm.active_workspaces["task-022"] = {
|
||||
"task_id": "task-022",
|
||||
"worktree_path": path,
|
||||
"container_id": None,
|
||||
"container": None,
|
||||
}
|
||||
await wm.cleanup_workspace("task-022")
|
||||
assert "task-022" not in wm.active_workspaces
|
||||
|
||||
|
||||
class TestGetActiveWorkspaces:
|
||||
def test_get_empty(self, workspace_manager):
|
||||
assert workspace_manager.get_active_workspaces() == []
|
||||
|
||||
def test_get_with_workspaces(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
wm.active_workspaces["t1"] = {
|
||||
"task_id": "t1",
|
||||
"worktree_path": "/path/t1",
|
||||
"container_id": "c1",
|
||||
"container": MagicMock(),
|
||||
}
|
||||
wm.active_workspaces["t2"] = {
|
||||
"task_id": "t2",
|
||||
"worktree_path": "/path/t2",
|
||||
"container_id": "c2",
|
||||
"container": MagicMock(),
|
||||
}
|
||||
|
||||
result = wm.get_active_workspaces()
|
||||
assert len(result) == 2
|
||||
assert {"task_id": "t1", "worktree_path": "/path/t1", "container_id": "c1"} in result
|
||||
assert {"task_id": "t2", "worktree_path": "/path/t2", "container_id": "c2"} in result
|
||||
for item in result:
|
||||
assert "container" not in item
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCleanupAll:
|
||||
async def test_cleanup_all_success(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
mock_c1 = MagicMock()
|
||||
mock_c2 = MagicMock()
|
||||
wm.active_workspaces["t1"] = {
|
||||
"task_id": "t1",
|
||||
"worktree_path": "/tmp/nonexistent1",
|
||||
"container_id": "c1",
|
||||
"container": mock_c1,
|
||||
}
|
||||
wm.active_workspaces["t2"] = {
|
||||
"task_id": "t2",
|
||||
"worktree_path": "/tmp/nonexistent2",
|
||||
"container_id": "c2",
|
||||
"container": mock_c2,
|
||||
}
|
||||
|
||||
await wm.cleanup_all()
|
||||
|
||||
mock_c1.stop.assert_called_once()
|
||||
mock_c2.stop.assert_called_once()
|
||||
assert wm.active_workspaces == {}
|
||||
|
||||
async def test_cleanup_all_with_errors(self, workspace_manager):
|
||||
wm = workspace_manager
|
||||
mock_c1 = MagicMock()
|
||||
mock_c1.stop.side_effect = Exception("stop failed")
|
||||
mock_c1.remove.side_effect = Exception("remove failed")
|
||||
|
||||
wm.active_workspaces["t1"] = {
|
||||
"task_id": "t1",
|
||||
"worktree_path": "/tmp/nonexistent",
|
||||
"container_id": "c1",
|
||||
"container": mock_c1,
|
||||
}
|
||||
|
||||
with pytest.raises(WorkspaceError, match="Cleanup all completed with errors"):
|
||||
await wm.cleanup_all()
|
||||
|
||||
assert wm.active_workspaces == {}
|
||||
Reference in New Issue
Block a user