Implement comprehensive issue management system with pluggable backend support: ARCHITECTURE: - Abstract IssueBackend base class with standardized interface - Plugin discovery and configuration management system - Unified CLI integration with markitect issues commands BACKENDS IMPLEMENTED: - Gitea plugin: Integrates with existing GiteaIssueRepository infrastructure - Local plugin: File-based issue management with markdown + YAML frontmatter CLI COMMANDS: - markitect issues list [--state open|closed|all] [--backend name] - markitect issues show <id> [--backend name] - markitect issues create <title> <body> [--backend name] - markitect issues close <id> [--backend name] - markitect issues comment <id> <text> [--backend name] CONFIGURATION: - YAML-based backend configuration (.markitect/config/issues.yml) - Default backends: gitea (remote) and local (file-based) - Seamless backend switching via CLI options LOCAL FILE STRUCTURE: - .markitect/issues/open/ - Active issues as markdown files - .markitect/issues/closed/ - Completed issues - YAML frontmatter with issue metadata + markdown body - Git integration for version control of local issues TESTING: - Comprehensive test suite for plugin manager (15/17 tests passing) - Plugin interface validation and error handling - CLI integration tests (functional verification complete) This addresses the original problem where Claude sometimes missed existing issue functions and tried direct API calls. Now provides consistent, unified interface regardless of backend. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
466 lines
17 KiB
Python
466 lines
17 KiB
Python
"""
|
|
Tests for Issue #59 - Gitea Plugin Implementation
|
|
|
|
This module contains tests for the Gitea backend plugin that integrates
|
|
with the existing GiteaIssueRepository infrastructure.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, patch, AsyncMock
|
|
from typing import List, Dict, Any
|
|
|
|
# Import classes we'll implement
|
|
# Note: These imports will fail initially (RED phase)
|
|
from markitect.issues.plugins.gitea import GiteaPlugin
|
|
from markitect.issues.base import IssueBackend
|
|
from domain.issues.models import Issue
|
|
from infrastructure.repositories.gitea_repository import GiteaIssueRepository
|
|
|
|
|
|
class TestGiteaPluginInitialization:
|
|
"""Test suite for Gitea plugin initialization and configuration."""
|
|
|
|
def test_gitea_plugin_inherits_from_issue_backend(self):
|
|
"""Test that GiteaPlugin properly inherits from IssueBackend."""
|
|
config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
plugin = GiteaPlugin(config)
|
|
|
|
assert isinstance(plugin, IssueBackend)
|
|
|
|
def test_gitea_plugin_accepts_configuration(self):
|
|
"""Test that GiteaPlugin accepts and stores configuration."""
|
|
config = {
|
|
'url': 'http://gitea.example.com',
|
|
'repo': 'owner/repository',
|
|
'token_env': 'GITEA_TOKEN'
|
|
}
|
|
|
|
plugin = GiteaPlugin(config)
|
|
|
|
assert plugin.config == config
|
|
|
|
def test_gitea_plugin_initializes_repository(self):
|
|
"""Test that GiteaPlugin properly initializes underlying repository."""
|
|
config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
with patch('markitect.issues.plugins.gitea.GiteaIssueRepository') as mock_repo_class:
|
|
plugin = GiteaPlugin(config)
|
|
|
|
# Should initialize repository with config
|
|
mock_repo_class.assert_called_once()
|
|
|
|
def test_gitea_plugin_handles_missing_config_gracefully(self):
|
|
"""Test that GiteaPlugin handles missing configuration parameters."""
|
|
config = {} # Empty config
|
|
|
|
# Should not raise errors, but may use defaults
|
|
plugin = GiteaPlugin(config)
|
|
assert plugin is not None
|
|
|
|
def test_gitea_plugin_validates_required_config_parameters(self):
|
|
"""Test that GiteaPlugin validates required configuration parameters."""
|
|
# This will be implemented when we add config validation
|
|
pass
|
|
|
|
|
|
class TestGiteaPluginListIssues:
|
|
"""Test suite for listing issues through Gitea plugin."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_list_all_issues(self, mock_repo_class):
|
|
"""Test listing all issues regardless of state."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issues = AsyncMock()
|
|
|
|
# Mock issues data
|
|
mock_issues = [Mock(spec=Issue), Mock(spec=Issue)]
|
|
mock_repo.get_issues.return_value = mock_issues
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
# Use asyncio.run in actual implementation
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = mock_issues
|
|
issues = plugin.list_issues(state='all')
|
|
|
|
assert len(issues) == 2
|
|
assert all(isinstance(issue, Mock) for issue in issues)
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_list_open_issues_only(self, mock_repo_class):
|
|
"""Test listing only open issues."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issues = AsyncMock()
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = []
|
|
plugin.list_issues(state='open')
|
|
|
|
# Verify repository was called with correct state filter
|
|
mock_run.assert_called_once()
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_list_closed_issues_only(self, mock_repo_class):
|
|
"""Test listing only closed issues."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issues = AsyncMock()
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = []
|
|
plugin.list_issues(state='closed')
|
|
|
|
mock_run.assert_called_once()
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_list_issues_handles_repository_errors(self, mock_repo_class):
|
|
"""Test that list_issues handles repository errors gracefully."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issues = AsyncMock(side_effect=Exception("API Error"))
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run', side_effect=Exception("API Error")):
|
|
with pytest.raises(Exception):
|
|
plugin.list_issues()
|
|
|
|
|
|
class TestGiteaPluginGetIssue:
|
|
"""Test suite for getting individual issues through Gitea plugin."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_get_specific_issue_by_id(self, mock_repo_class):
|
|
"""Test getting a specific issue by ID."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issue = AsyncMock()
|
|
|
|
mock_issue = Mock(spec=Issue)
|
|
mock_issue.number = 59
|
|
mock_repo.get_issue.return_value = mock_issue
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = mock_issue
|
|
issue = plugin.get_issue('59')
|
|
|
|
assert issue == mock_issue
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_get_issue_converts_string_id_to_int(self, mock_repo_class):
|
|
"""Test that get_issue properly converts string IDs to integers."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issue = AsyncMock()
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = Mock()
|
|
plugin.get_issue('59')
|
|
|
|
# Verify repository was called with integer
|
|
# This will be verified in the actual async call
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_get_nonexistent_issue_raises_error(self, mock_repo_class):
|
|
"""Test that getting non-existent issue raises appropriate error."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issue = AsyncMock(side_effect=Exception("Issue not found"))
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run', side_effect=Exception("Issue not found")):
|
|
with pytest.raises(Exception):
|
|
plugin.get_issue('999999')
|
|
|
|
|
|
class TestGiteaPluginCreateIssue:
|
|
"""Test suite for creating issues through Gitea plugin."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_create_issue_with_title_and_body(self, mock_repo_class):
|
|
"""Test creating an issue with title and body."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.create_issue = AsyncMock()
|
|
|
|
mock_created_issue = Mock(spec=Issue)
|
|
mock_created_issue.number = 60
|
|
mock_repo.create_issue.return_value = mock_created_issue
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = mock_created_issue
|
|
issue = plugin.create_issue('Test Title', 'Test Body')
|
|
|
|
assert issue == mock_created_issue
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_create_issue_with_additional_kwargs(self, mock_repo_class):
|
|
"""Test creating an issue with additional keyword arguments."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.create_issue = AsyncMock()
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = Mock()
|
|
plugin.create_issue('Title', 'Body', labels=['bug', 'priority:high'])
|
|
|
|
# Additional kwargs should be passed through to repository
|
|
mock_run.assert_called_once()
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_create_issue_handles_validation_errors(self, mock_repo_class):
|
|
"""Test that create_issue handles validation errors appropriately."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.create_issue = AsyncMock(side_effect=ValueError("Invalid title"))
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run', side_effect=ValueError("Invalid title")):
|
|
with pytest.raises(ValueError):
|
|
plugin.create_issue('', 'Body') # Empty title
|
|
|
|
|
|
class TestGiteaPluginUpdateIssue:
|
|
"""Test suite for updating issues through Gitea plugin."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_update_issue_title(self, mock_repo_class):
|
|
"""Test updating an issue's title."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.update_issue = AsyncMock()
|
|
|
|
mock_updated_issue = Mock(spec=Issue)
|
|
mock_repo.update_issue.return_value = mock_updated_issue
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = mock_updated_issue
|
|
issue = plugin.update_issue('59', title='New Title')
|
|
|
|
assert issue == mock_updated_issue
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_update_issue_body(self, mock_repo_class):
|
|
"""Test updating an issue's body."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.update_issue = AsyncMock()
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = Mock()
|
|
plugin.update_issue('59', body='New body content')
|
|
|
|
mock_run.assert_called_once()
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_update_issue_multiple_fields(self, mock_repo_class):
|
|
"""Test updating multiple issue fields simultaneously."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.update_issue = AsyncMock()
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = Mock()
|
|
plugin.update_issue('59', title='New Title', body='New Body', state='closed')
|
|
|
|
mock_run.assert_called_once()
|
|
|
|
|
|
class TestGiteaPluginCommentOperations:
|
|
"""Test suite for comment operations through Gitea plugin."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_add_comment_to_issue(self, mock_repo_class):
|
|
"""Test adding a comment to an issue."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
|
|
# Mock the comment addition method (may need to be added to repository)
|
|
mock_comment_result = {'id': 123, 'body': 'Test comment'}
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch.object(plugin, '_add_comment_async') as mock_add:
|
|
mock_add.return_value = mock_comment_result
|
|
result = plugin.add_comment('59', 'Test comment')
|
|
|
|
assert result == mock_comment_result
|
|
|
|
def test_add_comment_validates_input(self):
|
|
"""Test that add_comment validates input parameters."""
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
# Test empty comment
|
|
with pytest.raises(ValueError):
|
|
plugin.add_comment('59', '')
|
|
|
|
# Test invalid issue ID
|
|
with pytest.raises(ValueError):
|
|
plugin.add_comment('', 'Valid comment')
|
|
|
|
|
|
class TestGiteaPluginCloseIssue:
|
|
"""Test suite for closing issues through Gitea plugin."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_close_issue_updates_state(self, mock_repo_class):
|
|
"""Test that closing an issue updates its state to closed."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.update_issue = AsyncMock()
|
|
|
|
mock_closed_issue = Mock(spec=Issue)
|
|
mock_closed_issue.state = "closed"
|
|
mock_repo.update_issue.return_value = mock_closed_issue
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = mock_closed_issue
|
|
issue = plugin.close_issue('59')
|
|
|
|
assert issue == mock_closed_issue
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_close_already_closed_issue_succeeds(self, mock_repo_class):
|
|
"""Test that closing an already closed issue succeeds gracefully."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.update_issue = AsyncMock()
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run') as mock_run:
|
|
mock_run.return_value = Mock()
|
|
# Should not raise an error
|
|
plugin.close_issue('59')
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_close_nonexistent_issue_raises_error(self, mock_repo_class):
|
|
"""Test that closing non-existent issue raises appropriate error."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.update_issue = AsyncMock(side_effect=Exception("Issue not found"))
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run', side_effect=Exception("Issue not found")):
|
|
with pytest.raises(Exception):
|
|
plugin.close_issue('999999')
|
|
|
|
|
|
class TestGiteaPluginErrorHandling:
|
|
"""Test suite for error handling in Gitea plugin."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_network_errors_are_handled_gracefully(self, mock_repo_class):
|
|
"""Test that network errors are handled gracefully."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issues = AsyncMock(side_effect=ConnectionError("Network error"))
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run', side_effect=ConnectionError("Network error")):
|
|
with pytest.raises(ConnectionError):
|
|
plugin.list_issues()
|
|
|
|
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
|
|
def test_authentication_errors_provide_helpful_messages(self, mock_repo_class):
|
|
"""Test that authentication errors provide helpful error messages."""
|
|
mock_repo = Mock()
|
|
mock_repo_class.return_value = mock_repo
|
|
mock_repo.get_issues = AsyncMock(side_effect=PermissionError("Authentication failed"))
|
|
|
|
plugin = GiteaPlugin(self.config)
|
|
|
|
with patch('asyncio.run', side_effect=PermissionError("Authentication failed")):
|
|
with pytest.raises(PermissionError):
|
|
plugin.list_issues()
|
|
|
|
def test_invalid_configuration_raises_appropriate_error(self):
|
|
"""Test that invalid configuration raises appropriate errors."""
|
|
# Test will be implemented when we add configuration validation
|
|
pass
|
|
|
|
|
|
class TestGiteaPluginIntegration:
|
|
"""Test suite for Gitea plugin integration with existing infrastructure."""
|
|
|
|
def test_plugin_integrates_with_existing_gitea_repository(self):
|
|
"""Test that plugin properly integrates with existing GiteaIssueRepository."""
|
|
config = {
|
|
'url': 'http://gitea.example.com',
|
|
'repo': 'owner/repository'
|
|
}
|
|
|
|
with patch('markitect.issues.plugins.gitea.GiteaIssueRepository') as mock_repo_class:
|
|
plugin = GiteaPlugin(config)
|
|
|
|
# Should create repository instance
|
|
mock_repo_class.assert_called_once()
|
|
|
|
def test_plugin_preserves_existing_domain_models(self):
|
|
"""Test that plugin uses existing domain models without modification."""
|
|
# Plugin should work with existing Issue model
|
|
config = {'url': 'http://test.com', 'repo': 'test/repo'}
|
|
plugin = GiteaPlugin(config)
|
|
|
|
# Should be able to handle Issue domain objects
|
|
assert plugin is not None
|
|
|
|
def test_plugin_maintains_backward_compatibility(self):
|
|
"""Test that plugin maintains compatibility with existing code."""
|
|
# This will be verified through integration tests
|
|
# ensuring existing TDD workflows continue to work
|
|
pass |