feat: add repository configuration system with defensive validation

- Add archon_configured_repositories table migration with production-ready sandbox type constraints
- Implement SupabaseWorkOrderRepository for CRUD operations with comprehensive error handling
- Add defensive validation in _row_to_model with detailed logging for invalid enum values
- Implement granular exception handling (409 duplicates, 422 validation, 502 GitHub API errors)
- Document async/await pattern for interface consistency across repository implementations
- Add Supabase health check to verify table existence
- Expand test coverage from 10 to 17 tests with error handling and edge case validation
- Add supabase dependency to agent-work-orders group
- Enable ENABLE_AGENT_WORK_ORDERS flag in docker-compose for production deployment
This commit is contained in:
Rasmus Widing
2025-10-24 20:01:15 +03:00
parent 6a8e784aab
commit 71393520dc
9 changed files with 1449 additions and 0 deletions

View File

@@ -0,0 +1,454 @@
"""Unit Tests for RepositoryConfigRepository
Tests all CRUD operations for configured repositories.
"""
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from src.agent_work_orders.models import ConfiguredRepository, SandboxType, WorkflowStep
from src.agent_work_orders.state_manager.repository_config_repository import RepositoryConfigRepository
@pytest.fixture
def mock_supabase_client():
"""Mock Supabase client with chainable methods"""
mock = MagicMock()
# Set up method chaining: table().select().order().execute()
mock.table.return_value = mock
mock.select.return_value = mock
mock.order.return_value = mock
mock.insert.return_value = mock
mock.update.return_value = mock
mock.delete.return_value = mock
mock.eq.return_value = mock
# Execute returns response with data attribute
mock.execute.return_value = MagicMock(data=[])
return mock
@pytest.fixture
def repository_instance(mock_supabase_client):
"""Create RepositoryConfigRepository instance with mocked client"""
with patch('src.agent_work_orders.state_manager.repository_config_repository.get_supabase_client', return_value=mock_supabase_client):
return RepositoryConfigRepository()
@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_repositories_returns_all_repositories(repository_instance, mock_supabase_client):
"""Test listing all repositories"""
# Mock response data
mock_data = [
{
"id": "repo-1",
"repository_url": "https://github.com/test/repo1",
"display_name": "test/repo1",
"owner": "test",
"default_branch": "main",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch", "planning", "execute"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
# Call method
repositories = await repository_instance.list_repositories()
# Assertions
assert len(repositories) == 1
assert isinstance(repositories[0], ConfiguredRepository)
assert repositories[0].id == "repo-1"
assert repositories[0].repository_url == "https://github.com/test/repo1"
# Verify Supabase client methods called correctly
mock_supabase_client.table.assert_called_once_with("archon_configured_repositories")
mock_supabase_client.select.assert_called_once()
@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_repositories_with_empty_result(repository_instance, mock_supabase_client):
"""Test listing repositories when database is empty"""
mock_supabase_client.execute.return_value = MagicMock(data=[])
repositories = await repository_instance.list_repositories()
assert repositories == []
assert isinstance(repositories, list)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_repository_success(repository_instance, mock_supabase_client):
"""Test getting a single repository by ID"""
mock_data = [{
"id": "repo-1",
"repository_url": "https://github.com/test/repo1",
"display_name": "test/repo1",
"owner": "test",
"default_branch": "main",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch", "planning"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repository = await repository_instance.get_repository("repo-1")
assert repository is not None
assert isinstance(repository, ConfiguredRepository)
assert repository.id == "repo-1"
mock_supabase_client.eq.assert_called_with("id", "repo-1")
@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_repository_not_found(repository_instance, mock_supabase_client):
"""Test getting a repository that doesn't exist"""
mock_supabase_client.execute.return_value = MagicMock(data=[])
repository = await repository_instance.get_repository("nonexistent-id")
assert repository is None
@pytest.mark.unit
@pytest.mark.asyncio
async def test_create_repository_success(repository_instance, mock_supabase_client):
"""Test creating a new repository"""
mock_data = [{
"id": "new-repo-id",
"repository_url": "https://github.com/test/newrepo",
"display_name": "test/newrepo",
"owner": "test",
"default_branch": "main",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch", "planning", "execute", "commit", "create-pr"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repository = await repository_instance.create_repository(
repository_url="https://github.com/test/newrepo",
display_name="test/newrepo",
owner="test",
default_branch="main",
is_verified=True,
)
assert repository is not None
assert repository.id == "new-repo-id"
assert repository.repository_url == "https://github.com/test/newrepo"
assert repository.is_verified is True
mock_supabase_client.insert.assert_called_once()
@pytest.mark.unit
@pytest.mark.asyncio
async def test_create_repository_with_verification(repository_instance, mock_supabase_client):
"""Test creating a repository with is_verified=True sets last_verified_at"""
mock_data = [{
"id": "verified-repo",
"repository_url": "https://github.com/test/verified",
"display_name": None,
"owner": None,
"default_branch": None,
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch", "planning", "execute", "commit", "create-pr"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repository = await repository_instance.create_repository(
repository_url="https://github.com/test/verified",
is_verified=True,
)
assert repository.is_verified is True
assert repository.last_verified_at is not None
@pytest.mark.unit
@pytest.mark.asyncio
async def test_update_repository_success(repository_instance, mock_supabase_client):
"""Test updating a repository"""
mock_data = [{
"id": "repo-1",
"repository_url": "https://github.com/test/repo1",
"display_name": "test/repo1",
"owner": "test",
"default_branch": "main",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_branch", # Updated value (valid enum)
"default_commands": ["create-branch", "execute"], # Updated value
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repository = await repository_instance.update_repository(
"repo-1",
default_sandbox_type=SandboxType.GIT_BRANCH,
default_commands=[WorkflowStep.CREATE_BRANCH, WorkflowStep.EXECUTE],
)
assert repository is not None
assert repository.id == "repo-1"
mock_supabase_client.update.assert_called_once()
mock_supabase_client.eq.assert_called_with("id", "repo-1")
@pytest.mark.unit
@pytest.mark.asyncio
async def test_update_repository_not_found(repository_instance, mock_supabase_client):
"""Test updating a repository that doesn't exist"""
mock_supabase_client.execute.return_value = MagicMock(data=[])
repository = await repository_instance.update_repository(
"nonexistent-id",
default_sandbox_type=SandboxType.GIT_WORKTREE,
)
assert repository is None
@pytest.mark.unit
@pytest.mark.asyncio
async def test_delete_repository_success(repository_instance, mock_supabase_client):
"""Test deleting a repository"""
mock_data = [{"id": "repo-1"}] # Supabase returns deleted row
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
deleted = await repository_instance.delete_repository("repo-1")
assert deleted is True
mock_supabase_client.delete.assert_called_once()
mock_supabase_client.eq.assert_called_with("id", "repo-1")
@pytest.mark.unit
@pytest.mark.asyncio
async def test_delete_repository_not_found(repository_instance, mock_supabase_client):
"""Test deleting a repository that doesn't exist"""
mock_supabase_client.execute.return_value = MagicMock(data=[])
deleted = await repository_instance.delete_repository("nonexistent-id")
assert deleted is False
# =====================================================
# Additional Error Handling Tests
# =====================================================
@pytest.mark.unit
@pytest.mark.asyncio
async def test_row_to_model_with_invalid_workflow_step(repository_instance):
"""Test _row_to_model raises ValueError for invalid workflow step"""
invalid_row = {
"id": "test-id",
"repository_url": "https://github.com/test/repo",
"display_name": "test/repo",
"owner": "test",
"default_branch": "main",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_worktree",
"default_commands": ["invalid-command", "planning"], # Invalid command
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
with pytest.raises(ValueError) as exc_info:
repository_instance._row_to_model(invalid_row)
assert "invalid workflow steps" in str(exc_info.value).lower()
assert "test-id" in str(exc_info.value)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_row_to_model_with_invalid_sandbox_type(repository_instance):
"""Test _row_to_model raises ValueError for invalid sandbox type"""
invalid_row = {
"id": "test-id",
"repository_url": "https://github.com/test/repo",
"display_name": "test/repo",
"owner": "test",
"default_branch": "main",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "invalid_type", # Invalid type
"default_commands": ["create-branch", "planning"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
with pytest.raises(ValueError) as exc_info:
repository_instance._row_to_model(invalid_row)
assert "invalid sandbox type" in str(exc_info.value).lower()
assert "test-id" in str(exc_info.value)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_create_repository_with_all_fields(repository_instance, mock_supabase_client):
"""Test creating a repository with all optional fields populated"""
mock_data = [{
"id": "full-repo-id",
"repository_url": "https://github.com/test/fullrepo",
"display_name": "test/fullrepo",
"owner": "test",
"default_branch": "develop",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch", "planning", "execute", "commit", "create-pr"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repository = await repository_instance.create_repository(
repository_url="https://github.com/test/fullrepo",
display_name="test/fullrepo",
owner="test",
default_branch="develop",
is_verified=True,
)
assert repository.id == "full-repo-id"
assert repository.display_name == "test/fullrepo"
assert repository.owner == "test"
assert repository.default_branch == "develop"
assert repository.is_verified is True
assert repository.last_verified_at is not None
@pytest.mark.unit
@pytest.mark.asyncio
async def test_update_repository_with_multiple_fields(repository_instance, mock_supabase_client):
"""Test updating repository with multiple fields at once"""
mock_data = [{
"id": "repo-1",
"repository_url": "https://github.com/test/repo1",
"display_name": "updated-name",
"owner": "updated-owner",
"default_branch": "updated-branch",
"is_verified": True,
"last_verified_at": datetime.now().isoformat(),
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repository = await repository_instance.update_repository(
"repo-1",
display_name="updated-name",
owner="updated-owner",
default_branch="updated-branch",
is_verified=True,
)
assert repository is not None
assert repository.display_name == "updated-name"
assert repository.owner == "updated-owner"
assert repository.default_branch == "updated-branch"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_repositories_with_multiple_items(repository_instance, mock_supabase_client):
"""Test listing multiple repositories"""
mock_data = [
{
"id": f"repo-{i}",
"repository_url": f"https://github.com/test/repo{i}",
"display_name": f"test/repo{i}",
"owner": "test",
"default_branch": "main",
"is_verified": i % 2 == 0, # Alternate verified status
"last_verified_at": datetime.now().isoformat() if i % 2 == 0 else None,
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch", "planning", "execute"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
for i in range(5)
]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repositories = await repository_instance.list_repositories()
assert len(repositories) == 5
assert all(isinstance(repo, ConfiguredRepository) for repo in repositories)
# Check verification status alternates
assert repositories[0].is_verified is True
assert repositories[1].is_verified is False
@pytest.mark.unit
@pytest.mark.asyncio
async def test_create_repository_database_error(repository_instance, mock_supabase_client):
"""Test create_repository handles database errors properly"""
mock_supabase_client.execute.side_effect = Exception("Database connection failed")
with pytest.raises(Exception) as exc_info:
await repository_instance.create_repository(
repository_url="https://github.com/test/repo",
is_verified=False,
)
assert "Database connection failed" in str(exc_info.value)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_repository_with_minimal_data(repository_instance, mock_supabase_client):
"""Test getting repository with minimal fields (all optionals null)"""
mock_data = [{
"id": "minimal-repo",
"repository_url": "https://github.com/test/minimal",
"display_name": None,
"owner": None,
"default_branch": None,
"is_verified": False,
"last_verified_at": None,
"default_sandbox_type": "git_worktree",
"default_commands": ["create-branch"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}]
mock_supabase_client.execute.return_value = MagicMock(data=mock_data)
repository = await repository_instance.get_repository("minimal-repo")
assert repository is not None
assert repository.display_name is None
assert repository.owner is None
assert repository.default_branch is None
assert repository.is_verified is False
assert repository.last_verified_at is None