"""Tests for issue parser agent.""" from unittest.mock import Mock, patch import pytest from anthropic import Anthropic from anthropic.types import Message, TextBlock, Usage from src.parser import clear_cache, parse_issue_metadata @pytest.fixture(autouse=True) def setup_test_env(monkeypatch: pytest.MonkeyPatch) -> None: """Set up test environment variables.""" monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "test-secret") monkeypatch.setenv("GITEA_URL", "https://test.example.com") monkeypatch.setenv("ANTHROPIC_API_KEY", "test-anthropic-key") monkeypatch.setenv("LOG_LEVEL", "debug") @pytest.fixture def sample_complete_issue_body() -> str: """Complete issue body with all fields.""" return """## Objective Create AI agent (Sonnet) that parses issue markdown body to extract structured metadata. ## Implementation Details 1. Create parse_issue_metadata() function 2. Use Anthropic API with Sonnet model ## Context Estimate • Files to modify: 3 (parser.py, agent.py, models.py) • Implementation complexity: medium (20000 tokens) • Test requirements: medium (10000 tokens) • Documentation: medium (3000 tokens) • **Total estimated: 46800 tokens** • **Recommended agent: sonnet** ## Difficulty medium ## Dependencies • Blocked by: #157 (COORD-001 - needs webhook to trigger parser) • Blocks: #159 (COORD-003 - queue needs parsed metadata) ## Acceptance Criteria [ ] Parser extracts all required fields [ ] Returns valid JSON matching schema """ @pytest.fixture def sample_minimal_issue_body() -> str: """Minimal issue body with only required fields.""" return """## Objective Fix the login bug. ## Acceptance Criteria [ ] Bug is fixed """ @pytest.fixture def sample_malformed_issue_body() -> str: """Malformed issue body to test graceful failure.""" return """This is just random text without proper sections. Some more random content here. """ @pytest.fixture def mock_anthropic_response() -> Message: """Mock Anthropic API response.""" return Message( id="msg_123", type="message", role="assistant", content=[ TextBlock( type="text", text=( '{"estimated_context": 46800, "difficulty": "medium", ' '"assigned_agent": "sonnet", "blocks": [159], "blocked_by": [157]}' ), ) ], model="claude-sonnet-4.5-20250929", stop_reason="end_turn", usage=Usage(input_tokens=500, output_tokens=50) ) @pytest.fixture def mock_anthropic_minimal_response() -> Message: """Mock Anthropic API response for minimal issue.""" return Message( id="msg_124", type="message", role="assistant", content=[ TextBlock( type="text", text=( '{"estimated_context": 50000, "difficulty": "medium", ' '"assigned_agent": "sonnet", "blocks": [], "blocked_by": []}' ), ) ], model="claude-sonnet-4.5-20250929", stop_reason="end_turn", usage=Usage(input_tokens=200, output_tokens=40) ) @pytest.fixture(autouse=True) def reset_cache() -> None: """Clear cache before each test.""" clear_cache() class TestParseIssueMetadata: """Tests for parse_issue_metadata function.""" @patch("src.parser.Anthropic") def test_parse_complete_issue( self, mock_anthropic_class: Mock, sample_complete_issue_body: str, mock_anthropic_response: Message ) -> None: """Test parsing complete issue body with all fields.""" # Setup mock mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock(return_value=mock_anthropic_response) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse issue result = parse_issue_metadata(sample_complete_issue_body, 158) # Verify result assert result.estimated_context == 46800 assert result.difficulty == "medium" assert result.assigned_agent == "sonnet" assert result.blocks == [159] assert result.blocked_by == [157] # Verify API was called correctly mock_messages.create.assert_called_once() call_args = mock_messages.create.call_args assert call_args.kwargs["model"] == "claude-sonnet-4.5-20250929" assert call_args.kwargs["max_tokens"] == 1024 assert call_args.kwargs["temperature"] == 0 @patch("src.parser.Anthropic") def test_parse_minimal_issue( self, mock_anthropic_class: Mock, sample_minimal_issue_body: str, mock_anthropic_minimal_response: Message ) -> None: """Test parsing minimal issue body uses defaults.""" # Setup mock mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock(return_value=mock_anthropic_minimal_response) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse issue result = parse_issue_metadata(sample_minimal_issue_body, 999) # Verify defaults are used assert result.estimated_context == 50000 assert result.difficulty == "medium" assert result.assigned_agent == "sonnet" assert result.blocks == [] assert result.blocked_by == [] @patch("src.parser.Anthropic") def test_parse_malformed_issue_returns_defaults( self, mock_anthropic_class: Mock, sample_malformed_issue_body: str ) -> None: """Test malformed issue body returns graceful defaults.""" # Setup mock to return invalid JSON mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock( return_value=Message( id="msg_125", type="message", role="assistant", content=[TextBlock(type="text", text='{"invalid": "json"')], model="claude-sonnet-4.5-20250929", stop_reason="end_turn", usage=Usage(input_tokens=100, output_tokens=20) ) ) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse issue result = parse_issue_metadata(sample_malformed_issue_body, 888) # Verify defaults assert result.estimated_context == 50000 assert result.difficulty == "medium" assert result.assigned_agent == "sonnet" assert result.blocks == [] assert result.blocked_by == [] @patch("src.parser.Anthropic") def test_api_failure_returns_defaults( self, mock_anthropic_class: Mock, sample_complete_issue_body: str ) -> None: """Test API failure returns defaults with error logged.""" # Setup mock to raise exception mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock(side_effect=Exception("API Error")) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse issue result = parse_issue_metadata(sample_complete_issue_body, 777) # Verify defaults assert result.estimated_context == 50000 assert result.difficulty == "medium" assert result.assigned_agent == "sonnet" assert result.blocks == [] assert result.blocked_by == [] @patch("src.parser.Anthropic") def test_caching_avoids_duplicate_api_calls( self, mock_anthropic_class: Mock, sample_complete_issue_body: str, mock_anthropic_response: Message ) -> None: """Test that caching prevents duplicate API calls for same issue.""" # Setup mock mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock(return_value=mock_anthropic_response) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse same issue twice result1 = parse_issue_metadata(sample_complete_issue_body, 158) result2 = parse_issue_metadata(sample_complete_issue_body, 158) # Verify API was called only once assert mock_messages.create.call_count == 1 # Verify both results are identical assert result1.model_dump() == result2.model_dump() @patch("src.parser.Anthropic") def test_different_issues_not_cached( self, mock_anthropic_class: Mock, sample_complete_issue_body: str, sample_minimal_issue_body: str, mock_anthropic_response: Message ) -> None: """Test that different issues result in separate API calls.""" # Setup mock mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock(return_value=mock_anthropic_response) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse different issues parse_issue_metadata(sample_complete_issue_body, 158) parse_issue_metadata(sample_minimal_issue_body, 159) # Verify API was called twice assert mock_messages.create.call_count == 2 @patch("src.parser.Anthropic") def test_difficulty_validation( self, mock_anthropic_class: Mock, sample_complete_issue_body: str ) -> None: """Test that difficulty values are validated.""" # Setup mock with invalid difficulty mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock( return_value=Message( id="msg_126", type="message", role="assistant", content=[ TextBlock( type="text", text=( '{"estimated_context": 10000, "difficulty": "invalid", ' '"assigned_agent": "sonnet", "blocks": [], "blocked_by": []}' ), ) ], model="claude-sonnet-4.5-20250929", stop_reason="end_turn", usage=Usage(input_tokens=100, output_tokens=20) ) ) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse issue result = parse_issue_metadata(sample_complete_issue_body, 666) # Should default to "medium" for invalid difficulty assert result.difficulty == "medium" @patch("src.parser.Anthropic") def test_agent_validation( self, mock_anthropic_class: Mock, sample_complete_issue_body: str ) -> None: """Test that agent values are validated.""" # Setup mock with invalid agent mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock( return_value=Message( id="msg_127", type="message", role="assistant", content=[ TextBlock( type="text", text=( '{"estimated_context": 10000, "difficulty": "medium", ' '"assigned_agent": "invalid_agent", "blocks": [], "blocked_by": []}' ), ) ], model="claude-sonnet-4.5-20250929", stop_reason="end_turn", usage=Usage(input_tokens=100, output_tokens=20) ) ) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Parse issue result = parse_issue_metadata(sample_complete_issue_body, 555) # Should default to "sonnet" for invalid agent assert result.assigned_agent == "sonnet" @patch("src.parser.Anthropic") def test_parse_time_performance( self, mock_anthropic_class: Mock, sample_complete_issue_body: str, mock_anthropic_response: Message ) -> None: """Test that parsing completes within performance target.""" import time # Setup mock mock_client = Mock(spec=Anthropic) mock_messages = Mock() mock_messages.create = Mock(return_value=mock_anthropic_response) mock_client.messages = mock_messages mock_anthropic_class.return_value = mock_client # Measure parse time start_time = time.time() parse_issue_metadata(sample_complete_issue_body, 158) elapsed_time = time.time() - start_time # Should complete within 2 seconds (mocked, so should be instant) assert elapsed_time < 2.0