refactor: remove obsolete issue management system in favor of issue-facade
Some checks failed
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Some checks failed
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Complete cleanup of the legacy TDD AI and issue management system, establishing clear separation of concerns as requested. All issue handling is now provided by the standalone issue-facade system. Removed components: - TDD AI framework (tddai/ directory and tddai_cli.py) - Legacy issue management CLI commands and services - Issue-related Makefile targets and helper commands - Obsolete tests and infrastructure dependencies - Finance modules that depended on the old issue system Updated: - Makefile: Removed issue-*, tdd-*, and test-from-issue commands - CLI framework: Simplified to core functionality only - Documentation: Added deprecation notice for old config system The issue-facade now serves as the universal CLI for issue tracking, providing backend-agnostic interface to GitHub, GitLab, Gitea, and local SQLite storage as documented in issue-facade/README.md. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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'])
|
||||
Reference in New Issue
Block a user