refactor: remove obsolete issue management system in favor of issue-facade
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:
parent
cb94c92fc0
commit
a8e5b4b044
58 changed files with 11 additions and 14628 deletions
|
|
@ -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"])
|
||||
|
|
@ -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
|
||||
|
|
@ -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__])
|
||||
|
|
@ -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__])
|
||||
|
|
@ -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'])
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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__])
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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'])
|
||||
Loading…
Add table
Add a link
Reference in a new issue