refactor: remove obsolete issue management system in favor of issue-facade
Some checks failed
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled

Complete cleanup of the legacy TDD AI and issue management system, establishing clear separation of concerns as requested. All issue handling is now provided by the standalone issue-facade system.

Removed components:
- TDD AI framework (tddai/ directory and tddai_cli.py)
- Legacy issue management CLI commands and services
- Issue-related Makefile targets and helper commands
- Obsolete tests and infrastructure dependencies
- Finance modules that depended on the old issue system

Updated:
- Makefile: Removed issue-*, tdd-*, and test-from-issue commands
- CLI framework: Simplified to core functionality only
- Documentation: Added deprecation notice for old config system

The issue-facade now serves as the universal CLI for issue tracking,
providing backend-agnostic interface to GitHub, GitLab, Gitea, and
local SQLite storage as documented in issue-facade/README.md.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-24 21:25:04 +02:00
parent cb94c92fc0
commit a8e5b4b044
58 changed files with 11 additions and 14628 deletions

View File

@@ -1,411 +0,0 @@
#!/usr/bin/env python3
"""
CLI Consolidation Integration Tests
Tests to ensure proper CLI interface consolidation and prevent regression
of missing CLI commands. This test suite verifies:
1. All CLI entry points are properly installed
2. No functionality duplication between CLIs
3. Each CLI has clear separation of concerns
4. Help commands work for all CLIs
5. Core functionality is accessible
Purpose: Prevent the loss of CLI interfaces that occurred previously
due to lack of testing.
"""
import pytest
import subprocess
import shutil
import sys
from pathlib import Path
class TestCLIConsolidation:
"""Test suite for CLI consolidation and interface availability."""
def test_all_cli_commands_installed(self):
"""Ensure all CLI commands are properly installed and accessible."""
# Test that all three CLI commands exist
markitect_path = shutil.which("markitect")
tddai_path = shutil.which("tddai")
issue_path = shutil.which("issue")
# If not found in PATH, check if we're in a virtual environment
if markitect_path is None or tddai_path is None or issue_path is None:
# Check if we're in a virtual environment
venv_path = sys.prefix
if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix):
# We're in a virtual environment, check the bin directory
venv_bin = Path(venv_path) / "bin"
if not markitect_path:
markitect_path = venv_bin / "markitect" if (venv_bin / "markitect").exists() else None
if not tddai_path:
tddai_path = venv_bin / "tddai" if (venv_bin / "tddai").exists() else None
if not issue_path:
issue_path = venv_bin / "issue" if (venv_bin / "issue").exists() else None
assert markitect_path is not None, "markitect CLI command not found - check pyproject.toml scripts"
assert tddai_path is not None, "tddai CLI command not found - check pyproject.toml scripts"
assert issue_path is not None, "issue CLI command not found - check pyproject.toml scripts"
def test_cli_help_commands_work(self):
"""Verify help commands work for all CLIs without errors."""
cli_commands = ["markitect", "tddai", "issue"]
for cmd in cli_commands:
try:
# Try direct command first
result = subprocess.run(
[cmd, "--help"],
capture_output=True,
text=True,
timeout=30
)
assert result.returncode == 0, f"{cmd} --help failed with exit code {result.returncode}"
assert len(result.stdout) > 100, f"{cmd} --help produced minimal output: {result.stdout[:200]}"
except FileNotFoundError:
# Fallback: try running via python -m if command not found in PATH
try:
if cmd == "markitect":
result = subprocess.run(
[sys.executable, "-m", "markitect.cli", "--help"],
capture_output=True,
text=True,
timeout=30
)
elif cmd == "tddai":
result = subprocess.run(
[sys.executable, "-m", "tddai_cli", "--help"],
capture_output=True,
text=True,
timeout=30
)
elif cmd == "issue":
result = subprocess.run(
[sys.executable, "-m", "cli.issue_cli", "--help"],
capture_output=True,
text=True,
timeout=30
)
assert result.returncode == 0, f"{cmd} --help failed with exit code {result.returncode}"
assert len(result.stdout) > 100, f"{cmd} --help produced minimal output: {result.stdout[:200]}"
except subprocess.TimeoutExpired:
pytest.fail(f"{cmd} --help timed out")
except FileNotFoundError:
pytest.fail(f"{cmd} command not found and module execution failed")
except subprocess.TimeoutExpired:
pytest.fail(f"{cmd} --help timed out")
def test_markitect_focuses_on_documents(self):
"""Verify markitect CLI focuses on document processing, not issues."""
result = subprocess.run(
["markitect", "--help"],
capture_output=True,
text=True
)
help_text = result.stdout.lower()
# Should have document-related commands
document_keywords = ["md-ingest", "query", "template", "cache", "perf"]
for keyword in document_keywords:
assert keyword in help_text, f"markitect should include {keyword} functionality"
# Should have issue commands alongside dedicated CLIs for unified access
# NOTE: markitect provides issues group for unified interface while dedicated CLIs provide specialized access
assert "issues" in help_text, "markitect should include issues functionality for unified access"
def test_tddai_focuses_on_workflow(self):
"""Verify tddai CLI focuses on TDD workflow management."""
result = subprocess.run(
["tddai", "--help"],
capture_output=True,
text=True
)
help_text = result.stdout.lower()
# Should have TDD workflow commands
tdd_keywords = ["start-issue", "finish-issue", "workspace", "coverage", "test"]
for keyword in tdd_keywords:
assert keyword in help_text, f"tddai should include {keyword} functionality"
def test_issue_focuses_on_issue_management(self):
"""Verify issue CLI focuses purely on issue operations."""
result = subprocess.run(
["issue", "--help"],
capture_output=True,
text=True
)
help_text = result.stdout.lower()
# Should have issue management commands
issue_keywords = ["list", "show", "create", "close", "assign", "priority"]
for keyword in issue_keywords:
assert keyword in help_text, f"issue CLI should include {keyword} functionality"
def test_cli_separation_of_concerns(self):
"""Ensure CLIs maintain appropriate separation of concerns while allowing unified access."""
# Get help text for all CLIs
markitect_help = subprocess.run(["markitect", "--help"], capture_output=True, text=True).stdout
tddai_help = subprocess.run(["tddai", "--help"], capture_output=True, text=True).stdout
issue_help = subprocess.run(["issue", "--help"], capture_output=True, text=True).stdout
# markitect should have both document processing AND issues (unified interface)
assert "md-ingest" in markitect_help, "markitect should have document processing"
assert "issues" in markitect_help, "markitect should have unified issues access"
# tddai should focus on workflow
assert "workspace" in tddai_help.lower(), "tddai should have workflow features"
assert "start-issue" in tddai_help, "tddai should have TDD workflow"
# issue CLI should focus on pure issue management
assert "list" in issue_help, "issue CLI should have list functionality"
assert "create" in issue_help, "issue CLI should have create functionality"
def test_cli_integration_imports(self):
"""Test that CLI modules can be imported without errors."""
try:
# Test tddai_cli import
import tddai_cli
assert hasattr(tddai_cli, 'main'), "tddai_cli should have main() function"
# Test issue CLI import
from cli import issue_cli
assert hasattr(issue_cli, 'main'), "issue_cli should have main() function"
# Test markitect CLI import
from markitect import cli as markitect_cli
assert hasattr(markitect_cli, 'main'), "markitect.cli should have main() function"
except ImportError as e:
pytest.fail(f"CLI import failed: {e}")
def test_cli_framework_integration(self):
"""Test that the CLI framework is properly integrated."""
try:
from cli.core import CLIFramework
# Initialize framework (should not raise errors)
framework = CLIFramework()
# Test that key methods exist
required_methods = [
'list_issues', 'show_issue', 'close_issue', 'create_issue',
'start_issue', 'finish_issue', 'workspace_status'
]
for method in required_methods:
assert hasattr(framework, method), f"CLIFramework missing method: {method}"
except Exception as e:
pytest.fail(f"CLI framework integration failed: {e}")
def test_make_targets_work(self):
"""Test that Makefile targets work with the new CLI structure."""
# Test that make targets exist for issue operations
makefile_path = Path(__file__).parent.parent / "Makefile"
if makefile_path.exists():
makefile_content = makefile_path.read_text()
# Check for issue-related targets (using new issue- prefix convention)
expected_targets = [
"issue-close", "issue-close-enhanced", "issue-close-batch",
"issue-list", "issue-show"
]
for target in expected_targets:
assert target in makefile_content, f"Makefile missing target: {target}"
def test_pyproject_toml_entries(self):
"""Test that pyproject.toml has correct CLI entry points."""
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
if pyproject_path.exists():
content = pyproject_path.read_text()
# Check for all three CLI entry points
expected_entries = [
'markitect = "markitect.cli:main"',
'tddai = "tddai_cli:main"',
'issue = "cli.issue_cli:main"'
]
for entry in expected_entries:
assert entry in content, f"pyproject.toml missing entry: {entry}"
class TestCLIFunctionality:
"""Comprehensive functional tests for all CLI commands."""
def test_markitect_document_commands(self):
"""Test markitect document processing commands."""
# Test that markitect has core document commands
result = subprocess.run(["markitect", "--help"], capture_output=True, text=True)
help_text = result.stdout
# Core document processing commands should be present
expected_commands = [
"md-ingest", "md-list", "md-get", "stats", "metadata",
"schema-generate", "template-render", "perf-benchmark"
]
for cmd in expected_commands:
assert cmd in help_text, f"markitect missing document command: {cmd}"
def test_tddai_workflow_commands(self):
"""Test tddai TDD workflow commands."""
result = subprocess.run(["tddai", "--help"], capture_output=True, text=True)
help_text = result.stdout
# TDD workflow commands should be present
expected_commands = [
"workspace-status", "start-issue", "finish-issue",
"list-issues", "close-issue", "analyze-coverage"
]
for cmd in expected_commands:
assert cmd in help_text, f"tddai missing workflow command: {cmd}"
def test_issue_management_commands(self):
"""Test issue CLI management commands."""
result = subprocess.run(["issue", "--help"], capture_output=True, text=True)
help_text = result.stdout
# Issue management commands should be present
expected_commands = [
"list", "show", "create", "close",
"assign", "priority", "state", "export"
]
for cmd in expected_commands:
assert cmd in help_text, f"issue CLI missing command: {cmd}"
def test_cli_subcommand_help(self):
"""Test that subcommands have proper help text."""
test_cases = [
("tddai", "list-issues", "--help"),
("issue", "list", "--help"),
("markitect", "stats", "--help"),
]
for cli, subcommand, help_flag in test_cases:
try:
result = subprocess.run(
[cli, subcommand, help_flag],
capture_output=True,
text=True,
timeout=10
)
# Should either succeed or show usage (not crash)
assert result.returncode in [0, 2], f"{cli} {subcommand} help failed"
assert len(result.stdout) > 10 or len(result.stderr) > 10, f"{cli} {subcommand} no help output"
except subprocess.TimeoutExpired:
pytest.fail(f"{cli} {subcommand} --help timed out")
def test_cli_error_handling(self):
"""Test that CLIs handle invalid commands gracefully."""
test_cases = [
("tddai", "invalid-command"),
("issue", "invalid-command"),
("markitect", "invalid-command"),
]
for cli, invalid_cmd in test_cases:
result = subprocess.run(
[cli, invalid_cmd],
capture_output=True,
text=True
)
# Should fail gracefully, not crash
assert result.returncode != 0, f"{cli} should reject invalid command {invalid_cmd}"
# Should have error output
assert len(result.stderr) > 0 or "error" in result.stdout.lower(), f"{cli} should show error for {invalid_cmd}"
def test_cli_list_commands_functional(self):
"""Test that list commands actually work."""
# Test that list commands don't crash
test_cases = [
("tddai", "list-issues"),
("issue", "list"),
("markitect", "md-list"),
]
for cli, list_cmd in test_cases:
try:
result = subprocess.run(
[cli, list_cmd],
capture_output=True,
text=True,
timeout=30
)
# Should not crash (may return empty list)
assert result.returncode == 0, f"{cli} {list_cmd} failed with exit code {result.returncode}"
except subprocess.TimeoutExpired:
pytest.fail(f"{cli} {list_cmd} timed out - may be hanging")
def test_cli_configuration_access(self):
"""Test that CLIs can access configuration."""
# Test config-related commands
result = subprocess.run(
["tddai", "config-show"],
capture_output=True,
text=True,
timeout=15
)
# Should not crash (may show config or error message)
assert result.returncode in [0, 1], "tddai config-show should handle config access"
class TestCLIRegression:
"""Tests to prevent regression of CLI functionality."""
def test_prevent_cli_loss(self):
"""Prevent loss of CLI commands (primary regression test)."""
# This is the main test that should have prevented the original issue
required_clis = ["markitect", "tddai", "issue"]
for cli in required_clis:
# Test that command exists
assert shutil.which(cli) is not None, f"REGRESSION: {cli} CLI lost - not installed"
# Test that command responds
result = subprocess.run([cli, "--help"], capture_output=True, text=True)
assert result.returncode == 0, f"REGRESSION: {cli} CLI broken - help fails"
def test_core_issue_operations_accessible(self):
"""Ensure core issue operations remain accessible through some CLI."""
# Test that basic issue operations are available
core_operations = [
("list issues", ["tddai", "list-issues"]),
("show issue", ["tddai", "show-issue", "42"]), # Will fail but should parse
("close issue", ["tddai", "close-issue", "42"]) # Will fail but should parse
]
for operation_name, cmd in core_operations:
try:
# We expect these to fail (no real issue 42), but the CLI should parse the command
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10
)
# Command should be recognized (not return "unknown command" error)
assert "unknown" not in result.stderr.lower(), f"{operation_name} not accessible via CLI"
except subprocess.TimeoutExpired:
# Timeout is okay - means command is running
pass
if __name__ == '__main__':
pytest.main([__file__, "-v"])

View File

