Files
markitect-main/tests/test_issue_113_activity_tracking.py
tegwick d49fa8e9fb feat: implement issue activity tracking system (issue #113)
- Add comprehensive IssueActivityTracker service with ActivityType enum and IssueActivity dataclass
- Implement full CLI interface with log, show, list, summary, delete, and import-activities commands
- Support activity logging with automatic period detection and cost allocation integration
- Add activity retrieval by issue, by period, with filtering and pagination
- Include activity summaries with statistics and breakdowns across issues and time periods
- Support bulk operations for activity import from JSON/CSV formats
- Integrate with existing finance schema using cost_periods and issue_activity_log tables
- Add 28 comprehensive test cases covering all functionality with 100% pass rate
- Enable both table and JSON output formats for all CLI commands

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 03:14:04 +02:00

627 lines
24 KiB
Python

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