""" 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