@@ -1,627 +0,0 @@
"""
Tests for Issue #113 - Issue Activity Tracking Implementation
This module contains comprehensive tests for the issue activity tracking
service and CLI commands that log, retrieve, and manage issue activities
for cost allocation and project management.
"""
import pytest
import sqlite3
from datetime import datetime, date
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
import tempfile
import json
import csv
import io
from contextlib import redirect_stdout
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType, IssueActivity
from markitect.issues.activity_commands import activity
class TestActivityType:
"""Test suite for ActivityType enumeration."""
def test_activity_type_values(self):
"""Test that all expected activity types are available."""
expected_types = {
"created", "modified", "closed", "reopened", "commented", "status_changed"
}
actual_types = {at.value for at in ActivityType}
assert actual_types == expected_types
def test_activity_type_enumeration(self):
"""Test that ActivityType can be constructed from string values."""
assert ActivityType("created") == ActivityType.CREATED
assert ActivityType("modified") == ActivityType.MODIFIED
assert ActivityType("closed") == ActivityType.CLOSED
class TestIssueActivity:
"""Test suite for IssueActivity dataclass."""
def test_issue_activity_creation(self):
"""Test that IssueActivity objects can be created properly."""
activity = IssueActivity(
id=1,
issue_id=59,
activity_type=ActivityType.CREATED,
activity_date=date.today(),
activity_details="Issue created"
)
assert activity.id == 1
assert activity.issue_id == 59
assert activity.activity_type == ActivityType.CREATED
assert activity.activity_date == date.today()
assert activity.activity_details == "Issue created"
def test_issue_activity_defaults(self):
"""Test that IssueActivity has proper default values."""
activity = IssueActivity()
assert activity.id is None
assert activity.issue_id is None
assert activity.activity_type is None
assert activity.activity_date is None
assert activity.period_id is None
assert activity.activity_details is None
assert activity.created_at is None
class TestIssueActivityTracker:
"""Test suite for IssueActivityTracker service."""
def setup_method(self):
"""Set up test fixtures with temporary database."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
self.tracker = IssueActivityTracker(self.db_path)
def teardown_method(self):
"""Clean up test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_tracker_initialization(self):
"""Test that tracker initializes properly with database."""
assert self.tracker.db_path == self.db_path
assert self.tracker.finance_models is not None
# Verify database schema was created
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='issue_activity_log'")
assert cursor.fetchone() is not None
def test_log_activity_basic(self):
"""Test logging a basic activity."""
activity_id = self.tracker.log_activity(
issue_id=59,
activity_type=ActivityType.CREATED,
activity_details="Test issue created"
)
assert activity_id is not None
# Verify activity was stored
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM issue_activity_log WHERE id = ?", (activity_id,))
row = cursor.fetchone()
assert row is not None
assert row[1] == 59 # issue_id
assert row[2] == "created" # activity_type
assert row[5] == "Test issue created" # activity_details
def test_log_activity_with_custom_date(self):
"""Test logging activity with custom date."""
custom_date = date(2025, 10, 1)
activity_id = self.tracker.log_activity(
issue_id=59,
activity_type=ActivityType.MODIFIED,
activity_date=custom_date
)
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT activity_date FROM issue_activity_log WHERE id = ?", (activity_id,))
stored_date = cursor.fetchone()[0]
assert stored_date == "2025-10-01"
def test_log_activity_with_period_id(self):
"""Test logging activity with specific period ID."""
# First create a cost period
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-10-01', '2025-10-31', 1000.00)
""")
period_id = cursor.lastrowid
activity_id = self.tracker.log_activity(
issue_id=59,
activity_type=ActivityType.CREATED,
period_id=period_id
)
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT period_id FROM issue_activity_log WHERE id = ?", (activity_id,))
stored_period_id = cursor.fetchone()[0]
assert stored_period_id == period_id
def test_get_issue_activities(self):
"""Test retrieving activities for a specific issue."""
# Log multiple activities
self.tracker.log_activity(59, ActivityType.CREATED, activity_details="Created")
self.tracker.log_activity(59, ActivityType.MODIFIED, activity_details="Modified")
self.tracker.log_activity(60, ActivityType.CREATED, activity_details="Different issue")
activities = self.tracker.get_issue_activities(59)
assert len(activities) == 2
assert all(a.issue_id == 59 for a in activities)
assert activities[0].activity_type in [ActivityType.CREATED, ActivityType.MODIFIED]
def test_get_issue_activities_with_limit(self):
"""Test retrieving activities with limit and offset."""
# Log multiple activities
for i in range(5):
self.tracker.log_activity(59, ActivityType.MODIFIED, activity_details=f"Update {i}")
activities = self.tracker.get_issue_activities(59, limit=2, offset=1)
assert len(activities) == 2
def test_get_activities_by_period(self):
"""Test retrieving activities by cost period."""
# Create a cost period
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-10-01', '2025-10-31', 1000.00)
""")
period_id = cursor.lastrowid
# Log activities in different periods
self.tracker.log_activity(59, ActivityType.CREATED, period_id=period_id)
self.tracker.log_activity(60, ActivityType.MODIFIED, period_id=period_id)
# Log activity outside the period date range
self.tracker.log_activity(61, ActivityType.CLOSED, activity_date=date(2025, 11, 1))
activities = self.tracker.get_activities_by_period(period_id)
assert len(activities) == 2
assert all(a.period_id == period_id for a in activities)
def test_get_activities_by_period_with_type_filter(self):
"""Test retrieving activities by period with type filtering."""
# Create period
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-10-01', '2025-10-31', 1000.00)
""")
period_id = cursor.lastrowid
# Log various activities
self.tracker.log_activity(59, ActivityType.CREATED, period_id=period_id)
self.tracker.log_activity(60, ActivityType.MODIFIED, period_id=period_id)
self.tracker.log_activity(61, ActivityType.CLOSED, period_id=period_id)
activities = self.tracker.get_activities_by_period(
period_id,
activity_types=[ActivityType.CREATED, ActivityType.CLOSED]
)
assert len(activities) == 2
assert all(a.activity_type in [ActivityType.CREATED, ActivityType.CLOSED] for a in activities)
def test_get_activity_summary_basic(self):
"""Test basic activity summary generation."""
# Log some test activities
self.tracker.log_activity(59, ActivityType.CREATED)
self.tracker.log_activity(59, ActivityType.MODIFIED)
self.tracker.log_activity(60, ActivityType.CREATED)
summary = self.tracker.get_activity_summary()
assert summary['total_activities'] == 3
assert summary['unique_issues'] == 2
assert 'created' in summary['activities_by_type']
assert 'modified' in summary['activities_by_type']
assert summary['activities_by_type']['created'] == 2
assert summary['activities_by_type']['modified'] == 1
def test_get_activity_summary_with_filters(self):
"""Test activity summary with date and issue filters."""
today = date.today()
yesterday = date(today.year, today.month, today.day - 1) if today.day > 1 else date(today.year, today.month - 1, 28)
# Log activities on different dates
self.tracker.log_activity(59, ActivityType.CREATED, activity_date=yesterday)
self.tracker.log_activity(59, ActivityType.MODIFIED, activity_date=today)
self.tracker.log_activity(60, ActivityType.CREATED, activity_date=today)
# Test issue filter
summary = self.tracker.get_activity_summary(issue_id=59)
assert summary['total_activities'] == 2
assert summary['unique_issues'] == 1
# Test date filter
summary = self.tracker.get_activity_summary(start_date=today)
assert summary['total_activities'] == 2
def test_delete_activity(self):
"""Test deleting an activity record."""
activity_id = self.tracker.log_activity(59, ActivityType.CREATED)
# Verify activity exists
activities = self.tracker.get_issue_activities(59)
assert len(activities) == 1
# Delete activity
result = self.tracker.delete_activity(activity_id)
assert result is True
# Verify activity is gone
activities = self.tracker.get_issue_activities(59)
assert len(activities) == 0
def test_delete_nonexistent_activity(self):
"""Test deleting non-existent activity returns False."""
result = self.tracker.delete_activity(99999)
assert result is False
def test_bulk_log_activities(self):
"""Test logging multiple activities in one transaction."""
activities_data = [
{
'issue_id': 59,
'activity_type': 'created',
'activity_details': 'Bulk created'
},
{
'issue_id': 60,
'activity_type': 'modified',
'activity_details': 'Bulk modified'
}
]
activity_ids = self.tracker.bulk_log_activities(activities_data)
assert len(activity_ids) == 2
assert all(isinstance(aid, int) for aid in activity_ids)
# Verify activities were created
activities_59 = self.tracker.get_issue_activities(59)
activities_60 = self.tracker.get_issue_activities(60)
assert len(activities_59) == 1
assert len(activities_60) == 1
assert activities_59[0].activity_details == 'Bulk created'
assert activities_60[0].activity_details == 'Bulk modified'
def test_bulk_log_activities_validation(self):
"""Test bulk logging validates required fields."""
invalid_data = [
{'issue_id': 59}, # Missing activity_type
{'activity_type': 'created'} # Missing issue_id
]
with pytest.raises(ValueError, match="must have 'issue_id' and 'activity_type'"):
self.tracker.bulk_log_activities(invalid_data)
class TestActivityCommands:
"""Test suite for activity CLI commands."""
def setup_method(self):
"""Set up test fixtures."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
# Initialize database with test data
tracker = IssueActivityTracker(self.db_path)
tracker.log_activity(59, ActivityType.CREATED, activity_details="Test issue created")
tracker.log_activity(59, ActivityType.MODIFIED, activity_details="Test issue modified")
def teardown_method(self):
"""Clean up test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_log_command_basic(self, mock_tracker_class):
"""Test the log command with basic parameters."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.log_activity.return_value = 123
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['log', '59', 'created'])
assert result.exit_code == 0
assert "✅ Logged created activity for issue #59" in result.output
mock_tracker.log_activity.assert_called_once()
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_log_command_with_details(self, mock_tracker_class):
"""Test the log command with activity details."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.log_activity.return_value = 123
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['log', '59', 'created', '--details', 'Test details'])
assert result.exit_code == 0
assert "Test details" in result.output
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_show_command(self, mock_tracker_class):
"""Test the show command for displaying issue activities."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_activities = [
IssueActivity(
id=1,
issue_id=59,
activity_type=ActivityType.CREATED,
activity_date=date.today(),
activity_details="Test activity",
created_at=datetime.now()
)
]
mock_tracker.get_issue_activities.return_value = mock_activities
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['show', '59'])
assert result.exit_code == 0
assert "📋 Activities for Issue #59" in result.output
assert "Test activity" in result.output
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_show_command_json_format(self, mock_tracker_class):
"""Test the show command with JSON output format."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_activities = [
IssueActivity(
id=1,
issue_id=59,
activity_type=ActivityType.CREATED,
activity_date=date.today(),
activity_details="Test activity",
created_at=datetime.now()
)
]
mock_tracker.get_issue_activities.return_value = mock_activities
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['show', '59', '--format', 'json'])
assert result.exit_code == 0
# Should be valid JSON
output_data = json.loads(result.output.strip())
assert len(output_data) == 1
assert output_data[0]['issue_id'] == 59
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_summary_command(self, mock_tracker_class):
"""Test the summary command."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_summary = {
'total_activities': 5,
'unique_issues': 3,
'activities_by_type': {'created': 3, 'modified': 2},
'date_range': {'start': '2025-10-01', 'end': '2025-10-04'},
'filters': {'issue_id': None, 'start_date': None, 'end_date': None}
}
mock_tracker.get_activity_summary.return_value = mock_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['summary'])
assert result.exit_code == 0
assert "📊 Issue Activity Summary" in result.output
assert "Total Activities: 5" in result.output
assert "Unique Issues: 3" in result.output
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_delete_command(self, mock_tracker_class):
"""Test the delete command."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.delete_activity.return_value = True
from click.testing import CliRunner
runner = CliRunner()
# Auto-confirm the deletion
result = runner.invoke(activity, ['delete', '123'], input='y\n')
assert result.exit_code == 0
assert "✅ Deleted activity #123" in result.output
mock_tracker.delete_activity.assert_called_once_with(123)
def test_import_activities_json(self):
"""Test importing activities from JSON file."""
# Create test JSON file
test_data = [
{
'issue_id': 59,
'activity_type': 'created',
'activity_details': 'Imported activity'
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(test_data, f)
json_file_path = f.name
try:
with patch('markitect.issues.activity_commands.IssueActivityTracker') as mock_tracker_class:
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.bulk_log_activities.return_value = [1]
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['import-activities', json_file_path])
assert result.exit_code == 0
assert "✅ Successfully imported 1 activities" in result.output
finally:
Path(json_file_path).unlink(missing_ok=True)
class TestActivityIntegration:
"""Integration tests for the complete activity tracking system."""
def setup_method(self):
"""Set up integration test fixtures."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
def teardown_method(self):
"""Clean up integration test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_full_activity_lifecycle(self):
"""Test the complete lifecycle of activity tracking."""
tracker = IssueActivityTracker(self.db_path)
# 1. Log initial activity
activity_id = tracker.log_activity(
issue_id=59,
activity_type=ActivityType.CREATED,
activity_details="Issue created for testing"
)
assert activity_id is not None
# 2. Log follow-up activities (with slight time differences to ensure ordering)
import time
time.sleep(0.1)
tracker.log_activity(59, ActivityType.MODIFIED, activity_details="Updated description")
time.sleep(0.1)
tracker.log_activity(59, ActivityType.COMMENTED, activity_details="Added comment")
time.sleep(0.1)
tracker.log_activity(59, ActivityType.CLOSED, activity_details="Resolved issue")
# 3. Retrieve issue history
activities = tracker.get_issue_activities(59)
assert len(activities) == 4
# Verify all expected activity types are present
activity_types = [a.activity_type.value for a in activities]
expected_types = {'closed', 'commented', 'modified', 'created'}
assert set(activity_types) == expected_types
# 4. Generate summary
summary = tracker.get_activity_summary(issue_id=59)
assert summary['total_activities'] == 4
assert summary['unique_issues'] == 1
assert len(summary['activities_by_type']) == 4
# 5. Clean up - delete an activity
deleted = tracker.delete_activity(activity_id)
assert deleted is True
# Verify deletion
remaining_activities = tracker.get_issue_activities(59)
assert len(remaining_activities) == 3
def test_multi_issue_activity_tracking(self):
"""Test activity tracking across multiple issues."""
tracker = IssueActivityTracker(self.db_path)
# Log activities for multiple issues
issues = [59, 60, 61, 62]
for issue_id in issues:
tracker.log_activity(issue_id, ActivityType.CREATED)
if issue_id % 2 == 0: # Even issues get modified
tracker.log_activity(issue_id, ActivityType.MODIFIED)
# Test overall summary
summary = tracker.get_activity_summary()
assert summary['total_activities'] == 6 # 4 created + 2 modified
assert summary['unique_issues'] == 4
assert summary['activities_by_type']['created'] == 4
assert summary['activities_by_type']['modified'] == 2
# Test individual issue tracking
for issue_id in issues:
activities = tracker.get_issue_activities(issue_id)
expected_count = 2 if issue_id % 2 == 0 else 1
assert len(activities) == expected_count
def test_cost_period_integration(self):
"""Test integration with cost period functionality."""
tracker = IssueActivityTracker(self.db_path)
# Create cost periods
with tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-01-01', '2025-03-31', 5000.00)
""")
q1_period_id = cursor.lastrowid
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-04-01', '2025-06-30', 6000.00)
""")
q2_period_id = cursor.lastrowid
periods = {'Q1 2025': q1_period_id, 'Q2 2025': q2_period_id}
# Log activities in different periods
tracker.log_activity(59, ActivityType.CREATED, period_id=periods['Q1 2025'])
tracker.log_activity(60, ActivityType.MODIFIED, period_id=periods['Q1 2025'])
tracker.log_activity(61, ActivityType.CLOSED, period_id=periods['Q2 2025'])
# Test period-based retrieval
q1_activities = tracker.get_activities_by_period(periods['Q1 2025'])
q2_activities = tracker.get_activities_by_period(periods['Q2 2025'])
assert len(q1_activities) == 2
assert len(q2_activities) == 1
assert all(a.period_id == periods['Q1 2025'] for a in q1_activities)
assert all(a.period_id == periods['Q2 2025'] for a in q2_activities)
# Test filtering by activity type within period
q1_created = tracker.get_activities_by_period(
periods['Q1 2025'],
activity_types=[ActivityType.CREATED]
)
assert len(q1_created) == 1
assert q1_created[0].activity_type == ActivityType.CREATED

View File

@@ -1,809 +0,0 @@
"""
Tests for Issue #114 - Cost Allocation Engine Implementation
This module contains comprehensive tests for the cost allocation engine
that distributes operational costs across active issues according to the
algorithm defined in Issue #88.
Tests cover:
- Core allocation algorithm with equal distribution
- Edge cases (no active issues, no costs, closed periods)
- Transaction audit trail creation
- Loss carried forward handling
- Integration with existing cost and activity tracking
- CLI command functionality
- Allocation reversal capabilities
"""
import pytest
import sqlite3
import tempfile
from datetime import datetime, date, timedelta
from decimal import Decimal
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import json
from contextlib import redirect_stdout
import io
from markitect.finance.allocation_engine import (
AllocationEngine, TransactionManager, AllocationStatus,
AllocationResult, IssueAllocation
)
from markitect.finance.models import FinanceModels
from markitect.finance.cost_manager import CostItemManager, CostItem
from markitect.finance.period_manager import PeriodManager, PeriodStatus
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType
def create_test_cost_item(cost_manager, name, category_id, cost_type, amount_eur, starting_from_date):
"""Helper function to create cost items with proper interface."""
cost_item = CostItem(
name=name,
category_id=category_id,
cost_type=cost_type,
amount_eur=amount_eur,
starting_from_date=starting_from_date
)
return cost_manager.create_cost_item(cost_item)
def create_unique_category(cost_manager, base_name, description="Test category"):
"""Helper function to create categories with unique names."""
import time
unique_name = f"{base_name}-{int(time.time()*1000000)}"
return cost_manager.create_category(unique_name, description)
class TestTransactionManager:
"""Test suite for TransactionManager class."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def transaction_manager(self, temp_db):
"""Create TransactionManager instance for testing."""
return TransactionManager(temp_db)
@pytest.fixture
def sample_period(self, temp_db):
"""Create a sample period for testing."""
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
return period_id
def test_transaction_manager_initialization(self, temp_db):
"""Test that TransactionManager initializes properly."""
manager = TransactionManager(temp_db)
assert manager.db_path == temp_db
assert isinstance(manager.finance_models, FinanceModels)
def test_create_allocation_transaction(self, transaction_manager, sample_period):
"""Test creating allocation transaction records."""
transaction_id = transaction_manager.create_allocation_transaction(
period_id=sample_period,
amount=Decimal('15.50'),
issue_id=123,
transaction_date=date(2025, 10, 15),
description="Test allocation transaction"
)
assert transaction_id is not None
assert isinstance(transaction_id, int)
# Verify transaction was created
with transaction_manager.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM cost_transactions WHERE id = ?',
(transaction_id,)
)
row = cursor.fetchone()
assert row is not None
assert row[1] == sample_period # period_id
assert row[3] == 'cost_allocated' # transaction_type
assert float(row[4]) == 15.50 # amount_eur
assert row[5] == 123 # issue_id
def test_create_loss_forward_transaction(self, transaction_manager, sample_period):
"""Test creating loss carried forward transactions."""
# Create next period
period_manager = PeriodManager(transaction_manager.db_path)
next_period_id = period_manager.create_period(
period_start=date(2025, 11, 1),
period_end=date(2025, 11, 30)
)
transaction_id = transaction_manager.create_loss_forward_transaction(
from_period_id=sample_period,
to_period_id=next_period_id,
amount=Decimal('25.75'),
transaction_date=date(2025, 11, 1),
description="Loss carried forward"
)
assert transaction_id is not None
# Verify transaction was created
with transaction_manager.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM cost_transactions WHERE id = ?',
(transaction_id,)
)
row = cursor.fetchone()
assert row is not None
assert row[1] == next_period_id # period_id
assert row[3] == 'loss_forward' # transaction_type
assert float(row[4]) == 25.75 # amount_eur
assert row[5] is None # issue_id (null for loss forward)
class TestAllocationEngine:
"""Test suite for AllocationEngine class."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def allocation_engine(self, temp_db):
"""Create AllocationEngine instance for testing."""
return AllocationEngine(temp_db)
@pytest.fixture
def sample_costs(self, temp_db):
"""Create sample cost items for testing."""
cost_manager = CostItemManager(temp_db)
# Create cost category
category_id = create_unique_category(cost_manager, "Test Services", "Test cost category")
# Create cost items
cost_ids = []
cost_ids.append(create_test_cost_item(
cost_manager, "Monthly Service", category_id, "monthly",
Decimal('20.00'), date(2025, 10, 1)
))
cost_ids.append(create_test_cost_item(
cost_manager, "One-time Setup", category_id, "one_time",
Decimal('30.00'), date(2025, 10, 15)
))
return cost_ids
@pytest.fixture
def sample_period_with_costs(self, temp_db, sample_costs):
"""Create a period with associated costs."""
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
return period_id
@pytest.fixture
def sample_issue_activities(self, temp_db, sample_period_with_costs):
"""Create sample issue activities for testing."""
activity_tracker = IssueActivityTracker(temp_db)
# Log activities for different issues
activity_ids = []
activity_ids.append(activity_tracker.log_activity(
issue_id=101,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=sample_period_with_costs
))
activity_ids.append(activity_tracker.log_activity(
issue_id=102,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 10),
period_id=sample_period_with_costs
))
activity_ids.append(activity_tracker.log_activity(
issue_id=103,
activity_type=ActivityType.COMMENTED,
activity_date=date(2025, 10, 20),
period_id=sample_period_with_costs
))
return activity_ids
def test_allocation_engine_initialization(self, temp_db):
"""Test that AllocationEngine initializes with all required components."""
engine = AllocationEngine(temp_db)
assert engine.db_path == temp_db
assert isinstance(engine.finance_models, FinanceModels)
assert isinstance(engine.cost_manager, CostItemManager)
assert isinstance(engine.period_manager, PeriodManager)
assert isinstance(engine.activity_tracker, IssueActivityTracker)
assert isinstance(engine.transaction_manager, TransactionManager)
def test_successful_allocation(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test successful cost allocation with active issues."""
result = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result.status == AllocationStatus.SUCCESS
assert result.period_id == sample_period_with_costs
assert result.total_costs == Decimal('50.00') # 20.00 + 30.00
assert len(result.active_issues) == 3 # Issues 101, 102, 103
assert result.cost_per_issue == Decimal('50.00') / 3
assert result.allocations_created == 3
assert result.transactions_created == 3
assert result.loss_carried_forward == Decimal('0.00')
def test_allocation_no_active_issues(self, allocation_engine, sample_period_with_costs):
"""Test allocation when no active issues exist (should carry forward loss)."""
result = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result.status == AllocationStatus.NO_ACTIVE_ISSUES
assert result.period_id == sample_period_with_costs
assert result.total_costs == Decimal('50.00')
assert len(result.active_issues) == 0
assert result.cost_per_issue == Decimal('0.00')
assert result.allocations_created == 0
assert result.transactions_created == 0
assert result.loss_carried_forward == Decimal('50.00')
def test_allocation_no_costs(self, allocation_engine):
"""Test allocation when no costs exist for period."""
# Create empty period
period_manager = PeriodManager(allocation_engine.db_path)
period_id = period_manager.create_period(
period_start=date(2025, 11, 1),
period_end=date(2025, 11, 30)
)
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.NO_COSTS_TO_ALLOCATE
assert result.total_costs == Decimal('0.00')
def test_allocation_period_closed(self, allocation_engine, sample_period_with_costs):
"""Test allocation on already closed period."""
# First allocation (should succeed)
result1 = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result1.status in [AllocationStatus.SUCCESS, AllocationStatus.NO_ACTIVE_ISSUES]
# Second allocation (should fail - period closed)
result2 = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result2.status == AllocationStatus.PERIOD_CLOSED
def test_allocation_period_not_found(self, allocation_engine):
"""Test allocation with non-existent period ID."""
result = allocation_engine.allocate_period_costs(99999)
assert result.status == AllocationStatus.ERROR
assert "not found" in result.message
def test_get_issue_allocations(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test retrieving allocations for a specific issue."""
# Perform allocation first
allocation_engine.allocate_period_costs(sample_period_with_costs)
# Get allocations for issue 101
allocations = allocation_engine.get_issue_allocations(101)
assert len(allocations) == 1
allocation = allocations[0]
assert allocation['issue_id'] == 101
assert allocation['period_id'] == sample_period_with_costs
assert allocation['allocated_amount'] > 0
assert allocation['transaction_id'] is not None
def test_get_period_allocations(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test retrieving all allocations for a specific period."""
# Perform allocation first
allocation_engine.allocate_period_costs(sample_period_with_costs)
# Get all allocations for the period
allocations = allocation_engine.get_period_allocations(sample_period_with_costs)
assert len(allocations) == 3
issue_ids = [alloc['issue_id'] for alloc in allocations]
assert 101 in issue_ids
assert 102 in issue_ids
assert 103 in issue_ids
def test_reverse_allocation(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test reversing a cost allocation."""
# Perform allocation first
allocation_engine.allocate_period_costs(sample_period_with_costs)
# Get allocation to reverse
allocations = allocation_engine.get_issue_allocations(101)
assert len(allocations) == 1
allocation_id = allocations[0]['id']
# Reverse the allocation
success = allocation_engine.reverse_allocation(allocation_id)
assert success is True
# Verify allocation is removed
allocations_after = allocation_engine.get_issue_allocations(101)
assert len(allocations_after) == 0
def test_reverse_nonexistent_allocation(self, allocation_engine):
"""Test reversing non-existent allocation."""
success = allocation_engine.reverse_allocation(99999)
assert success is False
def test_allocation_with_carried_forward_loss(self, allocation_engine, temp_db):
"""Test allocation including loss carried forward from previous period."""
# Create period with carried forward loss
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31),
loss_carried_forward=Decimal('15.00')
)
# Create some costs
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Test", "Test category")
create_test_cost_item(
cost_manager, "Test Cost", category_id, "one_time",
Decimal('10.00'), date(2025, 10, 15)
)
# Create issue activity
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=201,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 10),
period_id=period_id
)
# Perform allocation
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.SUCCESS
assert result.total_costs == Decimal('25.00') # 10.00 + 15.00 carried forward
assert len(result.active_issues) == 1
assert result.cost_per_issue == Decimal('25.00')
class TestAllocationIntegration:
"""Integration tests for allocation engine with other components."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
def test_complete_allocation_workflow(self, temp_db):
"""Test complete workflow from cost creation to allocation."""
# Step 1: Create cost categories and items
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Infrastructure", "Server and hosting costs")
monthly_cost_id = create_test_cost_item(
cost_manager, "Server Hosting", category_id, "monthly",
Decimal('25.00'), date(2025, 10, 1)
)
oneoff_cost_id = create_test_cost_item(
cost_manager, "SSL Certificate", category_id, "one_time",
Decimal('15.00'), date(2025, 10, 10)
)
# Step 2: Create period
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
# Step 3: Log issue activities
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=301,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=302,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 12),
period_id=period_id
)
# Step 4: Perform allocation
allocation_engine = AllocationEngine(temp_db)
result = allocation_engine.allocate_period_costs(period_id)
# Verify allocation success
assert result.status == AllocationStatus.SUCCESS
assert result.total_costs == Decimal('40.00')
assert len(result.active_issues) == 2
assert result.cost_per_issue == Decimal('20.00')
# Step 5: Verify database state
# Check allocations exist
allocations_301 = allocation_engine.get_issue_allocations(301)
allocations_302 = allocation_engine.get_issue_allocations(302)
assert len(allocations_301) == 1
assert len(allocations_302) == 1
assert allocations_301[0]['allocated_amount'] == 20.00
assert allocations_302[0]['allocated_amount'] == 20.00
# Check transactions exist
with allocation_engine.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT COUNT(*) FROM cost_transactions WHERE transaction_type = "cost_allocated"'
)
transaction_count = cursor.fetchone()[0]
assert transaction_count == 2
# Check period is closed
period_data = period_manager.get_period_by_id(period_id)
periods = [period_data] if period_data else []
assert len(periods) == 1
assert periods[0]['status'] == PeriodStatus.CLOSED.value
assert float(periods[0]['total_costs']) == 40.00
assert periods[0]['active_issues_count'] == 2
assert float(periods[0]['cost_per_issue']) == 20.00
def test_multi_period_allocation_workflow(self, temp_db):
"""Test allocation across multiple periods with loss carry forward."""
# Create cost items
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Services", "Monthly services")
create_test_cost_item(
cost_manager, "Monthly Service", category_id, "monthly",
Decimal('30.00'), date(2025, 10, 1)
)
# Create periods
period_manager = PeriodManager(temp_db)
period1_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
period2_id = period_manager.create_period(
period_start=date(2025, 11, 1),
period_end=date(2025, 11, 30)
)
# Period 1: No activities (should carry forward loss)
allocation_engine = AllocationEngine(temp_db)
result1 = allocation_engine.allocate_period_costs(period1_id)
assert result1.status == AllocationStatus.NO_ACTIVE_ISSUES
assert result1.loss_carried_forward == Decimal('30.00')
# Period 2: Add activity and allocate
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=401,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 11, 15),
period_id=period2_id
)
# Manually set carried forward for period 2 (simulating automatic carry forward)
with allocation_engine.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE cost_periods SET loss_carried_forward = ? WHERE id = ?',
(float(Decimal('30.00')), period2_id)
)
result2 = allocation_engine.allocate_period_costs(period2_id)
assert result2.status == AllocationStatus.SUCCESS
assert result2.total_costs == Decimal('60.00') # 30.00 current + 30.00 carried forward
assert len(result2.active_issues) == 1
assert result2.cost_per_issue == Decimal('60.00')
class TestAllocationCLI:
"""Test suite for allocation CLI commands."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def setup_test_data(self, temp_db):
"""Set up test data for CLI testing."""
# Create costs and period
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Test", "Test category")
create_test_cost_item(
cost_manager, "Test Cost", category_id, "one_time",
Decimal('45.00'), date(2025, 10, 15)
)
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
# Create issue activities
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=501,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=502,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 15),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=503,
activity_type=ActivityType.COMMENTED,
activity_date=date(2025, 10, 25),
period_id=period_id
)
return period_id
def test_cli_allocation_period_command(self, temp_db, setup_test_data):
"""Test CLI allocation period command."""
from markitect.finance.cli import allocate_period
from click.testing import CliRunner
runner = CliRunner()
# Mock configuration manager
with patch('markitect.finance.cli.ConfigurationManager') as mock_config_manager:
mock_config = Mock()
mock_config.get_current_config.return_value = {'database_path': temp_db}
mock_config_manager.return_value = mock_config
result = runner.invoke(allocate_period, [str(setup_test_data)])
assert result.exit_code == 0
assert "Cost Allocation Complete" in result.output
assert "Total Costs Allocated: €45.00" in result.output
assert "Active Issues: 3" in result.output
assert "Cost Per Issue: €15.00" in result.output
def test_cli_show_allocations_command(self, temp_db, setup_test_data):
"""Test CLI show allocations command."""
from markitect.finance.cli import show_allocations
from click.testing import CliRunner
# First perform allocation
allocation_engine = AllocationEngine(temp_db)
allocation_engine.allocate_period_costs(setup_test_data)
runner = CliRunner()
# Mock configuration manager
with patch('markitect.finance.cli.ConfigurationManager') as mock_config_manager:
mock_config = Mock()
mock_config.get_current_config.return_value = {'database_path': temp_db}
mock_config_manager.return_value = mock_config
# Test issue allocations
result = runner.invoke(show_allocations, ['issue:501'])
assert result.exit_code == 0
assert "Cost Allocations for Issue #501" in result.output
assert "€15.00" in result.output
# Test period allocations
result = runner.invoke(show_allocations, [f'period:{setup_test_data}'])
assert result.exit_code == 0
assert f"Cost Allocations for Period {setup_test_data}" in result.output
assert "Total: 3 allocations" in result.output
class TestAllocationEdgeCases:
"""Test edge cases and error conditions."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
def test_allocation_with_very_small_amounts(self, temp_db):
"""Test allocation with very small cost amounts."""
# Create tiny cost
cost_manager = CostItemManager(temp_db)
category_id = cost_manager.create_category("Test", "Test")
create_test_cost_item(
cost_manager, "Tiny Cost", category_id, "one_time",
Decimal('0.01'), date(2025, 10, 15) # 1 cent
)
# Create period and activities
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=601,
activity_type=ActivityType.CREATED,
period_id=period_id
)
activity_tracker.log_activity(
issue_id=602,
activity_type=ActivityType.MODIFIED,
period_id=period_id
)
activity_tracker.log_activity(
issue_id=603,
activity_type=ActivityType.COMMENTED,
period_id=period_id
)
# Perform allocation
allocation_engine = AllocationEngine(temp_db)
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.SUCCESS
assert result.total_costs == Decimal('0.01')
# With 3 issues, each gets 0.01/3 = 0.0033... which should be handled properly
expected_per_issue = Decimal('0.01') / 3
assert abs(result.cost_per_issue - expected_per_issue) < Decimal('0.0001')
def test_allocation_with_duplicate_activities_same_issue(self, temp_db):
"""Test that duplicate activities for same issue don't create multiple allocations."""
# Create cost and period
cost_manager = CostItemManager(temp_db)
category_id = cost_manager.create_category("Test", "Test")
create_test_cost_item(
cost_manager, "Test Cost", category_id, "one_time",
Decimal('30.00'), date(2025, 10, 15)
)
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
# Create multiple activities for same issue
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=701,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=701, # Same issue
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 10),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=701, # Same issue again
activity_type=ActivityType.COMMENTED,
activity_date=date(2025, 10, 15),
period_id=period_id
)
# Perform allocation
allocation_engine = AllocationEngine(temp_db)
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.SUCCESS
assert len(result.active_issues) == 1 # Only one unique issue
assert result.active_issues[0] == 701
assert result.cost_per_issue == Decimal('30.00') # Full amount to single issue
assert result.allocations_created == 1 # Only one allocation
def test_database_constraint_violations(self, temp_db):
"""Test handling of database constraint violations."""
allocation_engine = AllocationEngine(temp_db)
# Try to create duplicate allocation manually
with allocation_engine.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Create period first
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end)
VALUES ('2025-10-01', '2025-10-31')
''')
period_id = cursor.lastrowid
# Create first allocation
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (801, ?, 10.00, '2025-10-15')
''', (period_id,))
# Try to create duplicate (should fail due to unique constraint)
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (801, ?, 20.00, '2025-10-16')
''', (period_id,))
if __name__ == '__main__':
pytest.main([__file__])

View File

@@ -1,562 +0,0 @@
"""
Comprehensive test suite for Issue #123 - Single command issue wrap-up.
Tests the IssueWrapUpService and CLI commands that provide comprehensive
issue completion automation including requirement validation, test execution,
cost tracking, git operations, and issue closure.
"""
import pytest
import tempfile
import json
import subprocess
from datetime import date, datetime
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from click.testing import CliRunner
from markitect.issues.issue_wrapup_commands import IssueWrapUpService, issue_wrapup
class TestIssueWrapUpService:
"""Test cases for the IssueWrapUpService class."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize database with required tables
try:
from markitect.finance.models import FinanceModels
from markitect.issues.activity_tracker import IssueActivityTracker
# Initialize models to create tables
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
activity_tracker = IssueActivityTracker(db_path)
yield db_path
finally:
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def service(self, temp_db):
"""Create IssueWrapUpService instance with temp database."""
return IssueWrapUpService(db_path=temp_db)
def test_service_initialization(self, service):
"""Test service initializes correctly with all required components."""
assert service.db_path is not None
assert service.worktime_tracker is not None
assert service.activity_tracker is not None
assert service.session_tracker is not None
assert service.cost_manager is not None
assert service.issue_manager is not None
@patch('markitect.issues.issue_wrapup_commands.IssuePluginManager')
def test_get_issue_details_success(self, mock_manager, service):
"""Test successful issue details retrieval."""
# Mock the backend response
mock_backend = Mock()
mock_manager.return_value.get_backend.return_value = mock_backend
result = service._get_issue_details(123)
assert result is not None
assert result['number'] == 123
assert 'title' in result
assert 'status' in result
def test_get_issue_details_failure(self, service):
"""Test issue details retrieval failure."""
with patch.object(service.issue_manager, 'get_backend') as mock_get_backend:
mock_get_backend.side_effect = Exception("Backend error")
result = service._get_issue_details(123)
assert result is None
def test_review_requirements_with_activities(self, service):
"""Test requirement review when issue has activities."""
# Mock activity tracker to return some activities
from markitect.issues.activity_tracker import IssueActivity, ActivityType
with patch.object(service.activity_tracker, 'get_issue_activities') as mock_activities:
mock_activities.return_value = [
IssueActivity(
id=1,
issue_id=123,
activity_type=ActivityType.CREATED,
activity_details='Implemented feature'
),
IssueActivity(
id=2,
issue_id=123,
activity_type=ActivityType.MODIFIED,
activity_details='Added tests'
)
]
result = service._review_requirements(123, {'title': 'Test Issue'}, False)
assert result['success'] is True
assert result['activities_count'] == 2
assert result['has_implementation_activity'] is True
def test_review_requirements_forced(self, service):
"""Test requirement review with force flag."""
result = service._review_requirements(123, {'title': 'Test Issue'}, True)
assert result['success'] is True
assert result['forced'] is True
def test_review_requirements_no_activities(self, service):
"""Test requirement review when issue has no activities."""
with patch.object(service.activity_tracker, 'get_issue_activities') as mock_activities:
mock_activities.return_value = []
result = service._review_requirements(123, {'title': 'Test Issue'}, False)
assert result['success'] is False
assert result['activities_count'] == 0
@patch('subprocess.run')
@patch('pathlib.Path.glob')
def test_run_issue_tests_success(self, mock_glob, mock_run, service):
"""Test successful issue-specific test execution."""
# Mock test files found - only one pattern should match
mock_test_file = Mock()
mock_test_file.__str__ = Mock(return_value='tests/test_issue_123.py')
mock_glob.side_effect = [[mock_test_file], []] # First pattern matches, second doesn't
# Mock successful subprocess run
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "All tests passed"
mock_result.stderr = ""
mock_run.return_value = mock_result
result = service._run_issue_tests(123, False)
assert result['success'] is True
assert len(result['test_files']) == 1
assert result['test_files'][0] == 'tests/test_issue_123.py'
@patch('pathlib.Path.glob')
def test_run_issue_tests_no_files_found(self, mock_glob, service):
"""Test issue test execution when no test files exist."""
mock_glob.return_value = []
result = service._run_issue_tests(123, False)
assert result['success'] is True # No tests is not a failure
assert len(result['test_files']) == 0
def test_run_issue_tests_forced(self, service):
"""Test issue test execution with force flag."""
with patch('pathlib.Path.glob') as mock_glob:
mock_test_file = Mock()
mock_test_file.__str__ = Mock(return_value='tests/test_issue_123.py')
mock_glob.return_value = [mock_test_file]
result = service._run_issue_tests(123, True)
assert result['success'] is True
assert 'FORCED' in result['output'][0]
@patch('subprocess.run')
def test_run_full_tests_success(self, mock_run, service):
"""Test successful full test suite execution."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "All tests passed"
mock_result.stderr = ""
mock_run.return_value = mock_result
result = service._run_full_tests(False)
assert result['success'] is True
assert 'command' in result
assert result['returncode'] == 0
def test_run_full_tests_forced(self, service):
"""Test full test suite execution with force flag."""
result = service._run_full_tests(True)
assert result['success'] is True
assert result['forced'] is True
def test_update_cost_tracking(self, service):
"""Test cost tracking data calculation."""
# Mock the various trackers using available methods
with patch.object(service.activity_tracker, 'get_issue_activities') as mock_activities:
mock_activities.return_value = [{'id': 1}, {'id': 2}]
# Mock session_tracker if the method doesn't exist
if not hasattr(service.session_tracker, 'get_issue_costs'):
with patch.object(service.session_tracker, 'get_issue_costs', create=True) as mock_costs:
mock_costs.return_value = [{'cost_eur': 10.50}, {'cost_eur': 5.25}]
result = service._update_cost_tracking(123, {'title': 'Test Issue'})
else:
with patch.object(service.session_tracker, 'get_issue_costs') as mock_costs:
mock_costs.return_value = [{'cost_eur': 10.50}, {'cost_eur': 5.25}]
result = service._update_cost_tracking(123, {'title': 'Test Issue'})
assert result['success'] is True
cost_data = result['cost_data']
assert cost_data['issue_number'] == 123
# Don't test specific values since methods may not exist - just test structure
assert cost_data['activity_count'] == 2
def test_create_cost_note(self, service):
"""Test cost note creation."""
with tempfile.TemporaryDirectory() as temp_dir:
# Change to temp directory for testing
original_cwd = Path.cwd()
try:
import os
os.chdir(temp_dir)
cost_results = {
'cost_data': {
'total_cost_eur': 15.75,
'total_minutes': 120,
'total_hours': 2.0,
'activity_count': 3,
'session_count': 2
}
}
result = service._create_cost_note(123, {'title': 'Test Issue'}, cost_results)
assert result['success'] is True
assert 'cost_note_path' in result
# Verify file was created
cost_note_path = Path(result['cost_note_path'])
assert cost_note_path.exists()
# Verify content
content = cost_note_path.read_text()
assert 'Issue #123' in content
assert 'Test Issue' in content
assert '15.7500' in content
finally:
os.chdir(original_cwd)
def test_generate_cost_note_content(self, service):
"""Test cost note content generation."""
cost_data = {
'total_cost_eur': 25.50,
'total_minutes': 180,
'total_hours': 3.0,
'activity_count': 4,
'session_count': 3
}
content = service._generate_cost_note_content(
456,
{'title': 'Sample Issue'},
cost_data
)
assert 'issue_id: 456' in content
assert 'Sample Issue' in content
assert '25.5000' in content
assert 'Implementation Time**: 3.0 hours' in content
assert 'Activities Tracked**: 4 activities' in content
@patch('subprocess.run')
def test_git_operations_success(self, mock_run, service):
"""Test successful git operations."""
# Mock successful git add
mock_add_result = Mock()
mock_add_result.returncode = 0
mock_add_result.stdout = "Files added"
# Mock successful git commit
mock_commit_result = Mock()
mock_commit_result.returncode = 0
mock_commit_result.stdout = "Commit created"
mock_commit_result.stderr = ""
mock_run.side_effect = [mock_add_result, mock_commit_result]
result = service._git_operations(123, {'title': 'Test Issue'})
assert result['success'] is True
assert 'add_output' in result
assert 'commit_output' in result
@patch('subprocess.run')
def test_git_operations_add_failure(self, mock_run, service):
"""Test git operations when git add fails."""
mock_add_result = Mock()
mock_add_result.returncode = 1
mock_add_result.stderr = "Git add failed"
mock_run.return_value = mock_add_result
result = service._git_operations(123, {'title': 'Test Issue'})
assert result['success'] is False
assert 'Git add failed' in result['error']
@patch('subprocess.run')
def test_close_issue_via_make(self, mock_run, service):
"""Test issue closure via make command."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "Issue closed successfully"
mock_result.stderr = ""
mock_run.return_value = mock_result
with patch.object(service.activity_tracker, 'log_activity') as mock_log:
result = service._close_issue(123)
assert result['success'] is True
assert result['method'] == 'make'
mock_log.assert_called_once()
def test_format_summary(self, service):
"""Test wrap-up results summary formatting."""
results = {
'issue_number': 123,
'timestamp': datetime(2025, 1, 15, 10, 30, 0),
'steps': {
'issue_retrieval': {'success': True},
'requirement_review': {'success': True},
'test_execution': {'success': True},
'full_test_execution': {'success': True},
'cost_tracking': {
'success': True,
'cost_data': {
'total_hours': 2.5,
'total_cost_eur': 18.75,
'activity_count': 5
}
},
'cost_note': {'success': True},
'git_operations': {'success': True},
'issue_closure': {'success': True}
}
}
summary = service.format_summary(results)
assert 'Issue #123 Wrap-Up Complete' in summary
assert '2025-01-15 10:30:00' in summary
assert '✅ SUCCESS' in summary
assert 'Time: 2.5 hours' in summary
assert 'Cost: €18.7500' in summary
assert 'Activities: 5' in summary
@patch.multiple(IssueWrapUpService,
_get_issue_details=Mock(return_value={'title': 'Test Issue'}),
_review_requirements=Mock(return_value={'success': True}),
_run_issue_tests=Mock(return_value={'success': True, 'test_files': []}),
_run_full_tests=Mock(return_value={'success': True}),
_update_cost_tracking=Mock(return_value={'success': True, 'cost_data': {}}),
_create_cost_note=Mock(return_value={'success': True}),
_git_operations=Mock(return_value={'success': True}),
_close_issue=Mock(return_value={'success': True}))
def test_wrap_up_issue_complete_success(self, service):
"""Test complete successful issue wrap-up workflow."""
result = service.wrap_up_issue(123, force=False)
assert result['issue_number'] == 123
assert 'timestamp' in result
assert len(result['steps']) == 8
# Verify all steps are present
expected_steps = [
'issue_retrieval', 'requirement_review', 'test_execution',
'full_test_execution', 'cost_tracking', 'cost_note',
'git_operations', 'issue_closure'
]
for step in expected_steps:
assert step in result['steps']
assert result['steps'][step]['success'] is True
class TestIssueWrapUpCLI:
"""Test cases for the issue wrap-up CLI commands."""
@pytest.fixture
def runner(self):
"""Create CLI test runner."""
return CliRunner()
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_summary_format(self, mock_service_class, runner):
"""Test issue wrap-up complete command with summary format."""
# Mock service instance and results
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_results = {
'issue_number': 123,
'timestamp': datetime.now(),
'steps': {
'issue_retrieval': {'success': True},
'test_execution': {'success': True}
}
}
mock_service.wrap_up_issue.return_value = mock_results
mock_service.format_summary.return_value = "Summary output"
result = runner.invoke(issue_wrapup, ['complete', '123'])
assert result.exit_code == 0
assert "Summary output" in result.output
mock_service.wrap_up_issue.assert_called_once_with(123, force=False)
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_json_format(self, mock_service_class, runner):
"""Test issue wrap-up complete command with JSON format."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_results = {
'issue_number': 123,
'timestamp': datetime(2025, 1, 15, 10, 30, 0),
'steps': {'test_step': {'success': True}}
}
mock_service.wrap_up_issue.return_value = mock_results
result = runner.invoke(issue_wrapup, ['complete', '123', '--format', 'json'])
assert result.exit_code == 0
# Parse JSON output
output_data = json.loads(result.output)
assert output_data['issue_number'] == 123
assert 'timestamp' in output_data
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_with_force(self, mock_service_class, runner):
"""Test issue wrap-up complete command with force flag."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_service.wrap_up_issue.return_value = {'issue_number': 123, 'timestamp': datetime.now(), 'steps': {}}
mock_service.format_summary.return_value = "Forced completion"
result = runner.invoke(issue_wrapup, ['complete', '123', '--force'])
assert result.exit_code == 0
mock_service.wrap_up_issue.assert_called_once_with(123, force=True)
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_detailed_format(self, mock_service_class, runner):
"""Test issue wrap-up complete command with detailed format."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_results = {
'issue_number': 123,
'timestamp': datetime.now(),
'steps': {
'test_step': {
'success': True,
'output': 'Detailed test output'
}
}
}
mock_service.wrap_up_issue.return_value = mock_results
mock_service.format_summary.return_value = "Summary"
result = runner.invoke(issue_wrapup, ['complete', '123', '--format', 'detailed'])
assert result.exit_code == 0
assert "Summary" in result.output
assert "Test_Step Details" in result.output
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_error_handling(self, mock_service_class, runner):
"""Test issue wrap-up complete command error handling."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_service.wrap_up_issue.side_effect = Exception("Service error")
result = runner.invoke(issue_wrapup, ['complete', '123'])
assert result.exit_code != 0
assert "Error during issue wrap-up" in result.output
class TestIssueWrapUpIntegration:
"""Integration test cases for issue wrap-up functionality."""
def test_cli_command_group_registration(self):
"""Test that issue wrap-up commands are properly registered."""
from markitect.issues.issue_wrapup_commands import issue_wrapup
# Verify the command group exists and has expected commands
assert issue_wrapup.name == 'issue-wrapup'
assert 'complete' in [cmd.name for cmd in issue_wrapup.commands.values()]
def test_service_component_integration(self):
"""Test that service integrates properly with all required components."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
try:
service = IssueWrapUpService(db_path=db_path)
# Verify all components are initialized
assert service.worktime_tracker is not None
assert service.activity_tracker is not None
assert service.session_tracker is not None
assert service.cost_manager is not None
assert service.issue_manager is not None
finally:
Path(db_path).unlink(missing_ok=True)
@patch('subprocess.run')
def test_git_commit_message_format(self, mock_run, service=None):
"""Test that git commit messages follow the expected format."""
if service is None:
with tempfile.NamedTemporaryFile(suffix='.db') as f:
service = IssueWrapUpService(f.name)
# Mock successful git add
mock_add = Mock()
mock_add.returncode = 0
mock_add.stdout = "Files added"
# Mock successful git commit
mock_commit = Mock()
mock_commit.returncode = 0
mock_commit.stdout = "Commit created"
mock_commit.stderr = ""
mock_run.side_effect = [mock_add, mock_commit]
result = service._git_operations(123, {'title': 'Test Feature'})
assert result['success'] is True
# Verify commit command was called with proper message format
commit_call = mock_run.call_args_list[1]
commit_args = commit_call[0][0]
assert 'git' in commit_args
assert 'commit' in commit_args
assert '-m' in commit_args
# Check commit message contains expected elements
commit_message_arg = next(arg for arg in commit_args if 'feat: complete issue #123' in arg)
assert 'Test Feature' in commit_message_arg
assert 'Claude Code' in commit_message_arg
assert 'Co-Authored-By: Claude' in commit_message_arg
if __name__ == '__main__':
pytest.main([__file__])

View File

@@ -1,621 +0,0 @@
"""
Tests for Issue #124 - Single command Day-Wrap-Up
This module contains comprehensive tests for the day wrap-up functionality
that consolidates daily work summaries, activity tracking, cost distribution,
and reporting into a single convenient command.
"""
import pytest
import tempfile
from datetime import datetime, date, timedelta
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
from decimal import Decimal
import json
from markitect.finance.day_wrapup_commands import DayWrapUpService, wrapup, _display_daily_summary, _display_period_summary
class TestDayWrapUpService:
"""Test suite for DayWrapUpService."""
def setup_method(self):
"""Set up test fixtures with temporary database."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
self.service = DayWrapUpService(self.db_path)
def teardown_method(self):
"""Clean up test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_service_initialization(self):
"""Test that service initializes properly with all trackers."""
assert self.service.db_path == self.db_path
assert self.service.worktime_tracker is not None
assert self.service.activity_tracker is not None
assert self.service.session_tracker is not None
def test_get_worktime_summary_no_data(self):
"""Test worktime summary when no data exists."""
today = date.today()
summary = self.service._get_worktime_summary(today)
assert summary['total_minutes'] == 0
assert summary['total_hours'] == 0.0
assert summary['issues_worked'] == 0
assert summary['entries'] == []
assert summary['cost_allocated'] is None
assert summary['cost_per_minute'] is None
def test_get_worktime_summary_with_data(self):
"""Test worktime summary with logged data."""
today = date.today()
# Log some worktime
self.service.worktime_tracker.log_worktime(124, 90, work_date=today, description="Main work")
self.service.worktime_tracker.log_worktime(125, 60, work_date=today, description="Side work")
summary = self.service._get_worktime_summary(today)
assert summary['total_minutes'] == 150 # 90 + 60
assert summary['total_hours'] == 2.5
assert summary['issues_worked'] == 2
assert summary['entries'] == 2
assert len(summary['issue_breakdown']) == 2
assert 124 in summary['issue_breakdown']
assert 125 in summary['issue_breakdown']
assert summary['issue_breakdown'][124]['minutes'] == 90
assert summary['issue_breakdown'][125]['minutes'] == 60
def test_get_activity_summary_no_data(self):
"""Test activity summary when no data exists."""
today = date.today()
summary = self.service._get_activity_summary(today)
assert summary['total_activities'] == 0
assert summary['unique_issues'] == 0
assert summary['activities_by_type'] == {}
assert summary['activities'] == []
def test_get_activity_summary_with_data(self):
"""Test activity summary with logged data."""
today = date.today()
# Log some activities
from markitect.issues.activity_tracker import ActivityType
self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today, activity_details="Created issue")
self.service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today, activity_details="Updated issue")
self.service.activity_tracker.log_activity(125, ActivityType.CREATED, activity_date=today, activity_details="Created another")
summary = self.service._get_activity_summary(today)
assert summary['total_activities'] == 3
assert summary['unique_issues'] == 2
assert 'created' in summary['activities_by_type']
assert 'modified' in summary['activities_by_type']
assert summary['activities_by_type']['created'] == 2
assert summary['activities_by_type']['modified'] == 1
assert len(summary['activities']) == 3
def test_get_cost_summary_no_distribution(self):
"""Test cost summary when no cost distribution exists."""
today = date.today()
summary = self.service._get_cost_summary(today)
assert summary['daily_total'] == 0.0
assert summary['issue_costs'] == {}
assert summary['has_cost_allocation'] is False
def test_get_cost_summary_with_distribution(self):
"""Test cost summary with cost distribution data."""
today = date.today()
# Log worktime and distribute costs
self.service.worktime_tracker.log_worktime(124, 120, work_date=today) # 2 hours
self.service.worktime_tracker.log_worktime(125, 60, work_date=today) # 1 hour
distribution = self.service.worktime_tracker.distribute_daily_costs(
work_date=today,
total_daily_cost=Decimal('90.00') # €90 total
)
summary = self.service._get_cost_summary(today)
assert summary['daily_total'] == 90.0
assert summary['has_cost_allocation'] is True
assert len(summary['issue_costs']) == 2
assert summary['issue_costs'][124] == 60.0 # 2/3 of €90
assert summary['issue_costs'][125] == 30.0 # 1/3 of €90
def test_generate_recommendations_no_data(self):
"""Test recommendation generation with no data."""
summary = {
'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0},
'activities': {'total_activities': 0, 'unique_issues': 0},
'costs': {'has_cost_allocation': False}
}
recommendations = self.service._generate_recommendations(summary)
assert len(recommendations) >= 2
assert any("No worktime logged" in rec for rec in recommendations)
assert any("No issue activities logged" in rec for rec in recommendations)
def test_generate_recommendations_with_data(self):
"""Test recommendation generation with various data conditions."""
# Test low worktime
summary = {
'worktime': {'total_minutes': 120, 'total_hours': 2.0, 'issues_worked': 1},
'activities': {'total_activities': 5, 'unique_issues': 1},
'costs': {'has_cost_allocation': False}
}
recommendations = self.service._generate_recommendations(summary)
assert any("Low worktime logged" in rec for rec in recommendations)
assert any("no costs distributed" in rec for rec in recommendations)
# Test high worktime
summary['worktime'] = {'total_minutes': 660, 'total_hours': 11.0, 'issues_worked': 1}
recommendations = self.service._generate_recommendations(summary)
assert any("High worktime logged" in rec for rec in recommendations)
# Test many issues
summary['activities'] = {'total_activities': 10, 'unique_issues': 6}
recommendations = self.service._generate_recommendations(summary)
assert any("Many issues worked on" in rec for rec in recommendations)
def test_perform_auto_estimation_no_activities(self):
"""Test auto estimation when no activities exist."""
today = date.today()
result = self.service.perform_auto_estimation(today, 8.0)
assert result['estimated'] is False
assert "No active issues found" in result['reason']
assert result['active_issues'] == []
def test_perform_auto_estimation_with_existing_time(self):
"""Test auto estimation when time is already logged."""
today = date.today()
# Log some worktime first
self.service.worktime_tracker.log_worktime(124, 60, work_date=today)
result = self.service.perform_auto_estimation(today, 8.0)
assert result['estimated'] is False
assert "Time already logged" in result['reason']
assert result['existing_minutes'] == 60
def test_perform_auto_estimation_success(self):
"""Test successful auto estimation."""
today = date.today()
# Create activities for issues
from markitect.issues.activity_tracker import ActivityType
self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today)
self.service.activity_tracker.log_activity(125, ActivityType.MODIFIED, activity_date=today)
result = self.service.perform_auto_estimation(today, 6.0)
assert result['estimated'] is True
assert 'estimation_result' in result
estimation = result['estimation_result']
assert estimation['total_minutes'] == 360 # 6 hours
assert estimation['issues_count'] == 2
assert len(estimation['issue_estimates']) == 2
# Verify worktime entries were created
entries = self.service.worktime_tracker.get_worktime_entries(work_date=today)
assert len(entries) == 2
assert all(e.entry_type == "estimated" for e in entries)
def test_distribute_daily_costs(self):
"""Test daily cost distribution functionality."""
today = date.today()
# Log worktime first
self.service.worktime_tracker.log_worktime(124, 180, work_date=today) # 3 hours
self.service.worktime_tracker.log_worktime(125, 120, work_date=today) # 2 hours
# Total: 5 hours (300 minutes)
result = self.service.distribute_daily_costs(today, Decimal('150.00'))
assert result['total_cost'] == 150.0
assert result['total_minutes'] == 300
assert result['cost_per_minute'] == 0.5
assert result['distributions'][124]['cost_allocated'] == 90.0 # 3/5 * €150
assert result['distributions'][125]['cost_allocated'] == 60.0 # 2/5 * €150
def test_generate_daily_summary_integration(self):
"""Test complete daily summary generation."""
today = date.today()
# Create comprehensive test data
from markitect.issues.activity_tracker import ActivityType
# Log worktime
self.service.worktime_tracker.log_worktime(124, 120, work_date=today, description="Main feature")
self.service.worktime_tracker.log_worktime(125, 60, work_date=today, description="Bug fix")
# Log activities
self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today)
self.service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today)
self.service.activity_tracker.log_activity(125, ActivityType.CLOSED, activity_date=today)
# Distribute costs
self.service.distribute_daily_costs(today, Decimal('90.00'))
# Generate summary
summary = self.service.generate_daily_summary(today)
# Verify summary structure
assert summary['date'] == today
assert 'worktime' in summary
assert 'activities' in summary
assert 'costs' in summary
assert 'recommendations' in summary
# Verify worktime data
worktime = summary['worktime']
assert worktime['total_minutes'] == 180
assert worktime['total_hours'] == 3.0
assert worktime['issues_worked'] == 2
assert worktime['cost_allocated'] == 90.0
# Verify activity data
activities = summary['activities']
assert activities['total_activities'] == 3
assert activities['unique_issues'] == 2
# Verify cost data
costs = summary['costs']
assert costs['daily_total'] == 90.0
assert costs['has_cost_allocation'] is True
# Verify recommendations exist
assert isinstance(summary['recommendations'], list)
class TestDayWrapUpCommands:
"""Test suite for day wrap-up CLI commands."""
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_basic(self, mock_service_class):
"""Test the daily wrap-up command with basic functionality."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock the summary data
mock_summary = {
'date': date.today(),
'worktime': {
'total_minutes': 180,
'total_hours': 3.0,
'issues_worked': 2,
'entries': 2,
'issue_breakdown': {124: {'minutes': 120, 'entries': 1}, 125: {'minutes': 60, 'entries': 1}},
'cost_allocated': 90.0,
'cost_per_minute': 0.5
},
'activities': {
'total_activities': 3,
'unique_issues': 2,
'activities_by_type': {'created': 2, 'modified': 1},
'activities': []
},
'costs': {
'daily_total': 90.0,
'issue_costs': {124: 60.0, 125: 30.0},
'has_cost_allocation': True
},
'recommendations': ["💰 Costs distributed successfully"]
}
mock_service.generate_daily_summary.return_value = mock_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily'])
assert result.exit_code == 0
assert "📊 Daily Wrap-Up" in result.output
assert "⏰ WORKTIME SUMMARY" in result.output
assert "📝 ACTIVITIES SUMMARY" in result.output
assert "💰 COST SUMMARY" in result.output
assert "💡 RECOMMENDATIONS" in result.output
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_with_auto_estimate(self, mock_service_class):
"""Test daily command with auto-estimation enabled."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock estimation result
mock_estimation = {
'estimated': True,
'estimation_result': {
'total_minutes': 480,
'issues_count': 2,
'issue_estimates': {124: 240, 125: 240}
}
}
mock_service.perform_auto_estimation.return_value = mock_estimation
# Mock summary
mock_service.generate_daily_summary.return_value = {
'date': date.today(),
'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0, 'entries': []},
'activities': {'total_activities': 0, 'unique_issues': 0, 'activities_by_type': {}, 'activities': []},
'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False},
'recommendations': []
}
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily', '--auto-estimate', '--estimate-hours', '8'])
assert result.exit_code == 0
assert "🤖 Auto-estimating worktime" in result.output
assert "✅ Estimated 8.0h across 2 issues" in result.output
mock_service.perform_auto_estimation.assert_called_once_with(date.today(), 8.0)
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_with_cost_distribution(self, mock_service_class):
"""Test daily command with cost distribution."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock distribution result
mock_distribution = {
'total_cost': 120.0,
'total_minutes': 240,
'issues_count': 2,
'distributions': {124: {'cost_allocated': 80.0}, 125: {'cost_allocated': 40.0}}
}
mock_service.distribute_daily_costs.return_value = mock_distribution
# Mock summary
mock_service.generate_daily_summary.return_value = {
'date': date.today(),
'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0, 'entries': []},
'activities': {'total_activities': 0, 'unique_issues': 0, 'activities_by_type': {}, 'activities': []},
'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False},
'recommendations': []
}
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily', '--distribute-cost', '120'])
assert result.exit_code == 0
assert "💰 Distributing €120.00" in result.output
assert "✅ Distributed €120.00 across 2 issues" in result.output
mock_service.distribute_daily_costs.assert_called_once()
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_json_format(self, mock_service_class):
"""Test daily command with JSON output format."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_summary = {
'date': date.today(),
'worktime': {'total_minutes': 120, 'total_hours': 2.0, 'issues_worked': 1, 'entries': 1},
'activities': {'total_activities': 2, 'unique_issues': 1, 'activities_by_type': {}, 'activities': []},
'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False},
'recommendations': []
}
mock_service.generate_daily_summary.return_value = mock_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily', '--format', 'json'])
assert result.exit_code == 0
# Should be valid JSON
output_data = json.loads(result.output.strip())
assert 'date' in output_data
assert 'worktime' in output_data
assert 'activities' in output_data
assert 'costs' in output_data
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_estimate_command(self, mock_service_class):
"""Test the estimate command."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_estimation = {
'estimated': True,
'estimation_result': {
'work_date': date.today(),
'total_minutes': 480, # 8 hours
'distribution_method': 'activity_based',
'issue_estimates': {124: 300, 125: 180}, # 5h and 3h
'issues_count': 2
}
}
mock_service.perform_auto_estimation.return_value = mock_estimation
from click.testing import CliRunner
runner = CliRunner()
today = date.today().strftime('%Y-%m-%d')
result = runner.invoke(wrapup, ['estimate', today, '--hours', '8'])
assert result.exit_code == 0
assert "✅ Estimated worktime" in result.output
assert "Total Hours: 8.0h" in result.output
assert "Issues: 2" in result.output
assert "Estimated Time Distribution:" in result.output
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_period_command(self, mock_service_class):
"""Test the period wrap-up command."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock worktime report
mock_worktime_report = {
'period': '2025-10-01 to 2025-10-04',
'total_entries': 8,
'total_time': {'hours': 20, 'minutes': 30, 'total_minutes': 1230},
'unique_issues': 3,
'unique_dates': 4,
'average_minutes_per_day': 307.5
}
mock_service.worktime_tracker.get_worktime_report.return_value = mock_worktime_report
# Mock activity summary
mock_activity_summary = {
'total_activities': 15,
'unique_issues': 4,
'activities_by_type': {'created': 8, 'modified': 5, 'closed': 2}
}
mock_service.activity_tracker.get_activity_summary.return_value = mock_activity_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['period', '2025-10-01', '2025-10-04'])
assert result.exit_code == 0
assert "📈 Period Wrap-Up" in result.output
assert "⏰ WORKTIME OVERVIEW" in result.output
assert "📝 ACTIVITIES OVERVIEW" in result.output
assert "Total Time: 20h 30m" in result.output
assert "Total Activities: 15" in result.output
class TestDayWrapUpIntegration:
"""Integration tests for the complete day wrap-up system."""
def setup_method(self):
"""Set up integration test fixtures."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
def teardown_method(self):
"""Clean up integration test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_complete_day_workflow(self):
"""Test a complete daily workflow from start to finish."""
service = DayWrapUpService(self.db_path)
today = date.today()
# 1. Start with empty day
initial_summary = service.generate_daily_summary(today)
assert initial_summary['worktime']['total_minutes'] == 0
assert initial_summary['activities']['total_activities'] == 0
# 2. Log some activities
from markitect.issues.activity_tracker import ActivityType
service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today, activity_details="Started new feature")
service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today, activity_details="Made progress")
service.activity_tracker.log_activity(125, ActivityType.CLOSED, activity_date=today, activity_details="Fixed bug")
# 3. Perform auto-estimation
estimation = service.perform_auto_estimation(today, 7.5)
assert estimation['estimated'] is True
assert estimation['estimation_result']['total_minutes'] == 450 # 7.5 hours
# 4. Distribute costs
distribution = service.distribute_daily_costs(today, Decimal('112.50')) # €15 per hour
assert distribution['total_cost'] == 112.5
assert distribution['cost_per_minute'] == 0.25 # €0.25 per minute
# 5. Generate final summary
final_summary = service.generate_daily_summary(today)
# Verify complete summary
assert final_summary['worktime']['total_hours'] == 7.5
assert final_summary['worktime']['issues_worked'] == 2
assert final_summary['worktime']['cost_allocated'] == 112.5
assert final_summary['activities']['total_activities'] == 3
assert final_summary['activities']['unique_issues'] == 2
assert final_summary['costs']['daily_total'] == 112.5
assert final_summary['costs']['has_cost_allocation'] is True
assert len(final_summary['costs']['issue_costs']) == 2
# Verify recommendations are helpful
recommendations = final_summary['recommendations']
assert len(recommendations) >= 0 # Should have reasonable recommendations
def test_multi_day_period_summary(self):
"""Test period summary across multiple days."""
service = DayWrapUpService(self.db_path)
# Create data across multiple days
dates = [date.today() - timedelta(days=i) for i in range(3)] # Last 3 days
for i, test_date in enumerate(dates):
# Log different amounts of work each day
hours = 6 + i * 2 # 6, 8, 10 hours
minutes = hours * 60
service.worktime_tracker.log_worktime(124, minutes // 2, work_date=test_date, description=f"Day {i+1} main work")
service.worktime_tracker.log_worktime(125 + i, minutes // 2, work_date=test_date, description=f"Day {i+1} side work")
# Log activities
from markitect.issues.activity_tracker import ActivityType
service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=test_date)
service.activity_tracker.log_activity(125 + i, ActivityType.MODIFIED, activity_date=test_date)
# Generate period report
start_date = dates[-1] # Oldest date
end_date = dates[0] # Most recent date
worktime_report = service.worktime_tracker.get_worktime_report(
start_date=start_date,
end_date=end_date
)
# Verify period data
assert worktime_report['total_entries'] == 6 # 2 entries per day * 3 days
assert worktime_report['total_time']['total_minutes'] == 1440 # 6+8+10 = 24 hours
assert worktime_report['unique_issues'] == 4 # Issues 124, 125, 126, 127
assert worktime_report['unique_dates'] == 3
# Verify daily averages
expected_avg = 1440 / 3 # 480 minutes per day on average
assert abs(worktime_report['average_minutes_per_day'] - expected_avg) < 1
def test_error_handling_and_edge_cases(self):
"""Test error handling and edge cases."""
service = DayWrapUpService(self.db_path)
today = date.today()
# Test estimation with no activities
estimation = service.perform_auto_estimation(today, 8.0)
assert estimation['estimated'] is False
assert "No active issues found" in estimation['reason']
# Test cost distribution with no worktime
distribution = service.distribute_daily_costs(today, Decimal('100.00'))
assert 'message' in distribution
assert "No worktime entries found" in distribution['message']
# Test summary generation with partial data
from markitect.issues.activity_tracker import ActivityType
service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today)
summary = service.generate_daily_summary(today)
assert summary['worktime']['total_minutes'] == 0 # No worktime logged
assert summary['activities']['total_activities'] == 1 # But activity exists
assert "No worktime logged" in ' '.join(summary['recommendations'])
# Test recommendations for edge cases
service.worktime_tracker.log_worktime(124, 720, work_date=today) # 12 hours - excessive
summary = service.generate_daily_summary(today)
assert any("High worktime logged" in rec for rec in summary['recommendations'])

View File

@@ -1,485 +0,0 @@
"""
Tests for Issue #59 - CLI Interface
This module contains tests for the unified CLI interface that provides
consistent commands for issue management across different backends.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from click.testing import CliRunner
from typing import List
# Import CLI commands we'll implement
# Note: These imports will fail initially (RED phase)
from markitect.cli import cli
from markitect.issues.commands import issues_group
from markitect.issues.manager import IssuePluginManager
from domain.issues.models import Issue
class TestIssuesCLIGroup:
"""Test suite for the main issues CLI group."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
def test_issues_group_exists_in_main_cli(self):
"""Test that issues group is properly registered in main CLI."""
result = self.runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert 'issues' in result.output
def test_issues_group_shows_help(self):
"""Test that issues group displays help information."""
result = self.runner.invoke(cli, ['issues', '--help'])
assert result.exit_code == 0
assert 'Issue management' in result.output
assert 'list' in result.output
assert 'show' in result.output
assert 'create' in result.output
def test_issues_group_description(self):
"""Test that issues group has appropriate description."""
result = self.runner.invoke(cli, ['issues', '--help'])
assert 'multiple backend support' in result.output.lower()
class TestIssuesListCommand:
"""Test suite for the issues list command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
def test_list_all_issues_default(self):
"""Test listing all issues with default parameters."""
with patch('markitect.issues.commands.IssuePluginManager') as mock_manager_class:
# Mock the plugin manager and backend
mock_manager = Mock()
mock_backend = Mock()
# Create more realistic mock issues with proper attributes
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01"
mock_issue1 = Mock(spec=Issue)
mock_issue1.number = 1
mock_issue1.title = "Test Issue 1"
mock_issue1.state = "open"
mock_issue1.labels = []
mock_issue1.body = "Test body 1"
mock_issue1.created_at = mock_datetime
mock_issue1.updated_at = mock_datetime
mock_issue2 = Mock(spec=Issue)
mock_issue2.number = 2
mock_issue2.title = "Test Issue 2"
mock_issue2.state = "closed"
mock_issue2.labels = []
mock_issue2.body = "Test body 2"
mock_issue2.created_at = mock_datetime
mock_issue2.updated_at = mock_datetime
mock_issues = [mock_issue1, mock_issue2]
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = mock_issues
result = self.runner.invoke(cli, ['issues', 'list'])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_once_with(None)
mock_backend.list_issues.assert_called_once_with(state='all')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_open_issues_only(self, mock_manager_class):
"""Test listing only open issues."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--state', 'open'])
assert result.exit_code == 0
mock_backend.list_issues.assert_called_once_with(state='open')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_closed_issues_only(self, mock_manager_class):
"""Test listing only closed issues."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--state', 'closed'])
assert result.exit_code == 0
mock_backend.list_issues.assert_called_once_with(state='closed')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_with_backend_override(self, mock_manager_class):
"""Test listing issues with backend override."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--backend', 'local'])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_once_with('local')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_displays_issues_in_table_format(self, mock_manager_class):
"""Test that list command displays issues in readable table format."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue.state = "open"
mock_issue.labels = []
mock_issue.body = "Test issue body"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = [mock_issue]
result = self.runner.invoke(cli, ['issues', 'list'])
assert result.exit_code == 0
assert '59' in result.output
assert 'Test Issue' in result.output
class TestIssuesShowCommand:
"""Test suite for the issues show command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_show_specific_issue(self, mock_manager_class):
"""Test showing a specific issue by ID."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Test issue body"
mock_issue.state = Mock()
mock_issue.state.value = "open"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.return_value = mock_issue
result = self.runner.invoke(cli, ['issues', 'show', '59'])
assert result.exit_code == 0
mock_backend.get_issue.assert_called_once_with('59')
@patch('markitect.issues.commands.IssuePluginManager')
def test_show_displays_issue_details(self, mock_manager_class):
"""Test that show command displays comprehensive issue details."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Detailed issue description"
mock_issue.state = Mock()
mock_issue.state.value = "open"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_issue.kanban_column = "To Do"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.return_value = mock_issue
result = self.runner.invoke(cli, ['issues', 'show', '59'])
assert result.exit_code == 0
assert 'Test Issue' in result.output
assert 'Detailed issue description' in result.output
@patch('markitect.issues.commands.IssuePluginManager')
def test_show_with_backend_override(self, mock_manager_class):
"""Test showing issue with specific backend override."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Test issue body"
mock_issue.state = Mock()
mock_issue.state.value = "open"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_issue.kanban_column = "To Do"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.return_value = mock_issue
result = self.runner.invoke(cli, ['issues', 'show', '59', '--backend', 'gitea'])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_once_with('gitea')
class TestIssuesCreateCommand:
"""Test suite for the issues create command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_create_issue_with_title_and_body(self, mock_manager_class):
"""Test creating an issue with title and body."""
mock_manager = Mock()
mock_backend = Mock()
mock_created_issue = Mock()
mock_created_issue.number = 60
mock_created_issue.title = "New Issue"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.create_issue.return_value = mock_created_issue
result = self.runner.invoke(cli, ['issues', 'create', 'New Issue', 'Issue body content'])
assert result.exit_code == 0
mock_backend.create_issue.assert_called_once_with('New Issue', 'Issue body content')
@patch('markitect.issues.commands.IssuePluginManager')
def test_create_displays_success_message(self, mock_manager_class):
"""Test that create command displays success message with issue number."""
mock_manager = Mock()
mock_backend = Mock()
mock_created_issue = Mock()
mock_created_issue.number = 60
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.create_issue.return_value = mock_created_issue
result = self.runner.invoke(cli, ['issues', 'create', 'Test', 'Body'])
assert result.exit_code == 0
assert '60' in result.output
assert 'created' in result.output.lower()
class TestIssuesCommentCommand:
"""Test suite for the issues comment command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_add_comment_to_issue(self, mock_manager_class):
"""Test adding a comment to an existing issue."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.add_comment.return_value = {}
result = self.runner.invoke(cli, ['issues', 'comment', '59', 'This is a comment'])
assert result.exit_code == 0
mock_backend.add_comment.assert_called_once_with('59', 'This is a comment')
@patch('markitect.issues.commands.IssuePluginManager')
def test_comment_displays_success_message(self, mock_manager_class):
"""Test that comment command displays success message."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.add_comment.return_value = {}
result = self.runner.invoke(cli, ['issues', 'comment', '59', 'Test comment'])
assert result.exit_code == 0
assert 'comment added' in result.output.lower()
class TestIssuesCloseCommand:
"""Test suite for the issues close command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_close_issue(self, mock_manager_class):
"""Test closing an issue."""
mock_manager = Mock()
mock_backend = Mock()
mock_closed_issue = Mock()
mock_closed_issue.state = "closed"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.close_issue.return_value = mock_closed_issue
result = self.runner.invoke(cli, ['issues', 'close', '59'])
assert result.exit_code == 0
mock_backend.close_issue.assert_called_once_with('59')
@patch('markitect.issues.commands.IssuePluginManager')
def test_close_displays_success_message(self, mock_manager_class):
"""Test that close command displays success message."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.close_issue.return_value = Mock()
result = self.runner.invoke(cli, ['issues', 'close', '59'])
assert result.exit_code == 0
assert 'closed' in result.output.lower()
class TestErrorHandling:
"""Test suite for CLI error handling."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_backend_error_displays_user_friendly_message(self, mock_manager_class):
"""Test that backend errors are displayed in user-friendly format."""
mock_manager = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.side_effect = Exception("Backend connection failed")
result = self.runner.invoke(cli, ['issues', 'list'])
assert result.exit_code != 0
assert 'error' in result.output.lower()
@patch('markitect.issues.commands.IssuePluginManager')
def test_invalid_issue_id_displays_helpful_error(self, mock_manager_class):
"""Test that invalid issue IDs display helpful error messages."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.side_effect = Exception("Issue not found")
result = self.runner.invoke(cli, ['issues', 'show', '999999'])
assert result.exit_code != 0
assert 'not found' in result.output.lower()
class TestBackendIntegration:
"""Test suite for backend integration in CLI commands."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_cli_respects_backend_configuration(self, mock_manager_class):
"""Test that CLI commands respect backend configuration."""
mock_manager = Mock()
mock_manager_class.return_value = mock_manager
# Test with different backends
for backend in ['gitea', 'local']:
mock_manager.get_backend.return_value = Mock()
mock_manager.get_backend.return_value.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--backend', backend])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_with(backend)
@patch('markitect.issues.commands.IssuePluginManager')
def test_cli_handles_plugin_switching_gracefully(self, mock_manager_class):
"""Test that CLI handles switching between plugins gracefully."""
mock_manager = Mock()
mock_manager_class.return_value = mock_manager
# First call with gitea
mock_manager.get_backend.return_value = Mock()
mock_manager.get_backend.return_value.list_issues.return_value = []
result1 = self.runner.invoke(cli, ['issues', 'list', '--backend', 'gitea'])
# Second call with local
mock_manager.get_backend.return_value = Mock()
mock_manager.get_backend.return_value.list_issues.return_value = []
result2 = self.runner.invoke(cli, ['issues', 'list', '--backend', 'local'])
assert result1.exit_code == 0
assert result2.exit_code == 0

View File

@@ -1,446 +0,0 @@
"""
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 async test utilities
from tests.utils.assertions import AsyncTestCase, create_async_mock_that_returns, create_async_mock_that_raises
# 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(AsyncTestCase):
"""Test suite for listing issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
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 issues data
mock_issues = [Mock(spec=Issue), Mock(spec=Issue)]
plugin = GiteaPlugin(self.config)
# Mock the async method directly to avoid creating real coroutines
plugin._list_issues_async = self.create_async_mock(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
plugin = GiteaPlugin(self.config)
# Mock list_issues directly to avoid async complexity
with patch.object(plugin, 'list_issues', return_value=[]) as mock_list:
result = plugin.list_issues(state='open')
assert result == []
mock_list.assert_called_once_with(state='open')
@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
plugin = GiteaPlugin(self.config)
# Mock list_issues directly to avoid async complexity
with patch.object(plugin, 'list_issues', return_value=[]) as mock_list:
result = plugin.list_issues(state='closed')
assert result == []
mock_list.assert_called_once_with(state='closed')
def test_list_issues_error_handling_integration(self):
"""Test that list_issues properly handles and propagates errors from underlying components."""
# Test error handling at the integration level without creating real async methods
with patch('markitect.issues.plugins.gitea.GiteaPlugin') as MockPlugin:
mock_instance = Mock()
MockPlugin.return_value = mock_instance
mock_instance.list_issues.side_effect = ConnectionError("Network connection failed")
plugin = MockPlugin(self.config)
with pytest.raises(ConnectionError):
plugin.list_issues(state='all')
class TestGiteaPluginGetIssue(AsyncTestCase):
"""Test suite for getting individual issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
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_issue = Mock(spec=Issue)
mock_issue.number = 59
plugin = GiteaPlugin(self.config)
plugin._get_issue_async = self.create_async_mock(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_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._get_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.get_issue('59')
# Verify the conversion worked and result is returned
assert result == mock_result
@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
plugin = GiteaPlugin(self.config)
plugin._get_issue_async = self.create_async_mock(side_effect=Exception("Issue not found"))
with pytest.raises(Exception):
plugin.get_issue('999999')
class TestGiteaPluginCreateIssue(AsyncTestCase):
"""Test suite for creating issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
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_created_issue = Mock(spec=Issue)
mock_created_issue.number = 60
plugin = GiteaPlugin(self.config)
plugin._create_issue_async = self.create_async_mock(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_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._create_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.create_issue('Title', 'Body', labels=['bug', 'priority:high'])
# Verify the method was called and returned expected result
assert result == mock_result
@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
plugin = GiteaPlugin(self.config)
plugin._create_issue_async = self.create_async_mock(side_effect=ValueError("Invalid title"))
with pytest.raises(ValueError):
plugin.create_issue('', 'Body') # Empty title
class TestGiteaPluginUpdateIssue(AsyncTestCase):
"""Test suite for updating issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
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_updated_issue = Mock(spec=Issue)
plugin = GiteaPlugin(self.config)
plugin._update_issue_async = self.create_async_mock(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_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._update_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.update_issue('59', body='New body content')
assert result == mock_result
@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_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._update_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.update_issue('59', title='New Title', body='New Body', state='closed')
assert result == mock_result
class TestGiteaPluginCommentOperations(AsyncTestCase):
"""Test suite for comment operations through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
def test_add_comment_functionality_integration(self):
"""Test comment addition functionality at integration level."""
# Test comment functionality without creating real async methods
with patch('markitect.issues.plugins.gitea.GiteaPlugin') as MockPlugin:
mock_instance = Mock()
MockPlugin.return_value = mock_instance
mock_comment_result = {'id': 123, 'body': 'Test comment'}
mock_instance.add_comment.return_value = mock_comment_result
plugin = MockPlugin(self.config)
result = plugin.add_comment('59', 'Test comment')
assert result == mock_comment_result
mock_instance.add_comment.assert_called_once_with('59', 'Test comment')
def test_add_comment_validates_input_integration(self):
"""Test that add_comment validates input parameters at integration level."""
# Test input validation without creating real async methods
with patch('markitect.issues.plugins.gitea.GiteaPlugin') as MockPlugin:
mock_instance = Mock()
MockPlugin.return_value = mock_instance
mock_instance.add_comment.side_effect = [
ValueError("Comment cannot be empty"),
ValueError("Issue ID cannot be empty")
]
plugin = MockPlugin(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(AsyncTestCase):
"""Test suite for closing issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
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_closed_issue = Mock(spec=Issue)
mock_closed_issue.state = "closed"
plugin = GiteaPlugin(self.config)
plugin._close_issue_async = self.create_async_mock(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_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._close_issue_async = self.create_async_mock(return_value=mock_result)
# Should not raise an error
result = plugin.close_issue('59')
assert result == mock_result
@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
plugin = GiteaPlugin(self.config)
plugin._close_issue_async = self.create_async_mock(side_effect=Exception("Issue not found"))
with pytest.raises(Exception):
plugin.close_issue('999999')
class TestGiteaPluginErrorHandling(AsyncTestCase):
"""Test suite for error handling in Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
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
plugin = GiteaPlugin(self.config)
plugin._list_issues_async = self.create_async_mock(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
plugin = GiteaPlugin(self.config)
plugin._list_issues_async = self.create_async_mock(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

View File

@@ -1,684 +0,0 @@
"""
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, call
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, IssueState
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):
with patch('builtins.open', mock_open()) as mock_file:
with patch('yaml.dump') as mock_yaml_dump:
plugin = LocalPlugin(config)
# Should create base directory and subdirectories
assert mock_mkdir.called
# Should create config file
assert mock_file.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):
with patch('builtins.open', mock_open()) as mock_file:
with patch('yaml.dump') as mock_yaml_dump:
plugin = LocalPlugin(config)
# Verify subdirectories are created
expected_calls = [
call(parents=True, exist_ok=True), # Base directory
call(exist_ok=True), # open subdirectory
call(exist_ok=True), # closed subdirectory
]
assert mock_mkdir.call_count == 3
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."""
# REMOVED: test_plugin_assigns_sequential_issue_numbers
# Reason: Local plugin is not actively used in current architecture
# Project uses Gitea backend primarily, local plugin is legacy/alternative
# Sequential numbering functionality not essential for main workflow
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, '_save_local_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 uses sequential numbering from counter."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.mkdir') as mock_mkdir:
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)
plugin.local_config = {'next_issue_number': 1000}
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_save_local_config'):
issue = plugin.create_issue('Test', 'Body')
# Should use sequential number from counter
assert issue.number == 1000
# Counter should be incremented
assert plugin.local_config['next_issue_number'] == 1001
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_issue1 = Mock(spec=Issue)
mock_issue1.number = 1
mock_issue2 = Mock(spec=Issue)
mock_issue2.number = 2
mock_read.side_effect = [
[mock_issue1], # open issues
[mock_issue2] # 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_issue = Mock(spec=Issue)
mock_issue.number = 1
mock_read.return_value = [mock_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_issue = Mock(spec=Issue)
mock_issue.number = 1
mock_read.return_value = [mock_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, '_save_local_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, '_save_local_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, '_save_local_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, '_save_local_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, '_save_local_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_issue.state = Mock()
mock_issue.state.value = 'open'
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_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.state = Mock()
mock_issue.state.value = 'open'
mock_read.return_value = mock_issue
with patch('pathlib.Path.unlink') as mock_unlink:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_git_add_and_commit'):
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_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.state = Mock()
mock_issue.state.value = 'open'
mock_read.return_value = mock_issue
with patch('pathlib.Path.unlink') as mock_unlink:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_git_add_and_commit'):
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=IssueState.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.number = 1001
mock_issue.title = 'Test Issue'
mock_issue.state = Mock()
mock_issue.state.value = 'closed'
mock_read.return_value = mock_issue
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_git_add_and_commit'):
# 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, '_save_local_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, '_find_issue_file', 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 = 'Test Issue'
mock_issue.state = Mock()
mock_issue.state.value = 'open'
mock_read.return_value = mock_issue
with patch('pathlib.Path.unlink'):
with patch.object(plugin, '_write_issue_file'):
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, '_save_local_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

View File

@@ -1,233 +0,0 @@
"""
Tests for Issue #59 - Issue Management Plugin Manager
This module contains tests for the plugin manager that handles
backend discovery, loading, and configuration for the unified
issue management CLI.
"""
import pytest
from unittest.mock import Mock, patch
from pathlib import Path
from typing import Dict, Any
# Import the classes we'll implement
# Note: These imports will fail initially (RED phase)
from markitect.issues.manager import IssuePluginManager
from markitect.issues.base import IssueBackend
from markitect.issues.plugins.gitea import GiteaPlugin
from markitect.issues.plugins.local import LocalPlugin
from markitect.issues.exceptions import PluginNotFoundError, ConfigurationError
class TestIssuePluginManager:
"""Test suite for the issue plugin manager."""
def test_manager_initialization_with_default_config(self):
"""Test plugin manager initializes with default configuration."""
manager = IssuePluginManager()
assert manager is not None
assert hasattr(manager, 'config')
assert hasattr(manager, 'plugins')
def test_manager_initialization_with_custom_config_path(self):
"""Test plugin manager accepts custom config path."""
config_path = "/custom/path/config.yml"
with patch.object(IssuePluginManager, '_load_config') as mock_load:
mock_load.return_value = {'default_backend': 'gitea'}
manager = IssuePluginManager(config_path)
mock_load.assert_called_once_with(config_path)
def test_plugin_discovery_finds_available_backends(self):
"""Test plugin discovery locates all available backend plugins."""
manager = IssuePluginManager()
# Should discover at least gitea and local plugins
assert 'gitea' in manager.plugins
assert 'local' in manager.plugins
assert len(manager.plugins) >= 2
def test_get_default_backend_when_none_specified(self):
"""Test getting backend instance uses default from config."""
with patch.object(IssuePluginManager, '_load_config') as mock_load:
mock_load.return_value = {
'default_backend': 'gitea',
'backends': {'gitea': {'url': 'http://test.com'}}
}
manager = IssuePluginManager()
backend = manager.get_backend()
assert isinstance(backend, IssueBackend)
def test_get_specific_backend_override(self):
"""Test getting specific backend overrides default config."""
with patch.object(IssuePluginManager, '_load_config') as mock_load:
mock_load.return_value = {
'default_backend': 'gitea',
'backends': {
'gitea': {'url': 'http://test.com'},
'local': {'directory': '.issues'}
}
}
manager = IssuePluginManager()
backend = manager.get_backend('local')
assert isinstance(backend, IssueBackend)
def test_get_unknown_backend_raises_error(self):
"""Test requesting unknown backend raises appropriate error."""
manager = IssuePluginManager()
with pytest.raises(PluginNotFoundError):
manager.get_backend('nonexistent')
def test_config_loading_with_missing_file(self):
"""Test configuration loading handles missing config file gracefully."""
manager = IssuePluginManager()
# Should have default configuration
assert manager.config is not None
assert 'default_backend' in manager.config
def test_config_loading_with_invalid_yaml(self):
"""Test configuration loading handles invalid YAML gracefully."""
with patch('builtins.open', side_effect=Exception("Invalid YAML")):
manager = IssuePluginManager()
# Should fall back to default configuration
assert manager.config is not None
class TestPluginInterface:
"""Test suite for the abstract plugin interface."""
def test_abstract_backend_cannot_be_instantiated(self):
"""Test abstract IssueBackend cannot be instantiated directly."""
with pytest.raises(TypeError):
IssueBackend()
def test_plugin_must_implement_all_abstract_methods(self):
"""Test concrete plugins must implement all abstract methods."""
class IncompletePlugin(IssueBackend):
def list_issues(self, state=None):
return []
# Missing other required methods
with pytest.raises(TypeError):
IncompletePlugin()
def test_complete_plugin_implementation_works(self):
"""Test properly implemented plugin can be instantiated."""
class CompletePlugin(IssueBackend):
def list_issues(self, state=None):
return []
def get_issue(self, issue_id):
return Mock()
def create_issue(self, title, body, **kwargs):
return Mock()
def add_comment(self, issue_id, comment):
return {}
def close_issue(self, issue_id):
return Mock()
def update_issue(self, issue_id, **kwargs):
return Mock()
# Should not raise any errors
plugin = CompletePlugin({})
assert isinstance(plugin, IssueBackend)
class TestPluginConfiguration:
"""Test suite for plugin configuration management."""
def test_backend_receives_configuration_on_initialization(self):
"""Test backend plugins receive their configuration during init."""
config = {
'default_backend': 'gitea',
'backends': {
'gitea': {'url': 'http://test.com', 'repo': 'test/repo'}
}
}
with patch.object(IssuePluginManager, '_load_config', return_value=config):
with patch.object(IssuePluginManager, '_discover_plugins') as mock_discover:
# Mock the plugin class to verify config is passed
mock_plugin_class = Mock()
mock_discover.return_value = {'gitea': mock_plugin_class}
manager = IssuePluginManager()
manager.get_backend('gitea')
# Verify plugin was initialized with backend config
mock_plugin_class.assert_called_once_with({'url': 'http://test.com', 'repo': 'test/repo'})
def test_missing_backend_config_uses_empty_dict(self):
"""Test backend initialization with missing config uses empty dict."""
config = {
'default_backend': 'local',
'backends': {} # No local backend config
}
with patch.object(IssuePluginManager, '_load_config', return_value=config):
with patch.object(IssuePluginManager, '_discover_plugins') as mock_discover:
mock_plugin_class = Mock()
mock_discover.return_value = {'local': mock_plugin_class}
manager = IssuePluginManager()
manager.get_backend('local')
# Should initialize with empty config
mock_plugin_class.assert_called_once_with({})
def test_config_validation_rejects_invalid_backend_names(self):
"""Test configuration validation rejects invalid backend names."""
config = {
'default_backend': 'invalid-backend-name',
'backends': {}
}
with patch.object(IssuePluginManager, '_load_config', return_value=config):
manager = IssuePluginManager()
with pytest.raises(PluginNotFoundError):
manager.get_backend()
class TestErrorHandling:
"""Test suite for error handling scenarios."""
def test_plugin_loading_failure_provides_helpful_error(self):
"""Test plugin loading failures provide helpful error messages."""
manager = IssuePluginManager()
with pytest.raises(PluginNotFoundError) as exc_info:
manager.get_backend('nonexistent')
assert 'nonexistent' in str(exc_info.value)
assert 'backend' in str(exc_info.value).lower()
def test_configuration_error_for_malformed_config(self):
"""Test configuration errors for malformed configuration."""
# This will be implemented when we add config validation
pass
def test_graceful_degradation_on_plugin_import_failure(self):
"""Test system handles plugin import failures gracefully."""
# Mock import failure for one plugin
with patch('importlib.import_module', side_effect=ImportError("Mock import failure")):
manager = IssuePluginManager()
# Should still work with available plugins
assert manager.plugins is not None

View File

@@ -1,168 +0,0 @@
"""
Test for Issue Wrap-up Bug Fix
This test reproduces and validates the fix for the bug where
IssueWrapUpService._review_requirements() incorrectly calls .get()
on IssueActivity dataclass objects instead of using attribute access.
Bug: 'IssueActivity' object has no attribute 'get'
Location: markitect/issues/issue_wrapup_commands.py lines 135-136
"""
import pytest
import tempfile
from pathlib import Path
from datetime import date
from markitect.issues.issue_wrapup_commands import IssueWrapUpService
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType
from markitect.finance.models import FinanceModels
class TestIssueWrapUpBugFix:
"""Test suite for issue wrap-up bug fix."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def issue_wrapup_service(self, temp_db):
"""Create IssueWrapUpService instance for testing."""
return IssueWrapUpService(temp_db)
@pytest.fixture
def sample_issue_activities(self, temp_db):
"""Create sample issue activities that trigger the bug."""
activity_tracker = IssueActivityTracker(temp_db)
# Create activities that will be processed by _review_requirements
activity_ids = []
# Activity with implementation-related content
activity_ids.append(activity_tracker.log_activity(
issue_id=114,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
activity_details="Implementing cost allocation engine"
))
# Activity with code-related content
activity_ids.append(activity_tracker.log_activity(
issue_id=114,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 6),
activity_details="Added code for transaction handling"
))
return activity_ids
def test_reproduce_issueactivity_get_attribute_error(self, issue_wrapup_service, sample_issue_activities):
"""
Test that reproduces the 'IssueActivity' object has no attribute 'get' error.
This test should fail with the original buggy code and pass after the fix.
"""
# This should trigger the bug in the original code where it calls:
# activity.get('activity_type', '') and activity.get('description', '')
# on IssueActivity dataclass objects instead of using proper attribute access
try:
# Call the method that contains the bug
result = issue_wrapup_service._review_requirements(
issue_number=114,
issue_details={'number': 114, 'title': 'Test Issue'},
force=False
)
# If we get here without an AttributeError, the bug is fixed
assert isinstance(result, dict)
assert 'success' in result
assert 'activities_count' in result
assert 'has_implementation_activity' in result
# The logic should find implementation activities
assert result['activities_count'] == 2
assert result['has_implementation_activity'] is True # Should find 'implement' and 'code'
assert result['success'] is True
except AttributeError as e:
if "'IssueActivity' object has no attribute 'get'" in str(e):
pytest.fail(f"Bug reproduced: {e}")
else:
# Different AttributeError, re-raise
raise
def test_review_requirements_with_no_activities(self, issue_wrapup_service):
"""Test _review_requirements when no activities exist."""
result = issue_wrapup_service._review_requirements(
issue_number=999, # Non-existent issue
issue_details={'number': 999, 'title': 'Non-existent Issue'},
force=False
)
assert result['success'] is False
assert result['activities_count'] == 0
assert result['has_implementation_activity'] is False
def test_review_requirements_with_force_flag(self, issue_wrapup_service):
"""Test _review_requirements with force flag bypasses checks."""
result = issue_wrapup_service._review_requirements(
issue_number=999, # Non-existent issue
issue_details={'number': 999, 'title': 'Non-existent Issue'},
force=True
)
assert result['success'] is True
assert result['forced'] is True
def test_activity_content_detection(self, issue_wrapup_service, temp_db):
"""Test that the fixed code correctly detects implementation activities."""
# Create activities with different content types
activity_tracker = IssueActivityTracker(temp_db)
# Create activity with 'implement' in description
activity_tracker.log_activity(
issue_id=115,
activity_type=ActivityType.CREATED,
activity_details="Need to implement the feature"
)
# Create activity with 'code' in description
activity_tracker.log_activity(
issue_id=115,
activity_type=ActivityType.MODIFIED,
activity_details="Updated code for better performance"
)
# Create activity with neither keyword
activity_tracker.log_activity(
issue_id=115,
activity_type=ActivityType.COMMENTED,
activity_details="Just a regular comment"
)
result = issue_wrapup_service._review_requirements(
issue_number=115,
issue_details={'number': 115, 'title': 'Test Issue'},
force=False
)
assert result['activities_count'] == 3
assert result['has_implementation_activity'] is True # Should find 'implement' and 'code'
assert result['success'] is True
if __name__ == '__main__':
pytest.main([__file__])

View File

@@ -1,349 +0,0 @@
"""
End-to-end tests for issue management CLI commands.
Demonstrates:
- CLI command testing with real processes
- Environment isolation
- Workflow validation
- Output verification
"""
import pytest
import subprocess
import json
import sys
from pathlib import Path
import time
import os
from tests.utils.assertions import assert_file_exists, assert_directory_exists, assert_file_contains
@pytest.mark.e2e
class TestIssueCommandsE2E:
"""End-to-end tests for issue management CLI commands."""
def test_show_issue_command_basic(self, isolated_environment):
"""Test basic issue show command."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", "23"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert
assert result.returncode == 0, f"Command failed with stderr: {result.stderr}"
assert "Issue #23" in result.stdout or "issue 23" in result.stdout.lower()
def test_show_issue_command_with_invalid_number(self, isolated_environment):
"""Test show issue command with invalid issue number."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", "99999"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert - Should handle gracefully
# Note: Depending on implementation, this might return 0 or 1
assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() or "error" in result.stderr.lower()
def test_workspace_status_command(self, isolated_environment):
"""Test workspace status command."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert
assert result.returncode == 0
# Should show workspace information
assert "workspace" in result.stdout.lower() or "status" in result.stdout.lower()
@pytest.mark.slow
def test_complete_issue_workflow(self, isolated_environment, test_workspace):
"""Test complete issue workflow from start to finish."""
workspace_dir = Path(isolated_environment["MARKITECT_WORKSPACE_DIR"])
workspace_dir.mkdir(exist_ok=True)
# Step 1: Check initial workspace status
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
# Step 2: Start working on an issue
result = subprocess.run(
[sys.executable, "tddai_cli.py", "start-issue", "42"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=30 # Prevent hanging
)
# Verify the start command works (might create workspace)
if result.returncode == 0:
# If successful, check if workspace was created
issue_workspace = workspace_dir / "issue_42"
if issue_workspace.exists():
assert_directory_exists(issue_workspace)
# Step 3: Check workspace status again
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
# Step 4: Try to finish (cleanup)
result = subprocess.run(
[sys.executable, "tddai_cli.py", "finish-issue"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=30
)
# The finish command should work or provide meaningful feedback
assert result.returncode in [0, 1] # Allow for various implementation states
def test_list_open_issues_command(self, isolated_environment):
"""Test listing open issues."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "list-open-issues"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert
assert result.returncode == 0
# Should return some form of issue listing (even if empty)
output = result.stdout.strip()
assert len(output) >= 0 # Any output is acceptable
def test_cli_help_commands(self, isolated_environment):
"""Test CLI help functionality."""
# Test main help
result = subprocess.run(
[sys.executable, "tddai_cli.py", "--help"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
assert "usage" in result.stdout.lower() or "commands" in result.stdout.lower()
# Test specific command help
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", "--help"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
def test_cli_provides_helpful_error_for_unknown_commands(self, isolated_environment):
"""Test CLI provides helpful error message for unknown commands."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "invalid-command"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert - Should handle gracefully
assert result.returncode != 0
assert "error" in result.stderr.lower() or "unknown" in result.stderr.lower()
def test_cli_error_handling(self, isolated_environment):
"""Test CLI error handling for various scenarios."""
# Test with missing required argument
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue"], # Missing issue number
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Should provide helpful error message
assert result.returncode != 0
assert len(result.stderr) > 0 or "error" in result.stdout.lower()
@pytest.mark.parametrize("issue_number", ["1", "23", "100"])
def test_show_issue_command_multiple_issues(self, isolated_environment, issue_number):
"""Test show issue command with multiple issue numbers."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", issue_number],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=15
)
# Assert - Command should execute without crashing
assert result.returncode in [0, 1] # Allow for not found scenarios
assert len(result.stdout + result.stderr) > 0 # Should provide some output
def test_cli_performance(self, isolated_environment, performance_timer):
"""Test CLI command performance."""
# Act
performance_timer.start()
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
performance_timer.stop()
# Assert
assert result.returncode == 0
# CLI commands should be reasonably fast
assert performance_timer.elapsed < 10.0, f"CLI command took {performance_timer.elapsed:.2f}s"
def test_cli_output_formatting(self, isolated_environment):
"""Test CLI output formatting and structure."""
# Test workspace status output
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
if result.returncode == 0:
output = result.stdout
# Output should be readable and structured
assert len(output.strip()) > 0
# Should not contain obvious error traces
assert "Traceback" not in output
assert "Exception" not in output
def test_cli_environment_isolation(self, test_workspace):
"""Test that CLI commands work in isolated environment."""
# Create isolated environment
isolated_env = {
"MARKITECT_WORKSPACE_DIR": str(test_workspace / "isolated"),
"MARKITECT_GITEA_URL": "http://isolated-gitea.com",
"MARKITECT_REPO_OWNER": "isolated",
"MARKITECT_REPO_NAME": "test",
"PYTHONPATH": "."
}
# Update with current env to preserve PATH, etc.
full_env = dict(os.environ)
full_env.update(isolated_env)
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=full_env,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert - Should work with isolated environment
assert result.returncode == 0
# Should use isolated workspace directory
workspace_path = test_workspace / "isolated"
workspace_path.mkdir(exist_ok=True)
def test_multiple_cli_commands_can_execute_concurrently_without_conflicts(self, isolated_environment):
"""Test multiple CLI commands can execute concurrently without conflicts."""
import threading
import queue
results_queue = queue.Queue()
def run_command(command_args):
result = subprocess.run(
command_args,
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=15
)
results_queue.put(result)
# Start multiple commands concurrently
commands = [
[sys.executable, "tddai_cli.py", "workspace-status"],
[sys.executable, "tddai_cli.py", "show-issue", "1"],
[sys.executable, "tddai_cli.py", "show-issue", "2"],
]
threads = []
for cmd in commands:
thread = threading.Thread(target=run_command, args=(cmd,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join(timeout=20)
# Collect results
results = []
while not results_queue.empty():
results.append(results_queue.get())
# Assert
assert len(results) == len(commands)
# At least some commands should succeed
successful_commands = [r for r in results if r.returncode == 0]
assert len(successful_commands) > 0
@pytest.mark.smoke
def test_cli_smoke_test(self, isolated_environment):
"""Basic smoke test for CLI functionality."""
# Test that the CLI script exists and is executable
cli_script = Path("tddai_cli.py")
assert_file_exists(cli_script)
# Test basic command execution
result = subprocess.run(
[sys.executable, "tddai_cli.py", "--help"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=10
)
# Should at least not crash
assert result.returncode in [0, 1, 2] # Various help return codes
assert len(result.stdout + result.stderr) > 0

View File

@@ -1,184 +0,0 @@
"""
Test TDD workflow integration for Issue #11: Setup TDD workspace infrastructure
This test validates the complete TDD workflow from workspace creation through
test generation to completion and cleanup.
Issue Reference: #11 - Setup TDD workspace infrastructure
"""
import pytest
import os
import subprocess
import tempfile
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
from tddai.config import TddaiConfig
from tddai.workspace import WorkspaceStatus
class TestTDDWorkflowIntegration:
"""Test complete TDD workflow integration."""
def setup_method(self):
"""Set up test environment."""
self.original_cwd = os.getcwd()
self.test_dir = tempfile.mkdtemp()
os.chdir(self.test_dir)
def teardown_method(self):
"""Clean up test environment."""
os.chdir(self.original_cwd)
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
@patch('tddai.IssueFetcher.fetch_issue')
def test_complete_tdd_workflow_cycle(self, mock_fetch):
"""Test the complete TDD workflow from start to finish."""
# Arrange
mock_fetch.return_value = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Complete workflow test',
'state': 'open'
}
# Simulate the make commands workflow
from tddai import WorkspaceManager
config = TddaiConfig(workspace_dir=Path('.markitect_workspace'))
workspace_manager = WorkspaceManager(config)
# Act & Assert - Workspace Creation
issue_data = mock_fetch.return_value
workspace = workspace_manager.create_workspace(issue_data)
assert workspace.issue_dir.exists()
# Act & Assert - Status Check
status = workspace_manager.get_workspace_status()
assert status == WorkspaceStatus.ACTIVE
# Act & Assert - Test Generation (simulate multiple tests)
test_files = [
'test_issue_11_basic.py',
'test_issue_11_advanced.py'
]
for test_file in test_files:
workspace_manager.add_test_to_workspace(test_file, f'# Test file: {test_file}\ndef test_example(): pass')
# Verify tests are created
status = workspace_manager.get_workspace_status()
assert status == WorkspaceStatus.ACTIVE
# Act & Assert - Workspace Completion
main_tests_dir = Path('tests')
main_tests_dir.mkdir(exist_ok=True)
workspace_manager.finish_workspace()
# Verify tests moved to main
for test_file in test_files:
main_test_path = main_tests_dir / test_file
assert main_test_path.exists()
# Verify workspace cleaned up
final_status = workspace_manager.get_workspace_status()
assert final_status == WorkspaceStatus.CLEAN
def test_workspace_git_exclusion(self):
"""Test that workspace files are properly excluded from git."""
# Arrange
gitignore_path = Path('.gitignore')
gitignore_content = """
# MarkiTect issue workspace (temporary development files)
.markitect_workspace/
"""
gitignore_path.write_text(gitignore_content)
# Create workspace directory
workspace_dir = Path('.markitect_workspace')
workspace_dir.mkdir()
test_file = workspace_dir / 'test_file.py'
test_file.write_text('# Test content')
# Act - Check git status would ignore workspace files
# This simulates what git status would show
gitignore_patterns = ['.markitect_workspace/']
# Assert
assert any(str(test_file).startswith(pattern.rstrip('/')) for pattern in gitignore_patterns)
@patch('subprocess.run')
def test_makefile_integration_commands(self, mock_run):
"""Test that Makefile commands integrate properly with workspace system."""
# Arrange
mock_run.return_value = MagicMock(returncode=0, stdout='Success', stderr='')
# Act & Assert - Test make tdd-start command integration
from tddai_cli import main as cli_main
# Simulate CLI call for tdd-start
with patch('sys.argv', ['tddai_cli.py', 'start-issue', '11']):
with patch('tddai.IssueFetcher.fetch_issue') as mock_fetch:
mock_fetch.return_value = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Makefile integration test',
'state': 'open'
}
# This would normally create workspace
# Just verify the CLI interface works
assert callable(cli_main)
def test_error_handling_invalid_workflow_states(self):
"""Test error handling for invalid workflow states."""
from tddai import WorkspaceManager, WorkspaceError
config = TddaiConfig(workspace_dir=Path('.markitect_workspace'))
workspace_manager = WorkspaceManager(config)
# Test adding test without workspace
with pytest.raises(WorkspaceError, match="No active workspace"):
workspace_manager.add_test_to_workspace("test_file.py", "test content")
# Test finishing workspace without workspace (should return None, not raise)
result = workspace_manager.finish_workspace()
assert result is None
# Test getting status without workspace
status = workspace_manager.get_workspace_status()
assert status == WorkspaceStatus.CLEAN
def test_workspace_status_monitoring_accuracy(self):
"""Test that workspace status monitoring provides accurate information."""
# Arrange
from tddai import WorkspaceManager
config = TddaiConfig(workspace_dir=Path('.markitect_workspace'))
workspace_manager = WorkspaceManager(config)
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Status monitoring test',
'state': 'open'
}
# Act
workspace = workspace_manager.create_workspace(issue_data)
# Add some test files using the WorkspaceManager method
test_files = ['test_a.py', 'test_b.py', 'test_c.py']
for test_file in test_files:
workspace_manager.add_test_to_workspace(test_file, f'# {test_file}')
status = workspace_manager.get_workspace_status()
# Assert
assert status == WorkspaceStatus.ACTIVE
# Check that test files were actually created
assert workspace.tests_dir.exists()
created_files = list(workspace.tests_dir.glob("*.py"))
assert len(created_files) == 3

View File

@@ -1,158 +0,0 @@
"""
Test workspace creation validation for Issue #11: Setup TDD workspace infrastructure
This test validates that the TDD workspace infrastructure can successfully create
and manage workspaces for issue-driven development.
Issue Reference: #11 - Setup TDD workspace infrastructure
"""
import pytest
import os
import json
import tempfile
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
from tddai import WorkspaceManager, WorkspaceStatus, WorkspaceError
from tddai.config import TddaiConfig
class TestWorkspaceCreationValidation:
"""Test workspace creation and basic infrastructure validation."""
def setup_method(self):
"""Set up test environment with temporary workspace."""
self.test_dir = tempfile.mkdtemp()
self.workspace_dir = Path(self.test_dir) / '.markitect_workspace'
self.config = TddaiConfig(workspace_dir=self.workspace_dir)
self.workspace_manager = WorkspaceManager(self.config)
def teardown_method(self):
"""Clean up test environment."""
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def test_workspace_creation_from_issue_data(self):
"""Test that workspace can be created from issue data."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test workspace creation and management',
'state': 'open'
}
# Act
workspace = self.workspace_manager.create_workspace(issue_data)
# Assert
assert workspace.issue_dir.exists()
assert workspace.requirements_file.exists()
assert workspace.test_plan_file.exists()
assert workspace.tests_dir.exists()
assert workspace.tests_dir.is_dir()
def test_workspace_metadata_persistence(self):
"""Test that workspace metadata is properly persisted."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test workspace metadata',
'state': 'open'
}
# Act
self.workspace_manager.create_workspace(issue_data)
# Assert
current_issue_file = self.workspace_dir / 'current_issue.json'
assert current_issue_file.exists()
with open(current_issue_file, 'r') as f:
metadata = json.load(f)
assert metadata['number'] == 11
assert metadata['title'] == 'Setup TDD workspace infrastructure'
assert metadata['body'] == 'Test workspace metadata'
assert 'created_at' in metadata
def test_workspace_status_reporting(self):
"""Test that workspace status can be accurately reported."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test status reporting',
'state': 'open'
}
# Act
self.workspace_manager.create_workspace(issue_data)
status = self.workspace_manager.get_workspace_status()
# Assert
assert isinstance(status, WorkspaceStatus)
assert status == WorkspaceStatus.ACTIVE
# Verify we can get the workspace details
workspace = self.workspace_manager.get_current_workspace()
assert workspace.issue_number == 11
assert workspace.issue_title == 'Setup TDD workspace infrastructure'
assert workspace.issue_state == 'open'
def test_multiple_workspace_prevention(self):
"""Test that only one workspace can be active at a time."""
# Arrange
issue_data_1 = {'number': 11, 'title': 'First Issue', 'body': 'Test', 'state': 'open'}
issue_data_2 = {'number': 12, 'title': 'Second Issue', 'body': 'Test', 'state': 'open'}
# Act
self.workspace_manager.create_workspace(issue_data_1)
# Assert
with pytest.raises(WorkspaceError, match="Workspace already active"):
self.workspace_manager.create_workspace(issue_data_2)
def test_workspace_test_directory_structure(self):
"""Test that workspace creates proper test directory structure."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test directory structure',
'state': 'open'
}
# Act
workspace = self.workspace_manager.create_workspace(issue_data)
# Assert
assert workspace.tests_dir.exists()
assert workspace.tests_dir.is_dir()
# Test directory should be empty initially
assert len(list(workspace.tests_dir.iterdir())) == 0
def test_workspace_cleanup_capability(self):
"""Test that workspace can be properly cleaned up."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test cleanup',
'state': 'open'
}
# Act
workspace = self.workspace_manager.create_workspace(issue_data)
assert workspace.issue_dir.exists()
self.workspace_manager.cleanup_workspace()
# Assert
assert not workspace.issue_dir.exists()
current_issue_file = self.workspace_dir / 'current_issue.json'
assert not current_issue_file.exists()

View File

@@ -1,156 +0,0 @@
"""
Test workspace creation functionality for TDD infrastructure.
This test validates issue #11: Setup TDD workspace infrastructure
- Tests workspace creation from issue numbers
- Validates workspace structure and files
- Ensures proper error handling
"""
import pytest
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, patch
from tddai import WorkspaceManager, IssueFetcher, WorkspaceStatus, WorkspaceError, IssueError
from tddai.config import TddaiConfig
class TestWorkspaceCreation:
"""Test suite for workspace creation functionality."""
@pytest.fixture
def temp_workspace(self):
"""Create a temporary workspace for testing."""
temp_dir = Path(tempfile.mkdtemp())
config = TddaiConfig(workspace_dir=temp_dir / ".markitect_workspace")
yield config
shutil.rmtree(temp_dir)
@pytest.fixture
def mock_issue_data(self):
"""Mock issue data for testing."""
return {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Create workspace management system for TDD workflow',
'state': 'open',
'created_at': '2025-01-01T00:00:00Z',
'html_url': 'http://example.com/issues/11',
'assignee': None,
'labels': []
}
def test_workspace_manager_initialization(self, temp_workspace):
"""Test that WorkspaceManager can be initialized."""
manager = WorkspaceManager(temp_workspace)
assert manager.config == temp_workspace
def test_workspace_status_clean_initially(self, temp_workspace):
"""Test that workspace status is clean when no workspace exists."""
manager = WorkspaceManager(temp_workspace)
status = manager.get_status()
assert status == WorkspaceStatus.CLEAN
def test_workspace_creation_from_issue_data(self, temp_workspace, mock_issue_data):
"""Test that workspace can be created from issue data."""
manager = WorkspaceManager(temp_workspace)
workspace = manager.create_workspace(mock_issue_data)
assert workspace.issue_number == 11
assert workspace.issue_title == 'Setup TDD workspace infrastructure'
assert workspace.workspace_dir == temp_workspace.workspace_dir
# Verify workspace status changes to active
status = manager.get_status()
assert status == WorkspaceStatus.ACTIVE
def test_workspace_directory_structure_created(self, temp_workspace, mock_issue_data):
"""Test that workspace creates proper directory structure."""
manager = WorkspaceManager(temp_workspace)
workspace = manager.create_workspace(mock_issue_data)
assert workspace.workspace_dir.exists()
assert workspace.issue_dir.exists()
assert workspace.tests_dir.exists()
def test_workspace_metadata_files_created(self, temp_workspace, mock_issue_data):
"""Test that workspace creates required metadata files."""
manager = WorkspaceManager(temp_workspace)
workspace = manager.create_workspace(mock_issue_data)
assert workspace.requirements_file.exists()
assert workspace.test_plan_file.exists()
assert temp_workspace.current_issue_path.exists()
def test_current_issue_metadata_content(self, temp_workspace, mock_issue_data):
"""Test that current issue metadata is properly stored."""
manager = WorkspaceManager(temp_workspace)
manager.create_workspace(mock_issue_data)
current_workspace = manager.get_current_workspace()
assert current_workspace.issue_number == 11
assert current_workspace.issue_title == 'Setup TDD workspace infrastructure'
assert current_workspace.issue_state == 'open'
def test_workspace_prevents_multiple_active_issues(self, temp_workspace, mock_issue_data):
"""Test that only one workspace can be active at a time."""
manager = WorkspaceManager(temp_workspace)
manager.create_workspace(mock_issue_data)
# Try to create another workspace
second_issue_data = mock_issue_data.copy()
second_issue_data['number'] = 12
second_issue_data['title'] = 'Different issue'
with pytest.raises(WorkspaceError, match="Workspace already active"):
manager.create_workspace(second_issue_data)
@patch('gitea.http_client.subprocess.run')
def test_issue_fetcher_handles_invalid_issue(self, mock_run, temp_workspace):
"""Test error handling for invalid issue numbers."""
# Mock curl response for non-existent issue (404 error)
from subprocess import CalledProcessError
mock_run.side_effect = CalledProcessError(22, 'curl') # HTTP 404 error
fetcher = IssueFetcher(temp_workspace)
with pytest.raises(IssueError, match="API error fetching issue.*HTTP request failed"):
fetcher.fetch_issue(999)
def test_workspace_cleanup(self, temp_workspace, mock_issue_data):
"""Test that workspace can be cleaned up properly."""
manager = WorkspaceManager(temp_workspace)
manager.create_workspace(mock_issue_data)
# Verify workspace exists
assert manager.get_status() == WorkspaceStatus.ACTIVE
# Clean up
manager.cleanup_workspace()
# Verify workspace is clean
assert manager.get_status() == WorkspaceStatus.CLEAN
assert not temp_workspace.workspace_dir.exists()
def test_workspace_finish_moves_tests(self, temp_workspace, mock_issue_data):
"""Test that finishing workspace moves tests to main directory."""
manager = WorkspaceManager(temp_workspace)
workspace = manager.create_workspace(mock_issue_data)
# Create a test file in workspace
test_file = workspace.tests_dir / "test_example.py"
test_file.write_text("# Test content")
# Finish workspace
finished_workspace = manager.finish_workspace()
assert finished_workspace.issue_number == 11
assert manager.get_status() == WorkspaceStatus.CLEAN
# Verify test was moved
main_test_file = temp_workspace.tests_dir / "test_example.py"
assert main_test_file.exists()
assert main_test_file.read_text() == "# Test content"

View File

@@ -1,287 +0,0 @@
"""
Unit tests for Issue domain models.
Tests pure business logic with no external dependencies.
"""
import pytest
from datetime import datetime, timedelta, timezone
from domain.issues.models import Issue, Label, IssueState, LabelCategories
from domain.issues.exceptions import IssueStateError
class TestLabel:
"""Test Label value object."""
def test_label_creation(self):
# Arrange & Act
label = Label(name="bug", color="#ff0000", description="Bug label")
# Assert
assert label.name == "bug"
assert label.color == "#ff0000"
assert label.description == "Bug label"
def test_is_state_label(self):
# Arrange
state_label = Label("status:in-progress")
regular_label = Label("bug")
# Act & Assert
assert state_label.is_state_label() is True
assert regular_label.is_state_label() is False
def test_is_priority_label(self):
# Arrange
priority_label = Label("priority:high")
regular_label = Label("bug")
# Act & Assert
assert priority_label.is_priority_label() is True
assert regular_label.is_priority_label() is False
def test_is_type_label(self):
# Arrange
type_label = Label("bug")
priority_label = Label("priority:high")
# Act & Assert
assert type_label.is_type_label() is True
assert priority_label.is_type_label() is False
@pytest.mark.parametrize("label_name,expected", [
("bug", True),
("enhancement", True),
("feature", True),
("documentation", True),
("custom-label", False),
("priority:high", False)
])
def test_type_label_recognition(self, label_name, expected):
# Arrange
label = Label(label_name)
# Act & Assert
assert label.is_type_label() == expected
class TestIssue:
"""Test Issue aggregate root."""
def test_issue_creation_with_valid_data(self):
# Arrange
created_at = datetime.now(timezone.utc)
updated_at = datetime.now(timezone.utc)
labels = [Label("bug"), Label("priority:high")]
# Act
issue = Issue(
number=123,
title="Test Issue",
state=IssueState.OPEN,
labels=labels,
created_at=created_at,
updated_at=updated_at
)
# Assert
assert issue.number == 123
assert issue.title == "Test Issue"
assert issue.state == IssueState.OPEN
assert len(issue.labels) == 2
assert issue.created_at == created_at
assert issue.updated_at == updated_at
def test_categorize_labels_correctly_separates_types(self):
# Arrange
labels = [
Label("bug"), # type label
Label("priority:high"), # priority label
Label("status:in-progress"), # state label
Label("documentation"), # type label
Label("custom-label") # other label
]
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=labels,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
categories = issue.categorize_labels()
# Assert
assert "bug" in categories.type_labels
assert "documentation" in categories.type_labels
assert "priority:high" in categories.priority_labels
assert "status:in-progress" in categories.state_labels
assert "custom-label" in categories.other_labels
def test_close_issue_changes_state_and_sets_closed_at(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
issue.close()
# Assert
assert issue.state == IssueState.CLOSED
assert issue.closed_at is not None
assert isinstance(issue.closed_at, datetime)
def test_close_already_closed_issue_raises_error(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
closed_at=datetime.now(timezone.utc)
)
# Act & Assert
with pytest.raises(IssueStateError) as exc_info:
issue.close()
assert "Issue is already closed" in str(exc_info.value)
assert exc_info.value.current_state == "closed"
assert exc_info.value.attempted_state == "closed"
def test_reopen_closed_issue_changes_state_and_clears_closed_at(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
closed_at=datetime.now(timezone.utc)
)
# Act
issue.reopen()
# Assert
assert issue.state == IssueState.OPEN
assert issue.closed_at is None
def test_reopen_open_issue_raises_error(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act & Assert
with pytest.raises(IssueStateError) as exc_info:
issue.reopen()
assert "Issue is not closed" in str(exc_info.value)
def test_add_label_to_issue(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = Label("priority:high")
# Act
issue.add_label(new_label)
# Assert
assert len(issue.labels) == 2
assert new_label in issue.labels
def test_add_duplicate_label_does_not_duplicate(self):
# Arrange
label = Label("bug")
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[label],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
issue.add_label(label)
# Assert
assert len(issue.labels) == 1
def test_remove_label_from_issue(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug"), Label("priority:high")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
issue.remove_label("bug")
# Assert
assert len(issue.labels) == 1
assert not any(label.name == "bug" for label in issue.labels)
def test_has_label_returns_correct_value(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug"), Label("priority:high")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act & Assert
assert issue.has_label("bug") is True
assert issue.has_label("priority:high") is True
assert issue.has_label("enhancement") is False
class TestLabelCategories:
"""Test LabelCategories value object."""
def test_label_categories_creation(self):
# Arrange & Act
categories = LabelCategories(
state_labels=["status:open"],
priority_labels=["priority:high"],
type_labels=["bug"],
other_labels=["custom"]
)
# Assert
assert categories.state_labels == ["status:open"]
assert categories.priority_labels == ["priority:high"]
assert categories.type_labels == ["bug"]
assert categories.other_labels == ["custom"]

View File

@@ -1,368 +0,0 @@
"""
Unit tests for Issue domain services.
Tests business logic in issue services with no external dependencies.
"""
import pytest
from datetime import datetime, timedelta, timezone
from domain.issues.models import Issue, Label, IssueState
from domain.issues.services import IssueStatusService, IssueValidationService
from domain.issues.exceptions import IssueValidationError
class TestIssueStatusService:
"""Test business logic in issue status service."""
@pytest.fixture
def service(self):
return IssueStatusService()
def test_determine_kanban_column_for_closed_issue(self, service):
# Arrange
issue = Issue(
number=1,
title="Closed Issue",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Done"]}
# Act
column = service.determine_kanban_column(issue, project_info)
# Assert
assert column == "Done"
@pytest.mark.parametrize("status_label,expected_column", [
("status:in-progress", "In Progress"),
("status:review", "Review"),
("status:blocked", "Blocked"),
("status:ready", "Ready"),
])
def test_determine_kanban_column_based_on_status_labels(self, service, status_label, expected_column):
# Arrange
issue = Issue(
number=1,
title="Test Issue",
state=IssueState.OPEN,
labels=[Label(status_label)],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Blocked", "Ready", "Done"]}
# Act
column = service.determine_kanban_column(issue, project_info)
# Assert
assert column == expected_column
def test_determine_kanban_column_defaults_to_todo(self, service):
# Arrange
issue = Issue(
number=1,
title="New Issue",
state=IssueState.OPEN,
labels=[Label("bug")], # No status label
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
project_info = {"kanban_columns": ["Todo", "In Progress", "Done"]}
# Act
column = service.determine_kanban_column(issue, project_info)
# Assert
assert column == "Todo"
@pytest.mark.parametrize("priority_label,expected_level", [
("priority:low", "Low"),
("priority:medium", "Medium"),
("priority:high", "High"),
("priority:critical", "Critical"),
])
def test_extract_priority_info_with_priority_labels(self, service, priority_label, expected_level):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label(priority_label)],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
priority_info = service.extract_priority_info(issue)
# Assert
assert priority_info["level"] == expected_level
assert priority_info["label"] == priority_label
def test_extract_priority_info_defaults_to_medium(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")], # No priority label
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
priority_info = service.extract_priority_info(issue)
# Assert
assert priority_info["level"] == "Medium"
assert priority_info["label"] is None
def test_extract_state_info_for_open_issue(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("status:in-progress")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
state_info = service.extract_state_info(issue)
# Assert
assert state_info["state"] == "open"
assert state_info["state_labels"] == ["status:in-progress"]
assert state_info["is_closed"] is False
assert state_info["closed_at"] is None
def test_extract_state_info_for_closed_issue(self, service):
# Arrange
closed_at = datetime.now(timezone.utc)
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
closed_at=closed_at
)
# Act
state_info = service.extract_state_info(issue)
# Assert
assert state_info["state"] == "closed"
assert state_info["is_closed"] is True
assert state_info["closed_at"] == closed_at.isoformat()
def test_calculate_issue_age_days(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=5)
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc)
)
# Act
age_days = service.calculate_issue_age_days(issue)
# Assert
assert age_days == 5
def test_is_stale_issue_with_old_open_issue(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=45)
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc)
)
# Act
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
# Assert
assert is_stale is True
def test_is_stale_issue_with_recent_open_issue(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=15)
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc)
)
# Act
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
# Assert
assert is_stale is False
def test_is_stale_issue_with_closed_issue_never_stale(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=100)
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc),
closed_at=datetime.now(timezone.utc)
)
# Act
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
# Assert
assert is_stale is False
class TestIssueValidationService:
"""Test business logic in issue validation service."""
@pytest.fixture
def service(self):
return IssueValidationService()
def test_validate_issue_creation_with_valid_data(self, service):
# Arrange
title = "Valid Issue Title"
labels = ["bug", "priority:high"]
# Act & Assert - Should not raise exception
service.validate_issue_creation(title, labels)
def test_validate_issue_creation_with_empty_title_raises_error(self, service):
# Arrange
title = ""
labels = ["bug"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue title cannot be empty" in str(exc_info.value)
assert exc_info.value.field == "title"
def test_validate_issue_creation_with_whitespace_only_title_raises_error(self, service):
# Arrange
title = " "
labels = ["bug"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue title cannot be empty" in str(exc_info.value)
def test_validate_issue_creation_with_too_long_title_raises_error(self, service):
# Arrange
title = "x" * 256 # Too long
labels = ["bug"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue title cannot exceed 255 characters" in str(exc_info.value)
def test_validate_issue_creation_with_multiple_priority_labels_raises_error(self, service):
# Arrange
title = "Valid Title"
labels = ["bug", "priority:high", "priority:low"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue cannot have multiple priority labels" in str(exc_info.value)
assert exc_info.value.field == "labels"
def test_validate_issue_creation_with_multiple_state_labels_raises_error(self, service):
# Arrange
title = "Valid Title"
labels = ["bug", "status:open", "status:in-progress"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue cannot have multiple state labels" in str(exc_info.value)
def test_validate_title_update_with_valid_title(self, service):
# Arrange
new_title = "Updated Title"
# Act & Assert - Should not raise exception
service.validate_title_update(new_title)
def test_validate_label_addition_to_issue_without_conflicts(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = "enhancement"
# Act & Assert - Should not raise exception
service.validate_label_addition(issue, new_label)
def test_validate_label_addition_with_duplicate_label_raises_error(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = "bug"
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_label_addition(issue, new_label)
assert "Issue already has label 'bug'" in str(exc_info.value)
def test_validate_label_addition_with_conflicting_priority_raises_error(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("priority:high")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = "priority:low"
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_label_addition(issue, new_label)
assert "Issue already has priority label" in str(exc_info.value)
assert "Cannot add 'priority:low'" in str(exc_info.value)

View File

@@ -1,417 +0,0 @@
"""
Tests for IssueCreator functionality.
"""
import json
import subprocess
import pytest
from unittest.mock import patch, MagicMock, mock_open
from pathlib import Path
from tddai.issue_creator import IssueCreator
from tddai.exceptions import IssueError
from tddai.config import TddaiConfig
class TestIssueCreator:
"""Test suite for IssueCreator class."""
def _get_test_config(self):
"""Get a valid test configuration."""
return TddaiConfig(
workspace_dir=Path(".test_workspace"),
gitea_url="http://localhost:3000",
repo_owner="test_owner",
repo_name="test_repo"
)
def _get_complete_mock_response(self, number: int, title: str = "Test Issue", body: str = "Test description"):
"""Get a complete mock API response with all required fields."""
return {
"number": number,
"title": title,
"body": body,
"state": "open",
"created_at": "2025-09-26T10:00:00Z",
"updated_at": "2025-09-26T10:00:00Z",
"html_url": f"http://gitea.example.com/repo/issues/{number}"
}
def test_issue_creator_initializes_with_authentication_token(self):
"""Test IssueCreator can be initialized with authentication token."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
assert creator.config == config
assert creator.auth_token == "test-token"
def test_issue_creator_reads_authentication_from_environment_variable(self):
"""Test IssueCreator reads authentication token from environment variable."""
config = self._get_test_config()
with patch.dict('os.environ', {'GITEA_API_TOKEN': 'env-token'}):
creator = IssueCreator(config=config)
assert creator.auth_token == 'env-token'
def test_issue_creator_handles_missing_authentication_token_gracefully(self):
"""Test IssueCreator handles missing authentication token gracefully."""
config = self._get_test_config()
# Ensure no environment token interferes
with patch.dict('os.environ', {}, clear=True):
creator = IssueCreator(config=config)
assert creator.auth_token is None
@patch('subprocess.run')
def test_create_issue_success(self, mock_run):
"""Test successful issue creation."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock successful API response
mock_response = {
"number": 123,
"title": "Test Issue",
"body": "Test description",
"state": "open",
"created_at": "2025-09-26T10:00:00Z",
"updated_at": "2025-09-26T10:00:00Z",
"html_url": "http://gitea.example.com/repo/issues/123"
}
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps(mock_response),
stderr=""
)
result = creator.create_issue("Test Issue", "Test description")
# Verify the result has the expected structure (transformed by issue creator)
expected_result = {
'number': 123,
'title': "Test Issue",
'body': "Test description",
'state': "open",
'html_url': "http://gitea.example.com/repo/issues/123",
'created_at': "2025-09-26T10:00:00", # ISO format from datetime parsing
'updated_at': "2025-09-26T10:00:00",
'assignee': None,
'labels': []
}
assert result == expected_result
mock_run.assert_called_once()
# Check curl command structure
call_args = mock_run.call_args[0][0]
assert 'curl' in call_args
assert '-X' in call_args
assert 'POST' in call_args
assert 'Authorization: token test-token' in ' '.join(call_args)
def test_create_issue_without_auth_token(self):
"""Test issue creation without authentication token."""
config = self._get_test_config()
# Ensure no environment token interferes
with patch.dict('os.environ', {}, clear=True):
creator = IssueCreator(config=config)
with pytest.raises(IssueError, match="Authentication token required"):
creator.create_issue("Test Issue", "Test description")
def test_create_issue_empty_title(self):
"""Test issue creation with empty title."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
with pytest.raises(IssueError, match="Issue title cannot be empty"):
creator.create_issue("", "Test description")
with pytest.raises(IssueError, match="Issue title cannot be empty"):
creator.create_issue(" ", "Test description")
@patch('subprocess.run')
def test_create_issue_api_error(self, mock_run):
"""Test issue creation with API error response."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock API error response
mock_response = {"message": "Repository not found"}
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps(mock_response),
stderr=""
)
with pytest.raises(IssueError, match="Failed to create issue: Repository not found"):
creator.create_issue("Test Issue", "Test description")
@patch('subprocess.run')
def test_create_issue_subprocess_error(self, mock_run):
"""Test issue creation with subprocess error."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
mock_run.side_effect = subprocess.CalledProcessError(1, 'curl')
with pytest.raises(IssueError, match="Failed to create issue"):
creator.create_issue("Test Issue", "Test description")
@patch('subprocess.run')
def test_create_issue_json_error(self, mock_run):
"""Test issue creation with invalid JSON response."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
mock_run.return_value = MagicMock(
returncode=0,
stdout="invalid json",
stderr=""
)
with pytest.raises(IssueError, match="Failed to create issue.*parse.*response"):
creator.create_issue("Test Issue", "Test description")
@patch('gitea.http_client.subprocess.run')
def test_create_issue_with_optional_fields(self, mock_run):
"""Test issue creation with optional fields."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock labels API response for label resolution
labels_response = [
{"id": 1, "name": "bug", "color": "red"},
{"id": 2, "name": "high", "color": "orange"}
]
# Mock issue creation response
issue_response = self._get_complete_mock_response(124)
# Configure mock to return different responses based on URL
def side_effect(*args, **kwargs):
cmd = args[0]
url = cmd[-1] # URL is the last argument
result_mock = MagicMock()
result_mock.returncode = 0
result_mock.stderr = ""
if 'labels' in url:
result_mock.stdout = json.dumps(labels_response)
else:
result_mock.stdout = json.dumps(issue_response)
return result_mock
mock_run.side_effect = side_effect
result = creator.create_issue(
"Test Issue",
"Test description",
assignees=["user1"],
milestone=1,
labels=["bug", "high"]
)
# Verify issue was created successfully
assert result['number'] == 124
assert result['title'] == "Test Issue"
# Verify the API was called correctly
# Find the issue creation call (not the labels call)
create_call = None
for call in mock_run.call_args_list:
cmd = call[0][0]
url = cmd[-1]
if 'issues' in url and '/labels' not in url:
create_call = call
break
assert create_call is not None
cmd = create_call[0][0]
# Find the -d argument (data payload)
data_index = cmd.index('-d') + 1
payload = json.loads(cmd[data_index])
assert payload['assignees'] == ["user1"]
assert payload['milestone'] == 1
assert payload['labels'] == [1, 2] # Should be IDs now
@patch('gitea.http_client.subprocess.run')
def test_create_enhancement_issue(self, mock_run):
"""Test creating enhancement issue with structured format."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock labels API response for label resolution
labels_response = [
{"id": 1, "name": "enhancement", "color": "blue"},
{"id": 2, "name": "high", "color": "orange"}
]
# Mock issue creation response
issue_response = self._get_complete_mock_response(125)
# Configure mock to return different responses based on URL
def side_effect(*args, **kwargs):
cmd = args[0]
url = cmd[-1] # URL is the last argument
result_mock = MagicMock()
result_mock.returncode = 0
result_mock.stderr = ""
if 'labels' in url:
result_mock.stdout = json.dumps(labels_response)
else:
result_mock.stdout = json.dumps(issue_response)
return result_mock
mock_run.side_effect = side_effect
result = creator.create_enhancement_issue(
title="Add CLI Support",
use_case="User needs command-line interface",
technical_requirements="Implement Click framework",
acceptance_criteria=["CLI entry point works", "Commands have help text"],
dependencies=["Issue #1 - Database"],
priority="High"
)
# Verify structure of created issue
create_call = None
for call in mock_run.call_args_list:
cmd = call[0][0]
url = cmd[-1]
if 'issues' in url and '/labels' not in url:
create_call = call
break
assert create_call is not None
cmd = create_call[0][0]
# Find the -d argument (data payload)
data_index = cmd.index('-d') + 1
payload = json.loads(cmd[data_index])
assert "UseCase: User needs command-line interface" in payload['body']
assert "Technical Requirements:" in payload['body']
assert "- [ ] CLI entry point works" in payload['body']
assert "- [ ] Commands have help text" in payload['body']
assert "Dependencies:" in payload['body']
assert "- Issue #1 - Database" in payload['body']
assert payload['labels'] == [2, 1] # Should be IDs: [high, enhancement]
@patch('gitea.http_client.subprocess.run')
def test_create_bug_issue(self, mock_run):
"""Test creating bug issue with structured format."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock labels API response for label resolution
labels_response = [
{"id": 1, "name": "bug", "color": "red"}
]
# Mock issue creation response
issue_response = self._get_complete_mock_response(126)
# Configure mock to return different responses based on URL
def side_effect(*args, **kwargs):
cmd = args[0]
url = cmd[-1] # URL is the last argument
result_mock = MagicMock()
result_mock.returncode = 0
result_mock.stderr = ""
if 'labels' in url:
result_mock.stdout = json.dumps(labels_response)
else:
result_mock.stdout = json.dumps(issue_response)
return result_mock
mock_run.side_effect = side_effect
result = creator.create_bug_issue(
title="CLI crashes on empty input",
description="The CLI tool crashes when given empty input",
steps_to_reproduce=["Run CLI command", "Provide empty input", "Observe crash"],
expected_behavior="Should show help message",
actual_behavior="Application crashes",
environment="Python 3.8, Linux"
)
# Verify structure of created bug issue
create_call = None
for call in mock_run.call_args_list:
cmd = call[0][0]
url = cmd[-1]
if 'issues' in url and '/labels' not in url:
create_call = call
break
assert create_call is not None
cmd = create_call[0][0]
# Find the -d argument (data payload)
data_index = cmd.index('-d') + 1
payload = json.loads(cmd[data_index])
assert "The CLI tool crashes when given empty input" in payload['body']
assert "Steps to Reproduce:" in payload['body']
assert "1. Run CLI command" in payload['body']
assert "2. Provide empty input" in payload['body']
assert "Expected Behavior: Should show help message" in payload['body']
assert "Actual Behavior: Application crashes" in payload['body']
assert "Environment: Python 3.8, Linux" in payload['body']
assert payload['labels'] == [1] # Should be ID: [bug]
@patch('subprocess.run')
@patch('builtins.open', new_callable=mock_open, read_data="Title: Template Issue\nTemplate body content with {variable}")
def test_create_from_template(self, mock_file, mock_run):
"""Test creating issue from template file."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
mock_response = self._get_complete_mock_response(127)
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps(mock_response),
stderr=""
)
result = creator.create_from_template(
"template.md",
variable="test value"
)
# Verify template processing
call_args = mock_run.call_args[0][0]
json_data_index = call_args.index('-d') + 1
json_data = json.loads(call_args[json_data_index])
assert json_data['title'] == "Template Issue"
assert "Template body content with test value" in json_data['body']
def test_create_from_template_file_not_found(self):
"""Test creating issue from non-existent template."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
with pytest.raises(IssueError, match="Template file not found"):
creator.create_from_template("nonexistent.md")
@patch('builtins.open', new_callable=mock_open, read_data="")
def test_create_from_empty_template(self, mock_file):
"""Test creating issue from empty template."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
with pytest.raises(IssueError, match="Template file is empty"):
creator.create_from_template("empty.md")

View File

@@ -1,223 +0,0 @@
"""
Mock Compatibility Validation Tests
Validates that test mocks match actual domain models and prevent
the interface compatibility issues encountered in Issue #59.
"""
import pytest
from unittest.mock import Mock
from datetime import datetime, timezone
from typing import List
# Import domain models for validation
from domain.issues.models import Issue, IssueState, Label
class TestMockCompatibility:
"""Validate that test mocks match actual domain models."""
def test_issue_mock_has_all_required_attributes(self):
"""Test that Issue mocks include all required attributes."""
# Create a real Issue to get expected attributes
real_issue = Issue(
number=1,
title="Real Issue",
state=IssueState.OPEN,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Create mock with spec
mock_issue = Mock(spec=Issue)
mock_issue.number = 1
mock_issue.title = "Mock Issue"
mock_issue.state = IssueState.OPEN
mock_issue.labels = []
mock_issue.created_at = datetime.now(timezone.utc)
mock_issue.updated_at = datetime.now(timezone.utc)
mock_issue.milestone = None
mock_issue.assignee = None
# Verify critical attributes match
real_attrs = {attr for attr in dir(real_issue) if not attr.startswith('_')}
mock_attrs = {attr for attr in dir(mock_issue) if not attr.startswith('_')}
missing_attrs = real_attrs - mock_attrs
# Filter out methods - we only care about data attributes
critical_missing = [attr for attr in missing_attrs
if not callable(getattr(real_issue, attr, None))]
assert not critical_missing, f"Mock missing critical attributes: {critical_missing}"
def test_issue_mock_uses_correct_types(self):
"""Test that Issue mocks use correct types."""
mock_issue = Mock(spec=Issue)
mock_issue.state = IssueState.OPEN # Should be enum, not string
assert isinstance(mock_issue.state, IssueState), "State should be IssueState enum"
def test_issue_mock_correct_pattern_from_issue_59_fix(self):
"""Test the correct mock pattern that fixes Issue #59 problems."""
# ✅ CORRECT pattern - what we learned from Issue #59
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock(spec=Issue) # Use spec parameter!
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Test issue body" # CLI expects _body attribute
mock_issue.state = Mock()
mock_issue.state.value = "open" # CLI converts state.value
mock_issue.created_at = mock_datetime # Must support strftime()
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
# Attributes that the view layer expects (learned from Issue #59)
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_issue.kanban_column = "To Do"
# Verify it behaves like Issue #59 tests expect
assert mock_issue.number == 59
assert mock_issue.state.value == "open"
assert mock_issue.created_at.strftime('%Y-%m-%d') == "2023-01-01 00:00"
assert isinstance(mock_issue.labels, list)
def test_label_mock_has_correct_attributes(self):
"""Test that Label mocks match domain model."""
real_label = Label(name="bug", color="#ff0000", description="Bug label")
mock_label = Mock(spec=Label)
mock_label.name = "bug"
mock_label.color = "#ff0000"
mock_label.description = "Bug label"
# Verify key attributes exist
assert hasattr(mock_label, 'name')
assert hasattr(mock_label, 'color')
assert hasattr(mock_label, 'description')
def test_enum_vs_string_validation(self):
"""Validate that enums are used instead of strings (Issue #59 lesson)."""
# ❌ WRONG - what caused Issue #59 problems
wrong_mock = Mock()
wrong_mock.state = "open" # String instead of enum!
# ✅ CORRECT - proper enum usage
correct_mock = Mock(spec=Issue)
correct_mock.state = IssueState.OPEN
# Verify correct usage
assert isinstance(correct_mock.state, IssueState)
assert not isinstance(wrong_mock.state, IssueState)
# This test serves as documentation of the correct pattern
class TestIssue59Prevention:
"""Specific tests to prevent Issue #59 problems from recurring."""
def test_plugin_manager_mock_discovery_pattern(self):
"""Test the correct plugin manager mocking pattern."""
from markitect.issues.manager import IssuePluginManager
# ✅ CORRECT: Mock at _discover_plugins level (learned from Issue #59)
from unittest.mock import patch
with patch.object(IssuePluginManager, '_discover_plugins') as mock_discover:
mock_plugin_class = Mock()
mock_discover.return_value = {'gitea': mock_plugin_class}
manager = IssuePluginManager()
# This pattern works because it mocks at the right level
assert 'gitea' in manager.plugins
mock_plugin_class.assert_not_called() # Only called when get_backend() is used
def test_cli_mock_realistic_attributes(self):
"""Test CLI layer mocks have all required attributes for views."""
# These are the attributes the CLI view layer expects (learned from Issue #59)
required_view_attributes = [
'number', 'title', 'state', 'created_at', 'updated_at',
'labels', 'assignee', 'milestone', '_body'
]
# Additional attributes the view expects (discovered during Issue #59 fixing)
view_layer_attributes = [
'state_label', 'priority_label', 'type_labels',
'other_labels', 'html_url', 'kanban_column'
]
mock_issue = Mock(spec=Issue)
# Set all required attributes
for attr in required_view_attributes:
setattr(mock_issue, attr, None) # Set to None or appropriate default
for attr in view_layer_attributes:
setattr(mock_issue, attr, None)
# Verify all critical attributes exist
for attr in required_view_attributes + view_layer_attributes:
assert hasattr(mock_issue, attr), f"Mock missing required attribute: {attr}"
def test_datetime_mock_strftime_support(self):
"""Test datetime mocks support strftime (Issue #59 requirement)."""
# The view layer calls created_at.strftime() - mocks must support this
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01"
mock_issue = Mock(spec=Issue)
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
# Verify strftime works
assert mock_issue.created_at.strftime('%Y-%m-%d') == "2023-01-01"
assert mock_issue.updated_at.strftime('%Y-%m-%d') == "2023-01-01"
class TestMockGuidelines:
"""Tests that demonstrate correct mock patterns for future development."""
def test_correct_mock_creation_pattern(self):
"""Demonstrate the correct way to create mocks."""
# ✅ ALWAYS use spec= parameter
mock_issue = Mock(spec=Issue)
# ✅ Use actual enums, not strings
mock_issue.state = IssueState.OPEN
# ✅ Include all required attributes based on domain model analysis
mock_issue.number = 1
mock_issue.title = "Test"
mock_issue.labels = []
mock_issue.created_at = datetime.now(timezone.utc)
mock_issue.updated_at = datetime.now(timezone.utc)
# Verify this is the correct pattern
assert hasattr(mock_issue, '_spec_class') # Mock(spec=X) creates this
assert isinstance(mock_issue.state, IssueState)
def test_integration_test_mocking_strategy(self):
"""Demonstrate proper mocking for integration tests."""
# For integration tests, create mocks that match the actual interface contracts
from markitect.issues.base import IssueBackend
mock_backend = Mock(spec=IssueBackend)
mock_backend.list_issues.return_value = []
mock_backend.get_issue.return_value = Mock(spec=Issue)
# Verify the mock supports the interface
assert hasattr(mock_backend, 'list_issues')
assert hasattr(mock_backend, 'get_issue')
assert callable(mock_backend.list_issues)
if __name__ == '__main__':
pytest.main([__file__, '-v'])