Files
markitect-main/tests/test_issue_59_local_plugin.py
tegwick 484d919ffa feat: Complete Issue #59 - Unified issue management CLI with plugin architecture
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>
2025-10-01 23:19:48 +02:00

649 lines
25 KiB
Python

"""
Tests for Issue #59 - Local File Plugin Implementation
This module contains tests for the local file-based backend plugin that
provides offline issue management using markdown files and directories.
"""
import pytest
from unittest.mock import Mock, patch, mock_open
from pathlib import Path
import tempfile
import yaml
import json
from typing import List, Dict, Any
# Import classes we'll implement
# Note: These imports will fail initially (RED phase)
from markitect.issues.plugins.local import LocalPlugin
from markitect.issues.base import IssueBackend
from domain.issues.models import Issue
class TestLocalPluginInitialization:
"""Test suite for Local plugin initialization and configuration."""
def test_local_plugin_inherits_from_issue_backend(self):
"""Test that LocalPlugin properly inherits from IssueBackend."""
config = {'directory': '.markitect/issues'}
plugin = LocalPlugin(config)
assert isinstance(plugin, IssueBackend)
def test_local_plugin_accepts_configuration(self):
"""Test that LocalPlugin accepts and stores configuration."""
config = {
'directory': '.markitect/issues',
'auto_git': True,
'numbering_start': 1000
}
plugin = LocalPlugin(config)
assert plugin.config == config
def test_local_plugin_creates_directory_structure(self):
"""Test that LocalPlugin creates necessary directory structure."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.mkdir') as mock_mkdir:
with patch('pathlib.Path.exists', return_value=False):
plugin = LocalPlugin(config)
# Should create base directory and subdirectories
assert mock_mkdir.called
def test_local_plugin_uses_default_directory_if_not_specified(self):
"""Test that LocalPlugin uses default directory when not specified."""
config = {}
plugin = LocalPlugin(config)
# Should use default directory
assert hasattr(plugin, 'issues_dir')
def test_local_plugin_handles_existing_directory_gracefully(self):
"""Test that LocalPlugin handles existing directories gracefully."""
config = {'directory': '.markitect/issues'}
with patch('pathlib.Path.exists', return_value=True):
# Should not raise errors
plugin = LocalPlugin(config)
assert plugin is not None
class TestLocalPluginDirectoryStructure:
"""Test suite for local plugin directory structure management."""
def test_plugin_creates_open_and_closed_subdirectories(self):
"""Test that plugin creates 'open' and 'closed' subdirectories."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.mkdir') as mock_mkdir:
with patch('pathlib.Path.exists', return_value=False):
plugin = LocalPlugin(config)
# Verify subdirectories are created
expected_calls = [
patch.call(parents=True, exist_ok=True), # Base directory
patch.call(exist_ok=True), # open subdirectory
patch.call(exist_ok=True), # closed subdirectory
]
def test_plugin_creates_config_file_if_missing(self):
"""Test that plugin creates config.yml if it doesn't exist."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.exists', return_value=False):
with patch('builtins.open', mock_open()) as mock_file:
with patch('yaml.dump') as mock_yaml_dump:
plugin = LocalPlugin(config)
# Should create and write config file
mock_file.assert_called()
mock_yaml_dump.assert_called()
def test_plugin_loads_existing_config_file(self):
"""Test that plugin loads existing config.yml file."""
config = {'directory': '/tmp/test_issues'}
existing_config = {'next_issue_number': 100}
with patch('pathlib.Path.exists', return_value=True):
with patch('builtins.open', mock_open(read_data=yaml.dump(existing_config))):
with patch('yaml.safe_load', return_value=existing_config):
plugin = LocalPlugin(config)
assert hasattr(plugin, 'local_config')
class TestLocalPluginIssueNumbering:
"""Test suite for issue numbering and ID management."""
def test_plugin_assigns_sequential_issue_numbers(self):
"""Test that plugin assigns sequential issue numbers."""
config = {'directory': '/tmp/test_issues', 'numbering_start': 1000}
with patch('pathlib.Path.exists', return_value=True):
plugin = LocalPlugin(config)
plugin.local_config = {'next_issue_number': 1001}
# Mock file operations
with patch.object(plugin, '_write_issue_file') as mock_write:
with patch.object(plugin, '_update_config') as mock_update:
issue = plugin.create_issue('Test Title', 'Test Body')
# Should use next available number
mock_write.assert_called_once()
mock_update.assert_called_once()
def test_plugin_increments_issue_counter_after_creation(self):
"""Test that plugin increments issue counter after creating issues."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
plugin.local_config = {'next_issue_number': 1000}
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_update_config') as mock_update:
plugin.create_issue('Test', 'Body')
# Should increment counter
mock_update.assert_called_once()
def test_plugin_handles_number_conflicts_gracefully(self):
"""Test that plugin handles existing issue number conflicts."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
plugin.local_config = {'next_issue_number': 1000}
# Mock existing file
with patch('pathlib.Path.exists', return_value=True):
with patch.object(plugin, '_find_next_available_number', return_value=1001):
with patch.object(plugin, '_write_issue_file'):
issue = plugin.create_issue('Test', 'Body')
# Should use next available number
assert issue is not None
class TestLocalPluginListIssues:
"""Test suite for listing issues from local files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_list_all_issues_reads_both_directories(self):
"""Test that listing all issues reads both open and closed directories."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory') as mock_read:
mock_read.side_effect = [
[Mock(spec=Issue)], # open issues
[Mock(spec=Issue)] # closed issues
]
issues = plugin.list_issues(state='all')
assert len(issues) == 2
assert mock_read.call_count == 2
def test_list_open_issues_only_reads_open_directory(self):
"""Test that listing open issues only reads open directory."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory') as mock_read:
mock_read.return_value = [Mock(spec=Issue)]
issues = plugin.list_issues(state='open')
mock_read.assert_called_once()
def test_list_closed_issues_only_reads_closed_directory(self):
"""Test that listing closed issues only reads closed directory."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory') as mock_read:
mock_read.return_value = [Mock(spec=Issue)]
issues = plugin.list_issues(state='closed')
mock_read.assert_called_once()
def test_list_issues_handles_empty_directories(self):
"""Test that listing issues handles empty directories gracefully."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory', return_value=[]):
issues = plugin.list_issues()
assert issues == []
def test_list_issues_sorts_by_issue_number(self):
"""Test that listed issues are sorted by issue number."""
plugin = LocalPlugin(self.config)
# Mock issues with different numbers
issue1 = Mock(spec=Issue)
issue1.number = 1002
issue2 = Mock(spec=Issue)
issue2.number = 1001
with patch.object(plugin, '_read_issues_from_directory', return_value=[issue1, issue2]):
issues = plugin.list_issues()
# Should be sorted by number
# Actual sorting will be implemented in the plugin
class TestLocalPluginGetIssue:
"""Test suite for getting individual issues from local files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_get_issue_searches_both_directories(self):
"""Test that get_issue searches both open and closed directories."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_read.return_value = Mock(spec=Issue)
issue = plugin.get_issue('1001')
mock_find.assert_called_once_with('1001')
def test_get_issue_reads_markdown_file_with_frontmatter(self):
"""Test that get_issue reads markdown file with YAML frontmatter."""
plugin = LocalPlugin(self.config)
issue_content = """---
number: 1001
title: "Test Issue"
state: "open"
created_at: "2025-10-01T10:00:00Z"
---
This is the issue body content.
"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=issue_content)):
issue = plugin.get_issue('1001')
assert issue is not None
def test_get_nonexistent_issue_raises_error(self):
"""Test that getting non-existent issue raises appropriate error."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file', return_value=None):
with pytest.raises(FileNotFoundError):
plugin.get_issue('999999')
def test_get_issue_handles_malformed_frontmatter(self):
"""Test that get_issue handles malformed YAML frontmatter gracefully."""
plugin = LocalPlugin(self.config)
malformed_content = """---
invalid: yaml: content
---
Body content
"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=malformed_content)):
with pytest.raises(yaml.YAMLError):
plugin.get_issue('1001')
class TestLocalPluginCreateIssue:
"""Test suite for creating issues as local files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_create_issue_generates_markdown_file(self):
"""Test that create_issue generates properly formatted markdown file."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_update_config'):
issue = plugin.create_issue('Test Title', 'Test Body')
# Should write file with YAML frontmatter and markdown body
mock_file.assert_called()
written_content = mock_file().write.call_args_list
# Verify content structure
assert len(written_content) > 0
def test_create_issue_uses_safe_filename(self):
"""Test that create_issue generates safe filenames from titles."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_update_config'):
plugin.create_issue('Test/Title: With Special$Characters!', 'Body')
# Should sanitize filename
# Actual filename sanitization will be verified in implementation
def test_create_issue_includes_metadata_in_frontmatter(self):
"""Test that created issues include proper metadata in YAML frontmatter."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_update_config'):
with patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value.isoformat.return_value = '2025-10-01T10:00:00'
issue = plugin.create_issue('Test Title', 'Test Body')
# Should include number, title, state, created_at in frontmatter
assert issue is not None
def test_create_issue_saves_to_open_directory(self):
"""Test that newly created issues are saved to open directory."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_update_config'):
plugin.create_issue('Test', 'Body')
# Should save to open directory
# File path will be verified in implementation
def test_create_issue_with_additional_metadata(self):
"""Test creating issue with additional metadata (labels, assignees, etc.)."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_update_config'):
issue = plugin.create_issue(
'Test Title',
'Test Body',
labels=['bug', 'priority:high'],
assignee='developer'
)
# Should include additional metadata in frontmatter
assert issue is not None
class TestLocalPluginUpdateIssue:
"""Test suite for updating local issue files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_update_issue_modifies_existing_file(self):
"""Test that update_issue modifies existing issue file."""
plugin = LocalPlugin(self.config)
existing_content = """---
number: 1001
title: "Old Title"
state: "open"
---
Old body content"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-old.md')
with patch('builtins.open', mock_open(read_data=existing_content)) as mock_file:
issue = plugin.update_issue('1001', title='New Title')
# Should read and write the file
mock_file.assert_called()
def test_update_issue_preserves_unchanged_fields(self):
"""Test that updating issue preserves fields that weren't changed."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.title = 'Original Title'
mock_issue.body = 'Original Body'
mock_read.return_value = mock_issue
with patch.object(plugin, '_write_issue_file'):
updated = plugin.update_issue('1001', title='New Title')
# Should preserve body and other fields
assert updated is not None
def test_update_issue_moves_file_on_state_change(self):
"""Test that updating issue state moves file between directories."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_read.return_value = Mock(spec=Issue)
with patch('pathlib.Path.rename') as mock_rename:
with patch.object(plugin, '_write_issue_file'):
plugin.update_issue('1001', state='closed')
# Should move file from open to closed directory
# Actual file movement will be verified in implementation
class TestLocalPluginCommentOperations:
"""Test suite for comment operations on local issues."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_add_comment_appends_to_issue_file(self):
"""Test that add_comment appends comment to issue file."""
plugin = LocalPlugin(self.config)
existing_content = """---
number: 1001
title: "Test Issue"
comments: []
---
Issue body content"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=existing_content)) as mock_file:
result = plugin.add_comment('1001', 'This is a comment')
# Should read and write the file with new comment
assert result is not None
def test_add_comment_includes_timestamp(self):
"""Test that added comments include timestamp metadata."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_read.return_value = Mock(spec=Issue)
with patch.object(plugin, '_write_issue_file'):
with patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value.isoformat.return_value = '2025-10-01T10:00:00'
result = plugin.add_comment('1001', 'Comment')
# Should include timestamp in comment
assert result is not None
def test_add_comment_validates_input(self):
"""Test that add_comment validates input parameters."""
plugin = LocalPlugin(self.config)
# Test empty comment
with pytest.raises(ValueError):
plugin.add_comment('1001', '')
# Test empty issue ID
with pytest.raises(ValueError):
plugin.add_comment('', 'Valid comment')
class TestLocalPluginCloseIssue:
"""Test suite for closing local issues."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_close_issue_moves_to_closed_directory(self):
"""Test that closing issue moves file to closed directory."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_read.return_value = Mock(spec=Issue)
with patch('pathlib.Path.rename') as mock_rename:
with patch.object(plugin, '_write_issue_file'):
issue = plugin.close_issue('1001')
# Should move file and update state
assert issue is not None
def test_close_issue_updates_state_metadata(self):
"""Test that closing issue updates state in YAML frontmatter."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, 'update_issue') as mock_update:
mock_update.return_value = Mock(spec=Issue)
issue = plugin.close_issue('1001')
mock_update.assert_called_once_with('1001', state='closed')
def test_close_already_closed_issue_succeeds(self):
"""Test that closing already closed issue succeeds gracefully."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/closed/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.state = 'closed'
mock_read.return_value = mock_issue
# Should not raise error
issue = plugin.close_issue('1001')
assert issue is not None
class TestLocalPluginGitIntegration:
"""Test suite for Git integration features."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues', 'auto_git': True}
def test_auto_git_commits_new_issues(self):
"""Test that auto_git feature commits new issues to Git."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_git_add_and_commit') as mock_git:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_update_config'):
plugin.local_config = {'next_issue_number': 1001}
plugin.create_issue('Test', 'Body')
mock_git.assert_called_once()
def test_auto_git_commits_issue_updates(self):
"""Test that auto_git feature commits issue updates."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_git_add_and_commit') as mock_git:
with patch.object(plugin, 'update_issue', return_value=Mock()):
plugin.close_issue('1001')
mock_git.assert_called_once()
def test_git_disabled_when_auto_git_false(self):
"""Test that Git operations are disabled when auto_git is False."""
config = {'directory': '/tmp/test_issues', 'auto_git': False}
plugin = LocalPlugin(config)
with patch.object(plugin, '_git_add_and_commit') as mock_git:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_update_config'):
plugin.local_config = {'next_issue_number': 1001}
plugin.create_issue('Test', 'Body')
mock_git.assert_not_called()
def test_git_operations_handle_no_git_repo_gracefully(self):
"""Test that Git operations handle absence of Git repo gracefully."""
plugin = LocalPlugin(self.config)
with patch('subprocess.run', side_effect=FileNotFoundError("git not found")):
# Should not raise errors
plugin._git_add_and_commit('Test commit message')
class TestLocalPluginErrorHandling:
"""Test suite for error handling in local plugin."""
def test_handles_permission_errors_gracefully(self):
"""Test that plugin handles file permission errors gracefully."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
with patch('builtins.open', side_effect=PermissionError("Permission denied")):
with pytest.raises(PermissionError):
plugin.create_issue('Test', 'Body')
def test_handles_disk_full_errors_gracefully(self):
"""Test that plugin handles disk full errors gracefully."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
with patch('builtins.open', side_effect=OSError("No space left on device")):
with pytest.raises(OSError):
plugin.create_issue('Test', 'Body')
def test_handles_invalid_yaml_in_existing_files(self):
"""Test that plugin handles invalid YAML in existing files."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
invalid_yaml = """---
invalid: yaml: content: [unclosed
---
Body"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=invalid_yaml)):
with pytest.raises(yaml.YAMLError):
plugin.get_issue('1001')
class TestLocalPluginBackwardCompatibility:
"""Test suite for backward compatibility features."""
def test_plugin_reads_legacy_file_formats(self):
"""Test that plugin can read legacy issue file formats."""
# Will be implemented if we need to support migration
pass
def test_plugin_upgrades_file_format_on_update(self):
"""Test that plugin upgrades file format when updating old issues."""
# Will be implemented if we need format migration
pass