#
tokens: 31816/50000 2/194 files (page 10/10)
lines: off (toggle) GitHub
raw markdown copy
This is page 10 of 10. Use http://codebase.md/sooperset/mcp-atlassian?lines=false&page={x} to view the full context.

# Directory Structure

```
├── .devcontainer
│   ├── devcontainer.json
│   ├── Dockerfile
│   ├── post-create.sh
│   └── post-start.sh
├── .dockerignore
├── .env.example
├── .github
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   ├── config.yml
│   │   └── feature_request.yml
│   ├── pull_request_template.md
│   └── workflows
│       ├── docker-publish.yml
│       ├── lint.yml
│       ├── publish.yml
│       ├── stale.yml
│       └── tests.yml
├── .gitignore
├── .pre-commit-config.yaml
├── AGENTS.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── README.md
├── scripts
│   ├── oauth_authorize.py
│   └── test_with_real_data.sh
├── SECURITY.md
├── smithery.yaml
├── src
│   └── mcp_atlassian
│       ├── __init__.py
│       ├── confluence
│       │   ├── __init__.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── labels.py
│       │   ├── pages.py
│       │   ├── search.py
│       │   ├── spaces.py
│       │   ├── users.py
│       │   ├── utils.py
│       │   └── v2_adapter.py
│       ├── exceptions.py
│       ├── jira
│       │   ├── __init__.py
│       │   ├── attachments.py
│       │   ├── boards.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── epics.py
│       │   ├── fields.py
│       │   ├── formatting.py
│       │   ├── issues.py
│       │   ├── links.py
│       │   ├── projects.py
│       │   ├── protocols.py
│       │   ├── search.py
│       │   ├── sprints.py
│       │   ├── transitions.py
│       │   ├── users.py
│       │   └── worklog.py
│       ├── models
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence
│       │   │   ├── __init__.py
│       │   │   ├── comment.py
│       │   │   ├── common.py
│       │   │   ├── label.py
│       │   │   ├── page.py
│       │   │   ├── search.py
│       │   │   ├── space.py
│       │   │   └── user_search.py
│       │   ├── constants.py
│       │   └── jira
│       │       ├── __init__.py
│       │       ├── agile.py
│       │       ├── comment.py
│       │       ├── common.py
│       │       ├── issue.py
│       │       ├── link.py
│       │       ├── project.py
│       │       ├── search.py
│       │       ├── version.py
│       │       ├── workflow.py
│       │       └── worklog.py
│       ├── preprocessing
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence.py
│       │   └── jira.py
│       ├── servers
│       │   ├── __init__.py
│       │   ├── confluence.py
│       │   ├── context.py
│       │   ├── dependencies.py
│       │   ├── jira.py
│       │   └── main.py
│       └── utils
│           ├── __init__.py
│           ├── date.py
│           ├── decorators.py
│           ├── env.py
│           ├── environment.py
│           ├── io.py
│           ├── lifecycle.py
│           ├── logging.py
│           ├── oauth_setup.py
│           ├── oauth.py
│           ├── ssl.py
│           ├── tools.py
│           └── urls.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── fixtures
│   │   ├── __init__.py
│   │   ├── confluence_mocks.py
│   │   └── jira_mocks.py
│   ├── integration
│   │   ├── conftest.py
│   │   ├── README.md
│   │   ├── test_authentication.py
│   │   ├── test_content_processing.py
│   │   ├── test_cross_service.py
│   │   ├── test_mcp_protocol.py
│   │   ├── test_proxy.py
│   │   ├── test_real_api.py
│   │   ├── test_ssl_verification.py
│   │   ├── test_stdin_monitoring_fix.py
│   │   └── test_transport_lifecycle.py
│   ├── README.md
│   ├── test_preprocessing.py
│   ├── test_real_api_validation.py
│   ├── unit
│   │   ├── confluence
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_labels.py
│   │   │   ├── test_pages.py
│   │   │   ├── test_search.py
│   │   │   ├── test_spaces.py
│   │   │   ├── test_users.py
│   │   │   ├── test_utils.py
│   │   │   └── test_v2_adapter.py
│   │   ├── jira
│   │   │   ├── conftest.py
│   │   │   ├── test_attachments.py
│   │   │   ├── test_boards.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_epics.py
│   │   │   ├── test_fields.py
│   │   │   ├── test_formatting.py
│   │   │   ├── test_issues_markdown.py
│   │   │   ├── test_issues.py
│   │   │   ├── test_links.py
│   │   │   ├── test_projects.py
│   │   │   ├── test_protocols.py
│   │   │   ├── test_search.py
│   │   │   ├── test_sprints.py
│   │   │   ├── test_transitions.py
│   │   │   ├── test_users.py
│   │   │   └── test_worklog.py
│   │   ├── models
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_base_models.py
│   │   │   ├── test_confluence_models.py
│   │   │   ├── test_constants.py
│   │   │   └── test_jira_models.py
│   │   ├── servers
│   │   │   ├── __init__.py
│   │   │   ├── test_confluence_server.py
│   │   │   ├── test_context.py
│   │   │   ├── test_dependencies.py
│   │   │   ├── test_jira_server.py
│   │   │   └── test_main_server.py
│   │   ├── test_exceptions.py
│   │   ├── test_main_transport_selection.py
│   │   └── utils
│   │       ├── __init__.py
│   │       ├── test_custom_headers.py
│   │       ├── test_date.py
│   │       ├── test_decorators.py
│   │       ├── test_env.py
│   │       ├── test_environment.py
│   │       ├── test_io.py
│   │       ├── test_lifecycle.py
│   │       ├── test_logging.py
│   │       ├── test_masking.py
│   │       ├── test_oauth_setup.py
│   │       ├── test_oauth.py
│   │       ├── test_ssl.py
│   │       ├── test_tools.py
│   │       └── test_urls.py
│   └── utils
│       ├── __init__.py
│       ├── assertions.py
│       ├── base.py
│       ├── factories.py
│       └── mocks.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/tests/unit/jira/test_issues.py:
--------------------------------------------------------------------------------

```python
"""Tests for the Jira Issues mixin."""

from unittest.mock import ANY, MagicMock, patch

import pytest

from mcp_atlassian.jira import JiraFetcher
from mcp_atlassian.jira.issues import IssuesMixin, logger
from mcp_atlassian.models.jira import JiraIssue


class TestIssuesMixin:
    """Tests for the IssuesMixin class."""

    @pytest.fixture
    def issues_mixin(self, jira_fetcher: JiraFetcher) -> IssuesMixin:
        """Create an IssuesMixin instance with mocked dependencies."""
        mixin = jira_fetcher

        # Add mock methods that would be provided by other mixins
        mixin._get_account_id = MagicMock(return_value="test-account-id")
        mixin.get_available_transitions = MagicMock(
            return_value=[{"id": "10", "name": "In Progress"}]
        )
        mixin.transition_issue = MagicMock(
            return_value=JiraIssue(id="123", key="TEST-123", summary="Test Issue")
        )

        return mixin

    def test_get_issue_basic(self, issues_mixin: IssuesMixin):
        """Test retrieving an issue by key."""
        # Mock the API response
        issues_mixin.jira.get_issue.return_value = {
            "id": "10001",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "created": "2023-01-01T00:00:00.000+0000",
                "updated": "2023-01-02T00:00:00.000+0000",
            },
        }

        # Call the method
        result = issues_mixin.get_issue("TEST-123")

        # Verify API calls
        issues_mixin.jira.get_issue.assert_called_once_with(
            "TEST-123",
            expand=None,
            fields=ANY,
            properties=None,
            update_history=True,
        )

        # Verify result structure
        assert isinstance(result, JiraIssue)
        assert result.key == "TEST-123"
        assert result.summary == "Test Issue"
        assert result.description == "This is a test issue"

        # Check Jira fields mapping
        assert result.status is not None
        assert result.status.name == "Open"
        assert result.issue_type.name == "Bug"

    def test_get_issue_with_comments(self, issues_mixin: IssuesMixin):
        """Test get_issue with comments."""
        # Mock the comments data
        comments_data = {
            "comments": [
                {
                    "id": "1",
                    "body": "This is a comment",
                    "author": {"displayName": "John Doe"},
                    "created": "2023-01-02T00:00:00.000+0000",
                    "updated": "2023-01-02T00:00:00.000+0000",
                }
            ]
        }

        # Mock the issue data
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "comment": comments_data,
                "summary": "Test Issue",
                "description": "Test Description",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "created": "2023-01-01T00:00:00.000+0000",
                "updated": "2023-01-02T00:00:00.000+0000",
            },
        }

        # Set up the mocked responses
        issues_mixin.jira.get_issue.return_value = issue_data
        issues_mixin.jira.issue_get_comments.return_value = comments_data

        # Call the method
        issue = issues_mixin.get_issue(
            "TEST-123",
            fields="summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype,comment",
        )

        # Verify the API calls
        issues_mixin.jira.get_issue.assert_called_once_with(
            "TEST-123",
            expand=None,
            fields="summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype,comment",
            properties=None,
            update_history=True,
        )
        issues_mixin.jira.issue_get_comments.assert_called_once_with("TEST-123")

        # Verify the comments were added to the issue
        assert hasattr(issue, "comments")
        assert len(issue.comments) == 1
        assert issue.comments[0].body == "This is a comment"

    def test_get_issue_with_epic_info(self, issues_mixin: IssuesMixin):
        """Test retrieving issue with epic information."""
        try:
            # Mock the API responses for get_issue
            issues_mixin.jira.get_issue.side_effect = [
                # First call - the issue
                {
                    "id": "10001",
                    "key": "TEST-123",
                    "fields": {
                        "summary": "Test Issue",
                        "description": "This is a test issue",
                        "status": {"name": "Open"},
                        "issuetype": {"name": "Story"},
                        "customfield_10010": "EPIC-456",  # Epic Link field
                        "created": "2023-01-01T00:00:00.000+0000",
                        "updated": "2023-01-02T00:00:00.000+0000",
                    },
                },
                # Second call - the epic
                {
                    "id": "10002",
                    "key": "EPIC-456",
                    "fields": {
                        "summary": "Epic Issue",
                        "description": "This is an epic",
                        "status": {"name": "In Progress"},
                        "issuetype": {"name": "Epic"},
                        "customfield_10011": "Epic Name Value",  # Epic Name field
                        "created": "2023-01-01T00:00:00.000+0000",
                        "updated": "2023-01-02T00:00:00.000+0000",
                    },
                },
            ]

            # Mock get_field_ids_to_epic
            issues_mixin.get_field_ids_to_epic = MagicMock(
                return_value={
                    "epic_link": "customfield_10010",
                    "epic_name": "customfield_10011",
                }
            )

            # Call the method - just use get_issue without the include_epic_info parameter
            issue = issues_mixin.get_issue("TEST-123")

            # Verify the API calls
            issues_mixin.jira.get_issue.assert_any_call(
                "TEST-123",
                expand=None,
                fields=ANY,
                properties=None,
                update_history=True,
            )
            issues_mixin.jira.get_issue.assert_any_call(
                "EPIC-456",
                expand=None,
                fields=None,
                properties=None,
                update_history=True,
            )

            # Verify the issue
            assert issue.key == "TEST-123"
            assert issue.summary == "Test Issue"

            # Verify that the epic information is in the custom fields
            assert issue.custom_fields.get("customfield_10010") == {"value": "EPIC-456"}
            assert issue.custom_fields.get("customfield_10011") == {
                "value": "Epic Name Value"
            }

        except Exception as e:
            pytest.fail(f"Test failed: {e}")

    def test_get_issue_error_handling(self, issues_mixin: IssuesMixin):
        """Test error handling in get_issue."""
        # Mock the API to raise an exception
        issues_mixin.jira.get_issue.side_effect = Exception("API error")

        # Call the method and verify it raises the expected exception
        with pytest.raises(
            Exception, match=r"Error retrieving issue TEST-123: API error"
        ):
            issues_mixin.get_issue("TEST-123")

    def test_normalize_comment_limit(self, issues_mixin: IssuesMixin):
        """Test normalizing comment limit."""
        # Test with None
        assert issues_mixin._normalize_comment_limit(None) is None

        # Test with integer
        assert issues_mixin._normalize_comment_limit(5) == 5

        # Test with "all"
        assert issues_mixin._normalize_comment_limit("all") is None

        # Test with string number
        assert issues_mixin._normalize_comment_limit("10") == 10

        # Test with invalid string
        assert issues_mixin._normalize_comment_limit("invalid") == 10

    def test_create_issue_basic(self, issues_mixin: IssuesMixin):
        """Test creating a basic issue."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Mock empty comments
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}

        # Call create_issue
        issue = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
        )

        # Verify API calls
        expected_fields = {
            "project": {"key": "TEST"},
            "summary": "Test Issue",
            "issuetype": {"name": "Bug"},
            "description": "This is a test issue",
        }
        issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)
        issues_mixin.jira.get_issue.assert_called_once_with("TEST-123")

        # Verify issue
        assert issue.key == "TEST-123"
        assert issue.summary == "Test Issue"

    def test_create_issue_no_components(self, issues_mixin: IssuesMixin):
        """Test creating an issue with no components specified."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Mock empty comments
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}

        # Call create_issue with components=None
        issue = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
            components=None,
        )

        # Verify API calls
        expected_fields = {
            "project": {"key": "TEST"},
            "summary": "Test Issue",
            "issuetype": {"name": "Bug"},
            "description": "This is a test issue",
        }
        issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)

        # Verify 'components' is not in the fields
        assert "components" not in issues_mixin.jira.create_issue.call_args[1]["fields"]

    def test_create_issue_single_component(self, issues_mixin: IssuesMixin):
        """Test creating an issue with a single component."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "components": [{"name": "UI"}],
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Mock empty comments
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}

        # Call create_issue with a single component
        issue = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
            components=["UI"],
        )

        # Verify API calls
        expected_fields = {
            "project": {"key": "TEST"},
            "summary": "Test Issue",
            "issuetype": {"name": "Bug"},
            "description": "This is a test issue",
            "components": [{"name": "UI"}],
        }
        issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)

        # Verify the components field was passed correctly
        assert issues_mixin.jira.create_issue.call_args[1]["fields"]["components"] == [
            {"name": "UI"}
        ]

    def test_create_issue_multiple_components(self, issues_mixin: IssuesMixin):
        """Test creating an issue with multiple components."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "components": [{"name": "UI"}, {"name": "API"}],
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Mock empty comments
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}

        # Call create_issue with multiple components
        issue = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
            components=["UI", "API"],
        )

        # Verify API calls
        expected_fields = {
            "project": {"key": "TEST"},
            "summary": "Test Issue",
            "issuetype": {"name": "Bug"},
            "description": "This is a test issue",
            "components": [{"name": "UI"}, {"name": "API"}],
        }
        issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)

        # Verify the components field was passed correctly
        assert issues_mixin.jira.create_issue.call_args[1]["fields"]["components"] == [
            {"name": "UI"},
            {"name": "API"},
        ]

    def test_create_issue_components_with_invalid_entries(
        self, issues_mixin: IssuesMixin
    ):
        """Test creating an issue with components list containing invalid entries."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "components": [{"name": "Valid"}, {"name": "Backend"}],
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Mock empty comments
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}

        # Call create_issue with components list containing invalid entries
        issue = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
            components=["Valid", "", None, "  Backend  "],
        )

        # Verify API calls
        expected_fields = {
            "project": {"key": "TEST"},
            "summary": "Test Issue",
            "issuetype": {"name": "Bug"},
            "description": "This is a test issue",
            "components": [{"name": "Valid"}, {"name": "Backend"}],
        }
        issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)

        # Verify the components field was passed correctly, with invalid entries filtered out
        assert issues_mixin.jira.create_issue.call_args[1]["fields"]["components"] == [
            {"name": "Valid"},
            {"name": "Backend"},
        ]

    def test_create_issue_components_precedence(self, issues_mixin, caplog):
        """Test that explicit components take precedence over components in additional_fields."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "components": [{"name": "Explicit"}],
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Mock empty comments
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}

        # Direct test for the precedence handling logic
        # Create fields dict with components already set by explicit parameter
        fields = {
            "project": {"key": "TEST"},
            "summary": "Test Issue",
            "issuetype": {"name": "Bug"},
            "description": "This is a test issue",
            "components": [{"name": "Explicit"}],
        }

        # Create kwargs with a conflicting components entry
        kwargs = {"components": [{"name": "Ignored"}]}

        # Directly call the method that would handle the precedence
        # This simulates what happens inside create_issue
        if "components" in fields and "components" in kwargs:
            logger.warning(
                "Components provided via both 'components' argument and 'additional_fields'. "
                "Using the explicit 'components' argument."
            )
            # Remove the conflicting key from kwargs to prevent issues later
            kwargs.pop("components", None)

        # Verify the warning was logged about the conflict
        assert (
            "Components provided via both 'components' argument and 'additional_fields'"
            in caplog.text
        )

        # Verify that kwargs no longer contains components
        assert "components" not in kwargs

        # Verify the components field was preserved with the explicit value
        assert fields["components"] == [{"name": "Explicit"}]

    def test_create_issue_with_assignee_cloud(self, issues_mixin: IssuesMixin):
        """Test creating an issue with an assignee in Jira Cloud."""
        # Mock create_issue response
        create_response = {"key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock get_issue response
        issues_mixin.get_issue = MagicMock(
            return_value=JiraIssue(key="TEST-123", description="", summary="Test Issue")
        )

        # Mock _get_account_id to return a Cloud account ID
        issues_mixin._get_account_id = MagicMock(return_value="cloud-account-id")

        # Configure for Cloud
        issues_mixin.config = MagicMock()
        issues_mixin.config.is_cloud = True

        # Call the method
        issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            assignee="testuser",
        )

        # Verify _get_account_id was called with the correct username
        issues_mixin._get_account_id.assert_called_once_with("testuser")

        # Verify the assignee was properly set for Cloud (accountId)
        fields = issues_mixin.jira.create_issue.call_args[1]["fields"]
        assert fields["assignee"] == {"accountId": "cloud-account-id"}

    def test_create_issue_with_assignee_server(self, issues_mixin: IssuesMixin):
        """Test creating an issue with an assignee in Jira Server/DC."""
        # Mock create_issue response
        create_response = {"key": "TEST-456"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock get_issue response
        issues_mixin.get_issue = MagicMock(
            return_value=JiraIssue(key="TEST-456", description="", summary="Test Issue")
        )

        # Mock _get_account_id to return a Server user ID (typically username)
        issues_mixin._get_account_id = MagicMock(return_value="server-user")

        # Configure for Server/DC
        issues_mixin.config = MagicMock()
        issues_mixin.config.is_cloud = False

        # Call the method
        issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            assignee="testuser",
        )

        # Verify _get_account_id was called with the correct username
        issues_mixin._get_account_id.assert_called_once_with("testuser")

        # Verify the assignee was properly set for Server/DC (name)
        fields = issues_mixin.jira.create_issue.call_args[1]["fields"]
        assert fields["assignee"] == {"name": "server-user"}

    def test_create_epic(self, issues_mixin: IssuesMixin):
        """Test creating an epic."""
        # Mock responses
        create_response = {"key": "EPIC-123"}
        issues_mixin.jira.create_issue.return_value = create_response
        issues_mixin.get_issue = MagicMock(
            return_value=JiraIssue(key="EPIC-123", description="", summary="Test Epic")
        )

        # Mock the prepare_epic_fields method from EpicsMixin
        with patch(
            "mcp_atlassian.jira.epics.EpicsMixin.prepare_epic_fields", autospec=True
        ) as mock_prepare_epic:
            # Set up the mock to store epic values in kwargs
            # Note: First argument is self because EpicsMixin.prepare_epic_fields is called as a class method
            def side_effect(self_args, fields, summary, kwargs, project_key):
                kwargs["__epic_name_value"] = summary
                kwargs["__epic_name_field"] = "customfield_10011"
                return None

            mock_prepare_epic.side_effect = side_effect

            # Mock get_field_ids_to_epic
            with patch.object(
                issues_mixin,
                "get_field_ids_to_epic",
                return_value={"Epic Name": "customfield_10011"},
            ):
                # Call the method
                result = issues_mixin.create_issue(
                    project_key="TEST",
                    summary="Test Epic",
                    issue_type="Epic",
                )

                # Verify create_issue was called with the right project and summary
                create_args = issues_mixin.jira.create_issue.call_args[1]
                fields = create_args["fields"]
                assert fields["project"]["key"] == "TEST"
                assert fields["summary"] == "Test Epic"

                # Verify epic fields are NOT in the fields dictionary (two-step creation)
                assert "customfield_10011" not in fields

                # Verify that prepare_epic_fields was called
                mock_prepare_epic.assert_called_once()

                # For an Epic, verify that update_issue should be called for the second step
                # This would happen in the EpicsMixin.update_epic_fields method which is called
                # after the initial creation
                assert issues_mixin.get_issue.called
                assert result.key == "EPIC-123"

    def test_update_issue_basic(self, issues_mixin: IssuesMixin):
        """Test updating an issue with basic fields."""
        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Updated Summary",
                "description": "This is a test issue",
                "status": {"name": "In Progress"},
                "issuetype": {"name": "Bug"},
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Mock empty comments
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}

        # Call the method
        document = issues_mixin.update_issue(
            issue_key="TEST-123", fields={"summary": "Updated Summary"}
        )

        # Verify the API calls
        issues_mixin.jira.update_issue.assert_called_once_with(
            issue_key="TEST-123", update={"fields": {"summary": "Updated Summary"}}
        )
        assert issues_mixin.jira.get_issue.called
        assert issues_mixin.jira.get_issue.call_args[0][0] == "TEST-123"

        # Verify the result
        assert document.id == "12345"
        assert document.key == "TEST-123"
        assert document.summary == "Updated Summary"

    def test_update_issue_with_status(self, issues_mixin: IssuesMixin):
        """Test updating an issue with a status change."""
        # Mock get_issue response
        issues_mixin.get_issue = MagicMock(
            return_value=JiraIssue(key="TEST-123", description="")
        )

        # Mock available transitions (using TransitionsMixin's normalized format)
        issues_mixin.get_available_transitions = MagicMock(
            return_value=[
                {
                    "id": "21",
                    "name": "In Progress",
                    "to_status": "In Progress",
                }
            ]
        )

        # Call the method with status in kwargs instead of fields
        issues_mixin.update_issue(issue_key="TEST-123", status="In Progress")

    def test_update_issue_unassign(self, issues_mixin: IssuesMixin):
        """Test unassigning an issue."""
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data
        issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
        issues_mixin._get_account_id = MagicMock()

        document = issues_mixin.update_issue(issue_key="TEST-123", assignee=None)

        issues_mixin.jira.update_issue.assert_called_once_with(
            issue_key="TEST-123", update={"fields": {"assignee": None}}
        )
        assert not issues_mixin._get_account_id.called
        assert document.key == "TEST-123"

    def test_delete_issue(self, issues_mixin: IssuesMixin):
        """Test deleting an issue."""
        # Call the method
        result = issues_mixin.delete_issue("TEST-123")

        # Verify the API call
        issues_mixin.jira.delete_issue.assert_called_once_with("TEST-123")
        assert result is True

    def test_delete_issue_error(self, issues_mixin: IssuesMixin):
        """Test error handling when deleting an issue."""
        # Setup mock to throw exception
        issues_mixin.jira.delete_issue.side_effect = Exception("Delete failed")

        # Call the method and verify exception is raised correctly
        with pytest.raises(
            Exception, match="Error deleting issue TEST-123: Delete failed"
        ):
            issues_mixin.delete_issue("TEST-123")

    def test_process_additional_fields_with_fixversions(
        self, issues_mixin: IssuesMixin
    ):
        """Test _process_additional_fields properly handles fixVersions field."""
        # Initialize test data
        fields = {}
        kwargs = {"fixVersions": [{"name": "TestRelease"}]}

        # Call the method
        issues_mixin._process_additional_fields(fields, kwargs)

        # Verify fixVersions was added correctly to fields
        assert "fixVersions" in fields
        assert fields["fixVersions"] == [{"name": "TestRelease"}]

    def test_create_issue_with_parent_for_task(self, issues_mixin: IssuesMixin):
        """Test creating a regular task issue with a parent field."""
        # Setup mock response for create_issue
        create_response = {
            "id": "12345",
            "key": "TEST-456",
            "self": "https://jira.example.com/rest/api/2/issue/12345",
        }
        issues_mixin.jira.create_issue.return_value = create_response

        # Setup mock response for issue retrieval
        issue_response = {
            "id": "12345",
            "key": "TEST-456",
            "fields": {
                "summary": "Test Task with Parent",
                "description": "This is a test",
                "status": {"name": "Open"},
                "issuetype": {"name": "Task"},
                "parent": {"key": "TEST-123"},
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_response

        issues_mixin._get_account_id = MagicMock(return_value="user123")

        # Execute - create a Task with parent field
        result = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Task with Parent",
            issue_type="Task",
            description="This is a test",
            assignee="jdoe",
            parent="TEST-123",  # Adding parent for a non-subtask
        )

        # Verify
        issues_mixin.jira.create_issue.assert_called_once()
        call_kwargs = issues_mixin.jira.create_issue.call_args[1]
        assert "fields" in call_kwargs
        fields = call_kwargs["fields"]

        # Verify parent field was included
        assert "parent" in fields
        assert fields["parent"] == {"key": "TEST-123"}

        # Verify issue method was called after creation
        assert issues_mixin.jira.get_issue.called
        assert issues_mixin.jira.get_issue.call_args[0][0] == "TEST-456"

        # Verify the issue was created successfully
        assert result is not None
        assert result.key == "TEST-456"

    def test_create_issue_with_fixversions(self, issues_mixin: IssuesMixin):
        """Test creating an issue with fixVersions in additional_fields."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "fixVersions": [{"name": "1.0.0"}],
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Create the issue with fixVersions in additional_fields
        result = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
            fixVersions=[{"name": "1.0.0"}],
        )

        # Verify API call to create issue
        issues_mixin.jira.create_issue.assert_called_once()
        call_args = issues_mixin.jira.create_issue.call_args[1]
        fields = call_args["fields"]
        assert fields["project"]["key"] == "TEST"
        assert fields["summary"] == "Test Issue"
        assert fields["issuetype"]["name"] == "Bug"
        assert fields["description"] == "This is a test issue"
        assert "fixVersions" in fields
        assert fields["fixVersions"] == [{"name": "1.0.0"}]

        # Verify API call to get issue
        issues_mixin.jira.get_issue.assert_called_once_with("TEST-123")

        # Verify result
        assert result.key == "TEST-123"
        assert result.summary == "Test Issue"
        assert result.issue_type and result.issue_type.name == "Bug"
        assert hasattr(result, "fix_versions")
        assert len(result.fix_versions) == 1
        # The JiraIssue model might process fixVersions differently, check the actual structure
        # This depends on how JiraIssue.from_api_response handles the fixVersions field
        # If it's a list of dictionaries, use:
        if hasattr(result.fix_versions[0], "name"):
            assert result.fix_versions[0].name == "1.0.0"
        else:
            # If it's a list of strings or other format, adjust accordingly:
            assert "1.0.0" in str(result.fix_versions[0])

    def test_get_issue_with_custom_fields(self, issues_mixin: IssuesMixin):
        """Test get_issue with custom fields parameter."""
        # Mock the response with custom fields
        mock_issue = {
            "id": "10001",
            "key": "TEST-123",
            "fields": {
                "summary": "Test issue with custom field",
                "customfield_10049": "Custom value",
                "customfield_10050": {"value": "Option value"},
                "description": "Issue description",
            },
        }
        issues_mixin.jira.get_issue.return_value = mock_issue

        # Test with string format
        issue = issues_mixin.get_issue("TEST-123", fields="summary,customfield_10049")

        # Verify the API call
        issues_mixin.jira.get_issue.assert_called_with(
            "TEST-123",
            expand=None,
            fields="summary,customfield_10049",
            properties=None,
            update_history=True,
        )

        # Check the result
        simplified = issue.to_simplified_dict()
        assert "customfield_10049" in simplified
        assert simplified["customfield_10049"] == {"value": "Custom value"}
        assert "description" not in simplified

        # Test with list format
        issues_mixin.jira.get_issue.reset_mock()
        issue = issues_mixin.get_issue(
            "TEST-123", fields=["summary", "customfield_10050"]
        )

        # Verify API call converts list to comma-separated string
        issues_mixin.jira.get_issue.assert_called_with(
            "TEST-123",
            expand=None,
            fields="summary,customfield_10050",
            properties=None,
            update_history=True,
        )

        # Check the result
        simplified = issue.to_simplified_dict()
        assert "customfield_10050" in simplified
        assert simplified["customfield_10050"] == {"value": "Option value"}

    def test_get_issue_with_all_fields(self, issues_mixin: IssuesMixin):
        """Test get_issue with '*all' fields parameter."""
        # Mock the response
        mock_issue = {
            "id": "10001",
            "key": "TEST-123",
            "fields": {
                "summary": "Test issue",
                "description": "Description",
                "customfield_10049": "Custom value",
            },
        }
        issues_mixin.jira.get_issue.return_value = mock_issue

        # Test with "*all" parameter
        issue = issues_mixin.get_issue("TEST-123", fields="*all")

        # Check that all fields are included
        simplified = issue.to_simplified_dict()
        assert "summary" in simplified
        assert "description" in simplified
        assert "customfield_10049" in simplified

    def test_get_issue_with_properties(self, issues_mixin: IssuesMixin):
        """Test get_issue with properties parameter."""
        # Mock the response
        issues_mixin.jira.get_issue.return_value = {
            "id": "10001",
            "key": "TEST-123",
            "fields": {},
        }

        # Test with properties parameter as string
        issues_mixin.get_issue("TEST-123", properties="property1,property2")

        # Verify API call - should include properties parameter and add 'properties' to fields
        issues_mixin.jira.get_issue.assert_called_with(
            "TEST-123",
            expand=None,
            fields=ANY,
            properties="property1,property2",
            update_history=True,
        )

        # Test with properties parameter as list
        issues_mixin.jira.get_issue.reset_mock()
        issues_mixin.get_issue("TEST-123", properties=["property1", "property2"])

        # Verify API call - should include properties parameter as comma-separated string and add 'properties' to fields
        issues_mixin.jira.get_issue.assert_called_with(
            "TEST-123",
            expand=None,
            fields=ANY,
            properties="property1,property2",
            update_history=True,
        )

    def test_get_issue_with_update_history(self, issues_mixin: IssuesMixin):
        """Test get_issue with update_history parameter."""
        # Mock the response
        issues_mixin.jira.get_issue.return_value = {
            "id": "10001",
            "key": "TEST-123",
            "fields": {},
        }

        # Test with update_history=False
        issues_mixin.get_issue("TEST-123", update_history=False)

        # Verify API call - should include update_history parameter
        issues_mixin.jira.get_issue.assert_called_with(
            "TEST-123",
            expand=None,
            fields=ANY,
            properties=None,
            update_history=False,
        )

    def test_batch_create_issues_basic(self, issues_mixin: IssuesMixin):
        """Test basic functionality of batch_create_issues."""
        # Setup test data
        issues = [
            {
                "project_key": "TEST",
                "summary": "Test Issue 1",
                "issue_type": "Task",
                "description": "Description 1",
            },
            {
                "project_key": "TEST",
                "summary": "Test Issue 2",
                "issue_type": "Bug",
                "description": "Description 2",
                "assignee": "john.doe",
                "components": ["Frontend"],
            },
        ]

        # Mock bulk create response
        bulk_response = {
            "issues": [
                {"id": "1", "key": "TEST-1", "self": "http://example.com/TEST-1"},
                {"id": "2", "key": "TEST-2", "self": "http://example.com/TEST-2"},
            ],
            "errors": [],
        }
        issues_mixin.jira.create_issues.return_value = bulk_response

        # Mock get_issue responses
        def get_issue_side_effect(key):
            if key == "TEST-1":
                return {
                    "id": "1",
                    "key": "TEST-1",
                    "fields": {"summary": "Test Issue 1"},
                }
            return {"id": "2", "key": "TEST-2", "fields": {"summary": "Test Issue 2"}}

        issues_mixin.jira.get_issue.side_effect = get_issue_side_effect
        issues_mixin._get_account_id.return_value = "user123"

        # Call the method
        result = issues_mixin.batch_create_issues(issues)

        # Verify results
        assert len(result) == 2
        assert result[0].key == "TEST-1"
        assert result[1].key == "TEST-2"

        # Verify bulk create was called correctly
        issues_mixin.jira.create_issues.assert_called_once()
        call_args = issues_mixin.jira.create_issues.call_args[0][0]
        assert len(call_args) == 2
        assert call_args[0]["fields"]["summary"] == "Test Issue 1"
        assert call_args[1]["fields"]["summary"] == "Test Issue 2"

    def test_batch_create_issues_validate_only(self, issues_mixin: IssuesMixin):
        """Test batch_create_issues with validate_only=True."""
        # Setup test data
        issues = [
            {
                "project_key": "TEST",
                "summary": "Test Issue 1",
                "issue_type": "Task",
            },
            {
                "project_key": "TEST",
                "summary": "Test Issue 2",
                "issue_type": "Bug",
            },
        ]

        # Call the method with validate_only=True
        result = issues_mixin.batch_create_issues(issues, validate_only=True)

        # Verify no issues were created
        assert len(result) == 0
        assert not issues_mixin.jira.create_issues.called

    def test_batch_create_issues_missing_required_fields(
        self, issues_mixin: IssuesMixin
    ):
        """Test batch_create_issues with missing required fields."""
        # Setup test data with missing fields
        issues = [
            {
                "project_key": "TEST",
                "summary": "Test Issue 1",
                # Missing issue_type
            },
            {
                "project_key": "TEST",
                "summary": "Test Issue 2",
                "issue_type": "Bug",
            },
        ]

        # Verify it raises ValueError
        with pytest.raises(ValueError) as exc_info:
            issues_mixin.batch_create_issues(issues)

        assert "Missing required fields" in str(exc_info.value)
        assert not issues_mixin.jira.create_issues.called

    def test_batch_create_issues_partial_failure(self, issues_mixin: IssuesMixin):
        """Test batch_create_issues when some issues fail to create."""
        # Setup test data
        issues = [
            {
                "project_key": "TEST",
                "summary": "Test Issue 1",
                "issue_type": "Task",
            },
            {
                "project_key": "TEST",
                "summary": "Test Issue 2",
                "issue_type": "Bug",
            },
        ]

        # Mock bulk create response with an error
        bulk_response = {
            "issues": [
                {"id": "1", "key": "TEST-1", "self": "http://example.com/TEST-1"},
            ],
            "errors": [{"issue": {"key": None}, "error": "Invalid issue type"}],
        }
        issues_mixin.jira.create_issues.return_value = bulk_response

        # Mock get_issue response for successful creation
        issues_mixin.jira.get_issue.return_value = {
            "id": "1",
            "key": "TEST-1",
            "fields": {"summary": "Test Issue 1"},
        }

        # Call the method
        result = issues_mixin.batch_create_issues(issues)

        # Verify results - should have only the first issue
        assert len(result) == 1
        assert result[0].key == "TEST-1"

        # Verify error was logged
        issues_mixin.jira.create_issues.assert_called_once()
        assert len(issues_mixin.jira.get_issue.mock_calls) == 1

    def test_batch_create_issues_empty_list(self, issues_mixin: IssuesMixin):
        """Test batch_create_issues with an empty list."""
        result = issues_mixin.batch_create_issues([])
        assert result == []
        assert not issues_mixin.jira.create_issues.called

    def test_batch_create_issues_with_components(self, issues_mixin: IssuesMixin):
        """Test batch_create_issues with component handling."""
        # Setup test data with various component formats
        issues = [
            {
                "project_key": "TEST",
                "summary": "Test Issue 1",
                "issue_type": "Task",
                "components": ["Frontend", "", None, "  Backend  "],
            }
        ]

        # Mock responses
        bulk_response = {
            "issues": [
                {"id": "1", "key": "TEST-1", "self": "http://example.com/TEST-1"},
            ],
            "errors": [],
        }
        issues_mixin.jira.create_issues.return_value = bulk_response
        issues_mixin.jira.get_issue.return_value = {
            "id": "1",
            "key": "TEST-1",
            "fields": {"summary": "Test Issue 1"},
        }

        # Call the method
        result = issues_mixin.batch_create_issues(issues)

        # Verify results
        assert len(result) == 1

        # Verify components were properly formatted
        call_args = issues_mixin.jira.create_issues.call_args[0][0]
        assert len(call_args) == 1
        components = call_args[0]["fields"]["components"]
        assert len(components) == 2
        assert components[0]["name"] == "Frontend"
        assert components[1]["name"] == "Backend"

    def test_add_assignee_to_fields_cloud(self, issues_mixin: IssuesMixin):
        """Test _add_assignee_to_fields for Cloud instance."""
        # Set up cloud config
        issues_mixin.config = MagicMock()
        issues_mixin.config.is_cloud = True

        # Test fields dict
        fields = {}

        # Call the method
        issues_mixin._add_assignee_to_fields(fields, "account-123")

        # Verify result
        assert fields["assignee"] == {"accountId": "account-123"}

    def test_add_assignee_to_fields_server_dc(self, issues_mixin: IssuesMixin):
        """Test _add_assignee_to_fields for Server/Data Center instance."""
        # Set up Server/DC config
        issues_mixin.config = MagicMock()
        issues_mixin.config.is_cloud = False

        # Test fields dict
        fields = {}

        # Call the method
        issues_mixin._add_assignee_to_fields(fields, "jdoe")

        # Verify result
        assert fields["assignee"] == {"name": "jdoe"}

    def test_batch_get_changelogs_not_cloud(self, issues_mixin: IssuesMixin):
        """Test batch_get_changelogs method on non-cloud instance."""
        issues_mixin.config = MagicMock()
        issues_mixin.config.is_cloud = False

        with pytest.raises(NotImplementedError):
            issues_mixin.batch_get_changelogs(
                issue_ids_or_keys=["TEST-123"],
                fields=["summary", "description"],
            )

    def test_batch_get_changelogs_cloud(self, issues_mixin: IssuesMixin):
        """Test batch_get_changelogs method on cloud instance."""
        issues_mixin.config = MagicMock()
        issues_mixin.config.is_cloud = True

        # Mock get_paged result
        mock_get_paged_result = [
            {
                "issueChangeLogs": [
                    {
                        "issueId": "TEST-1",
                        "changeHistories": [
                            {
                                "id": "10001",
                                "author": {
                                    "accountId": "user123",
                                    "displayName": "Test User 1",
                                    "active": True,
                                    "timeZone": "UTC",
                                    "accountType": "atlassian",
                                },
                                "created": "2024-01-05T10:06:03.548+0800",
                                "items": [
                                    {
                                        "field": "IssueParentAssociation",
                                        "fieldtype": "jira",
                                        "from": None,
                                        "fromString": None,
                                        "to": "1001",
                                        "toString": "TEST-100",
                                    }
                                ],
                            }
                        ],
                    },
                    {
                        "issueId": "TEST-2",
                        "changeHistories": [
                            {
                                "id": "10002",
                                "author": {
                                    "accountId": "user456",
                                    "displayName": "Test User 2",
                                    "active": True,
                                    "timeZone": "UTC",
                                    "accountType": "atlassian",
                                },
                                "created": "1704106800000",  # 2024-01-01
                                "items": [
                                    {
                                        "field": "Parent",
                                        "fieldtype": "jira",
                                        "from": None,
                                        "fromString": None,
                                        "to": "1002",
                                        "toString": "TEST-200",
                                    }
                                ],
                            },
                            {
                                "id": "10003",
                                "author": {
                                    "accountId": "user789",
                                    "displayName": "Test User 3",
                                    "active": True,
                                    "timeZone": "UTC",
                                    "accountType": "atlassian",
                                },
                                "created": "2024-01-06T10:06:03.548+0800",
                                "items": [
                                    {
                                        "field": "Parent",
                                        "fieldtype": "jira",
                                        "from": "1002",
                                        "fromString": "TEST-200",
                                        "to": "1003",
                                        "toString": "TEST-300",
                                    }
                                ],
                            },
                        ],
                    },
                ],
                "nextPageToken": "token1",
            },
            {
                "issueChangeLogs": [
                    {
                        "issueId": "TEST-2",
                        "changeHistories": [
                            {
                                "id": "10004",
                                "author": {
                                    "accountId": "user123",
                                    "displayName": "Test User 1",
                                    "active": True,
                                    "timeZone": "UTC",
                                    "accountType": "atlassian",
                                },
                                "created": "2024-01-10T10:06:03.548+0800",
                                "items": [
                                    {
                                        "field": "Parent",
                                        "fieldtype": "jira",
                                        "from": "1003",
                                        "fromString": "TEST-300",
                                        "to": "1004",
                                        "toString": "TEST-400",
                                    }
                                ],
                            }
                        ],
                    }
                ],
            },
        ]

        # Expected result
        expected_result = [
            {
                "assignee": {"display_name": "Unassigned"},
                "changelogs": [
                    {
                        "author": {
                            "avatar_url": None,
                            "display_name": "Test User 1",
                            "email": None,
                            "name": "Test User 1",
                        },
                        "created": "2024-01-05 10:06:03.548000+08:00",
                        "items": [
                            {
                                "field": "IssueParentAssociation",
                                "fieldtype": "jira",
                                "to_id": "1001",
                                "to_string": "TEST-100",
                            },
                        ],
                    },
                ],
                "id": "TEST-1",
                "key": "UNKNOWN-0",
                "summary": "",
            },
            {
                "assignee": {"display_name": "Unassigned"},
                "changelogs": [
                    {
                        "author": {
                            "avatar_url": None,
                            "display_name": "Test User 2",
                            "email": None,
                            "name": "Test User 2",
                        },
                        "created": "2024-01-01 11:00:00+00:00",
                        "items": [
                            {
                                "field": "Parent",
                                "fieldtype": "jira",
                                "to_id": "1002",
                                "to_string": "TEST-200",
                            },
                        ],
                    },
                    {
                        "author": {
                            "avatar_url": None,
                            "display_name": "Test User 3",
                            "email": None,
                            "name": "Test User 3",
                        },
                        "created": "2024-01-06 10:06:03.548000+08:00",
                        "items": [
                            {
                                "field": "Parent",
                                "fieldtype": "jira",
                                "from_id": "1002",
                                "from_string": "TEST-200",
                                "to_id": "1003",
                                "to_string": "TEST-300",
                            },
                        ],
                    },
                    {
                        "author": {
                            "avatar_url": None,
                            "display_name": "Test User 1",
                            "email": None,
                            "name": "Test User 1",
                        },
                        "created": "2024-01-10 10:06:03.548000+08:00",
                        "items": [
                            {
                                "field": "Parent",
                                "fieldtype": "jira",
                                "from_id": "1003",
                                "from_string": "TEST-300",
                                "to_id": "1004",
                                "to_string": "TEST-400",
                            },
                        ],
                    },
                ],
                "id": "TEST-2",
                "key": "UNKNOWN-0",
                "summary": "",
            },
        ]

        # Mock the get_paged method
        issues_mixin.get_paged = MagicMock(return_value=mock_get_paged_result)

        # Call the method
        result = issues_mixin.batch_get_changelogs(
            issue_ids_or_keys=["TEST-1", "TEST-2"],
            fields=["Parent"],
        )

        # Verify the result
        simplified_result = [issue.to_simplified_dict() for issue in result]
        assert simplified_result == expected_result

        # Verify the method was called with the correct arguments
        issues_mixin.get_paged.assert_called_once_with(
            method="post",
            url=issues_mixin.jira.resource_url("changelog/bulkfetch"),
            params_or_json={
                "fieldIds": ["Parent"],
                "issueIdsOrKeys": ["TEST-1", "TEST-2"],
            },
        )

    def test_create_issue_with_labels(self, issues_mixin: IssuesMixin):
        """Test creating an issue with labels in additional_fields."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "labels": ["bug", "frontend"],
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Create the issue with labels as a list
        result = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
            labels=["bug", "frontend"],
        )

        # Verify the API call
        issues_mixin.jira.create_issue.assert_called_once()
        call_kwargs = issues_mixin.jira.create_issue.call_args[1]
        assert "fields" in call_kwargs
        fields = call_kwargs["fields"]

        # Verify labels were added to the fields
        assert "labels" in fields
        assert fields["labels"] == ["bug", "frontend"]

        # Verify result
        assert result.key == "TEST-123"
        assert result.labels == ["bug", "frontend"]

    def test_create_issue_with_labels_as_string(self, issues_mixin: IssuesMixin):
        """Test creating an issue with labels as comma-separated string in additional_fields."""
        # Mock create_issue response
        create_response = {"id": "12345", "key": "TEST-123"}
        issues_mixin.jira.create_issue.return_value = create_response

        # Mock the issue data for get_issue
        issue_data = {
            "id": "12345",
            "key": "TEST-123",
            "fields": {
                "summary": "Test Issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
                "labels": ["bug", "frontend"],
            },
        }
        issues_mixin.jira.get_issue.return_value = issue_data

        # Create the issue with labels as a comma-separated string
        # Pass labels directly instead of through additional_fields
        result = issues_mixin.create_issue(
            project_key="TEST",
            summary="Test Issue",
            issue_type="Bug",
            description="This is a test issue",
            labels="bug,frontend",  # Pass as string and let _format_field_value_for_write handle it
        )

        # Verify the API call
        issues_mixin.jira.create_issue.assert_called_once()
        call_kwargs = issues_mixin.jira.create_issue.call_args[1]
        assert "fields" in call_kwargs
        fields = call_kwargs["fields"]

        # Verify labels were parsed and added to the fields
        assert "labels" in fields
        assert fields["labels"] == ["bug", "frontend"]

        # Verify result
        assert result.key == "TEST-123"
        assert result.labels == ["bug", "frontend"]

    def test_get_issue_with_config_projects_filter_restricted(
        self, issues_mixin: IssuesMixin
    ):
        """Test get_issue with projects filter from config - restricted case."""
        # Setup mock response
        mock_issues = {
            "issues": [
                {
                    "id": "10001",
                    "key": "TEST-123",
                    "fields": {
                        "summary": "Test issue",
                        "issuetype": {"name": "Bug"},
                        "status": {"name": "Open"},
                    },
                }
            ],
            "total": 1,
            "startAt": 0,
            "maxResults": 50,
        }
        issues_mixin.jira.jql.return_value = mock_issues
        issues_mixin.config.url = "https://example.atlassian.net"
        issues_mixin.config.projects_filter = "DEV"

        # Mock the API to raise an exception
        issues_mixin.jira.get_issue.side_effect = Exception("API error")

        # Call the method and verify it raises the expected exception
        with pytest.raises(
            Exception,
            match=(
                "Error retrieving issue TEST-123: "
                "Issue with project prefix 'TEST' are restricted by configuration"
            ),
        ):
            issues_mixin.get_issue("TEST-123")

    def test_get_issue_with_config_projects_filter_allowed(
        self, issues_mixin: IssuesMixin
    ):
        """Test get_issue with projects filter from config - allowed case."""
        # Setup mock response for a project that matches the filter
        mock_issue_data = {
            "id": "10001",
            "key": "DEV-123",
            "fields": {
                "summary": "Test issue",
                "description": "This is a test issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
            },
        }
        issues_mixin.jira.get_issue.return_value = mock_issue_data
        issues_mixin.config.url = "https://example.atlassian.net"
        issues_mixin.config.projects_filter = "DEV"

        # Call the method
        result = issues_mixin.get_issue("DEV-123")

        # Verify the API call was made correctly
        issues_mixin.jira.get_issue.assert_called_once_with(
            "DEV-123",
            expand=None,
            fields=ANY,
            properties=None,
            update_history=True,
        )

        # Verify the result
        assert isinstance(result, JiraIssue)
        assert result.key == "DEV-123"
        assert result.summary == "Test issue"

    def test_get_issue_with_multiple_projects_filter(self, issues_mixin: IssuesMixin):
        """Test get_issue with multiple projects in the filter."""
        # Setup mock response for a project that matches one of the multiple filters
        mock_issue_data = {
            "id": "10001",
            "key": "PROD-123",
            "fields": {
                "summary": "Production issue",
                "description": "This is a production issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
            },
        }
        issues_mixin.jira.get_issue.return_value = mock_issue_data
        issues_mixin.config.url = "https://example.atlassian.net"
        issues_mixin.config.projects_filter = "DEV,PROD"

        # Call the method
        result = issues_mixin.get_issue("PROD-123")

        # Verify the API call was made correctly
        issues_mixin.jira.get_issue.assert_called_once_with(
            "PROD-123",
            expand=None,
            fields=ANY,
            properties=None,
            update_history=True,
        )

        # Verify the result
        assert isinstance(result, JiraIssue)
        assert result.key == "PROD-123"
        assert result.summary == "Production issue"

    def test_get_issue_with_whitespace_in_projects_filter(
        self, issues_mixin: IssuesMixin
    ):
        """Test get_issue with extra whitespace in the projects filter."""
        # Setup mock response for a project that matches the filter with whitespace
        mock_issue_data = {
            "id": "10001",
            "key": "DEV-123",
            "fields": {
                "summary": "Development issue",
                "description": "This is a development issue",
                "status": {"name": "Open"},
                "issuetype": {"name": "Bug"},
            },
        }
        issues_mixin.jira.get_issue.return_value = mock_issue_data
        issues_mixin.config.url = "https://example.atlassian.net"
        issues_mixin.config.projects_filter = " DEV , PROD "  # Extra whitespace

        # Call the method
        result = issues_mixin.get_issue("DEV-123")

        # Verify the API call was made correctly
        issues_mixin.jira.get_issue.assert_called_once_with(
            "DEV-123",
            expand=None,
            fields=ANY,
            properties=None,
            update_history=True,
        )

        # Verify the result
        assert isinstance(result, JiraIssue)
        assert result.key == "DEV-123"
        assert result.summary == "Development issue"

```

--------------------------------------------------------------------------------
/tests/unit/models/test_jira_models.py:
--------------------------------------------------------------------------------

```python
"""
Tests for the Jira Pydantic models.

These tests validate the conversion of Jira API responses to structured models
and the simplified dictionary conversion for API responses.
"""

import os
import re

import pytest

from src.mcp_atlassian.models.constants import (
    EMPTY_STRING,
    JIRA_DEFAULT_ID,
    JIRA_DEFAULT_PROJECT,
    UNKNOWN,
)
from src.mcp_atlassian.models.jira import (
    JiraComment,
    JiraIssue,
    JiraIssueLink,
    JiraIssueLinkType,
    JiraIssueType,
    JiraLinkedIssue,
    JiraLinkedIssueFields,
    JiraPriority,
    JiraProject,
    JiraResolution,
    JiraSearchResult,
    JiraStatus,
    JiraStatusCategory,
    JiraTimetracking,
    JiraTransition,
    JiraUser,
    JiraWorklog,
)

# Optional: Import real API client for optional real-data testing
try:
    from atlassian import Jira

    from src.mcp_atlassian.jira import JiraConfig, JiraFetcher
    from src.mcp_atlassian.jira.issues import IssuesMixin
    from src.mcp_atlassian.jira.projects import ProjectsMixin
    from src.mcp_atlassian.jira.transitions import TransitionsMixin
    from src.mcp_atlassian.jira.worklog import WorklogMixin

    real_api_available = True
except ImportError:
    real_api_available = False

    # Create a module-level namespace for dummy classes
    class _DummyClasses:
        """Namespace for dummy classes when real imports fail."""

        class JiraFetcher:
            pass

        class JiraConfig:
            @staticmethod
            def from_env():
                return None

        class IssuesMixin:
            pass

        class ProjectsMixin:
            pass

        class TransitionsMixin:
            pass

        class WorklogMixin:
            pass

        class Jira:
            pass

    # Assign dummy classes to module namespace
    JiraFetcher = _DummyClasses.JiraFetcher
    JiraConfig = _DummyClasses.JiraConfig
    IssuesMixin = _DummyClasses.IssuesMixin
    ProjectsMixin = _DummyClasses.ProjectsMixin
    TransitionsMixin = _DummyClasses.TransitionsMixin
    WorklogMixin = _DummyClasses.WorklogMixin
    Jira = _DummyClasses.Jira


class TestJiraUser:
    """Tests for the JiraUser model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraUser from valid API data."""
        user_data = {
            "accountId": "user123",
            "displayName": "Test User",
            "emailAddress": "[email protected]",
            "active": True,
            "avatarUrls": {
                "48x48": "https://example.com/avatar.png",
                "24x24": "https://example.com/avatar-small.png",
            },
            "timeZone": "UTC",
        }
        user = JiraUser.from_api_response(user_data)
        assert user.account_id == "user123"
        assert user.display_name == "Test User"
        assert user.email == "[email protected]"
        assert user.active is True
        assert user.avatar_url == "https://example.com/avatar.png"
        assert user.time_zone == "UTC"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraUser from empty data."""
        user = JiraUser.from_api_response({})
        assert user.account_id is None
        assert user.display_name == "Unassigned"
        assert user.email is None
        assert user.active is True
        assert user.avatar_url is None
        assert user.time_zone is None

    def test_from_api_response_with_none_data(self):
        """Test creating a JiraUser from None data."""
        user = JiraUser.from_api_response(None)
        assert user.account_id is None
        assert user.display_name == "Unassigned"
        assert user.email is None
        assert user.active is True
        assert user.avatar_url is None
        assert user.time_zone is None

    def test_to_simplified_dict(self):
        """Test converting JiraUser to a simplified dictionary."""
        user = JiraUser(
            account_id="user123",
            display_name="Test User",
            email="[email protected]",
            active=True,
            avatar_url="https://example.com/avatar.png",
            time_zone="UTC",
        )
        simplified = user.to_simplified_dict()
        assert isinstance(simplified, dict)
        assert simplified["display_name"] == "Test User"
        assert simplified["email"] == "[email protected]"
        assert simplified["avatar_url"] == "https://example.com/avatar.png"
        assert "account_id" not in simplified
        assert "time_zone" not in simplified


class TestJiraStatusCategory:
    """Tests for the JiraStatusCategory model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraStatusCategory from valid API data."""
        data = {
            "id": 4,
            "key": "indeterminate",
            "name": "In Progress",
            "colorName": "yellow",
        }
        category = JiraStatusCategory.from_api_response(data)
        assert category.id == 4
        assert category.key == "indeterminate"
        assert category.name == "In Progress"
        assert category.color_name == "yellow"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraStatusCategory from empty data."""
        category = JiraStatusCategory.from_api_response({})
        assert category.id == 0
        assert category.key == EMPTY_STRING
        assert category.name == UNKNOWN
        assert category.color_name == EMPTY_STRING


class TestJiraStatus:
    """Tests for the JiraStatus model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraStatus from valid API data."""
        data = {
            "id": "10000",
            "name": "In Progress",
            "description": "Work is in progress",
            "iconUrl": "https://example.com/icon.png",
            "statusCategory": {
                "id": 4,
                "key": "indeterminate",
                "name": "In Progress",
                "colorName": "yellow",
            },
        }
        status = JiraStatus.from_api_response(data)
        assert status.id == "10000"
        assert status.name == "In Progress"
        assert status.description == "Work is in progress"
        assert status.icon_url == "https://example.com/icon.png"
        assert status.category is not None
        assert status.category.id == 4
        assert status.category.name == "In Progress"
        assert status.category.color_name == "yellow"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraStatus from empty data."""
        status = JiraStatus.from_api_response({})
        assert status.id == JIRA_DEFAULT_ID
        assert status.name == UNKNOWN
        assert status.description is None
        assert status.icon_url is None
        assert status.category is None

    def test_to_simplified_dict(self):
        """Test converting JiraStatus to a simplified dictionary."""
        status = JiraStatus(
            id="10000",
            name="In Progress",
            description="Work is in progress",
            icon_url="https://example.com/icon.png",
            category=JiraStatusCategory(
                id=4, key="indeterminate", name="In Progress", color_name="yellow"
            ),
        )
        simplified = status.to_simplified_dict()
        assert isinstance(simplified, dict)
        assert simplified["name"] == "In Progress"
        assert "category" in simplified
        assert simplified["category"] == "In Progress"
        assert "color" in simplified
        assert simplified["color"] == "yellow"
        assert "description" not in simplified


class TestJiraIssueType:
    """Tests for the JiraIssueType model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraIssueType from valid API data."""
        data = {
            "id": "10000",
            "name": "Task",
            "description": "A task that needs to be done.",
            "iconUrl": "https://example.com/task-icon.png",
        }
        issue_type = JiraIssueType.from_api_response(data)
        assert issue_type.id == "10000"
        assert issue_type.name == "Task"
        assert issue_type.description == "A task that needs to be done."
        assert issue_type.icon_url == "https://example.com/task-icon.png"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraIssueType from empty data."""
        issue_type = JiraIssueType.from_api_response({})
        assert issue_type.id == JIRA_DEFAULT_ID
        assert issue_type.name == UNKNOWN
        assert issue_type.description is None
        assert issue_type.icon_url is None

    def test_to_simplified_dict(self):
        """Test converting JiraIssueType to a simplified dictionary."""
        issue_type = JiraIssueType(
            id="10000",
            name="Task",
            description="A task that needs to be done.",
            icon_url="https://example.com/task-icon.png",
        )
        simplified = issue_type.to_simplified_dict()
        assert isinstance(simplified, dict)
        assert simplified["name"] == "Task"
        assert "id" not in simplified
        assert "description" not in simplified
        assert "icon_url" not in simplified


class TestJiraPriority:
    """Tests for the JiraPriority model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraPriority from valid API data."""
        data = {
            "id": "3",
            "name": "Medium",
            "description": "Medium priority",
            "iconUrl": "https://example.com/medium-priority.png",
        }
        priority = JiraPriority.from_api_response(data)
        assert priority.id == "3"
        assert priority.name == "Medium"
        assert priority.description == "Medium priority"
        assert priority.icon_url == "https://example.com/medium-priority.png"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraPriority from empty data."""
        priority = JiraPriority.from_api_response({})
        assert priority.id == JIRA_DEFAULT_ID
        assert priority.name == "None"  # Default for priority is 'None'
        assert priority.description is None
        assert priority.icon_url is None

    def test_to_simplified_dict(self):
        """Test converting JiraPriority to a simplified dictionary."""
        priority = JiraPriority(
            id="3",
            name="Medium",
            description="Medium priority",
            icon_url="https://example.com/medium-priority.png",
        )
        simplified = priority.to_simplified_dict()
        assert isinstance(simplified, dict)
        assert simplified["name"] == "Medium"
        assert "id" not in simplified
        assert "description" not in simplified
        assert "icon_url" not in simplified


class TestJiraComment:
    """Tests for the JiraComment model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraComment from valid API data."""
        data = {
            "id": "10000",
            "body": "This is a test comment",
            "created": "2024-01-01T12:00:00.000+0000",
            "updated": "2024-01-01T12:00:00.000+0000",
            "author": {
                "accountId": "user123",
                "displayName": "Comment User",
                "active": True,
            },
        }
        comment = JiraComment.from_api_response(data)
        assert comment.id == "10000"
        assert comment.body == "This is a test comment"
        assert comment.created == "2024-01-01T12:00:00.000+0000"
        assert comment.updated == "2024-01-01T12:00:00.000+0000"
        assert comment.author is not None
        assert comment.author.display_name == "Comment User"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraComment from empty data."""
        comment = JiraComment.from_api_response({})
        assert comment.id == JIRA_DEFAULT_ID
        assert comment.body == EMPTY_STRING
        assert comment.created == EMPTY_STRING
        assert comment.updated == EMPTY_STRING
        assert comment.author is None

    def test_to_simplified_dict(self):
        """Test converting JiraComment to a simplified dictionary."""
        comment = JiraComment(
            id="10000",
            body="This is a test comment",
            created="2024-01-01T12:00:00.000+0000",
            updated="2024-01-01T12:00:00.000+0000",
            author=JiraUser(account_id="user123", display_name="Comment User"),
        )
        simplified = comment.to_simplified_dict()
        assert isinstance(simplified, dict)
        assert "body" in simplified
        assert simplified["body"] == "This is a test comment"
        assert "created" in simplified
        assert isinstance(simplified["created"], str)
        assert "author" in simplified
        assert isinstance(simplified["author"], dict)
        assert simplified["author"]["display_name"] == "Comment User"


class TestJiraTimetracking:
    """Tests for the JiraTimetracking model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraTimetracking from valid API data."""
        data = {
            "originalEstimate": "2h",
            "remainingEstimate": "1h 30m",
            "timeSpent": "30m",
            "originalEstimateSeconds": 7200,
            "remainingEstimateSeconds": 5400,
            "timeSpentSeconds": 1800,
        }
        timetracking = JiraTimetracking.from_api_response(data)
        assert timetracking.original_estimate == "2h"
        assert timetracking.remaining_estimate == "1h 30m"
        assert timetracking.time_spent == "30m"
        assert timetracking.original_estimate_seconds == 7200
        assert timetracking.remaining_estimate_seconds == 5400
        assert timetracking.time_spent_seconds == 1800

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraTimetracking from empty data."""
        timetracking = JiraTimetracking.from_api_response({})
        assert timetracking.original_estimate is None
        assert timetracking.remaining_estimate is None
        assert timetracking.time_spent is None
        assert timetracking.original_estimate_seconds is None
        assert timetracking.remaining_estimate_seconds is None
        assert timetracking.time_spent_seconds is None

    def test_from_api_response_with_none_data(self):
        """Test creating a JiraTimetracking from None data."""
        timetracking = JiraTimetracking.from_api_response(None)
        assert timetracking is not None
        assert timetracking.original_estimate is None
        assert timetracking.remaining_estimate is None
        assert timetracking.time_spent is None
        assert timetracking.original_estimate_seconds is None
        assert timetracking.remaining_estimate_seconds is None
        assert timetracking.time_spent_seconds is None

    def test_to_simplified_dict(self):
        """Test converting JiraTimetracking to a simplified dictionary."""
        timetracking = JiraTimetracking(
            original_estimate="2h",
            remaining_estimate="1h 30m",
            time_spent="30m",
            original_estimate_seconds=7200,
            remaining_estimate_seconds=5400,
            time_spent_seconds=1800,
        )
        simplified = timetracking.to_simplified_dict()
        assert isinstance(simplified, dict)
        assert simplified["original_estimate"] == "2h"
        assert simplified["remaining_estimate"] == "1h 30m"
        assert simplified["time_spent"] == "30m"
        assert "original_estimate_seconds" not in simplified
        assert "remaining_estimate_seconds" not in simplified
        assert "time_spent_seconds" not in simplified


class TestJiraIssue:
    """Tests for the JiraIssue model."""

    def test_from_api_response_with_valid_data(self, jira_issue_data):
        """Test creating a JiraIssue from valid API data."""
        issue = JiraIssue.from_api_response(jira_issue_data)

        assert issue.id == "12345"
        assert issue.key == "PROJ-123"
        assert issue.summary == "Test Issue Summary"
        assert issue.description == "This is a test issue description"
        assert issue.created == "2024-01-01T10:00:00.000+0000"
        assert issue.updated == "2024-01-02T15:30:00.000+0000"

        assert issue.status is not None
        assert issue.status.name == "In Progress"
        assert issue.status.category is not None
        assert issue.status.category.name == "In Progress"

        assert issue.issue_type is not None
        assert issue.issue_type.name == "Task"

        assert issue.priority is not None
        assert issue.priority.name == "Medium"

        assert issue.assignee is not None
        assert issue.assignee.display_name == "Test User"

        assert issue.reporter is not None
        assert issue.reporter.display_name == "Reporter User"

        assert len(issue.labels) == 1
        assert issue.labels[0] == "test-label"

        assert len(issue.comments) == 1
        assert issue.comments[0].body == "This is a test comment"

        assert isinstance(issue.fix_versions, list)
        assert "v1.0" in issue.fix_versions

        assert isinstance(issue.attachments, list)
        assert len(issue.attachments) == 1
        assert issue.attachments[0].filename == "test_attachment.txt"

        assert isinstance(issue.timetracking, JiraTimetracking)
        assert issue.timetracking.original_estimate == "1d"

        assert issue.project is not None
        assert issue.project.key == "PROJ"
        assert issue.project.name == "Test Project"
        assert issue.resolution is not None
        assert issue.resolution.name == "Fixed"
        assert issue.duedate == "2024-12-31"
        assert issue.resolutiondate == "2024-01-15T11:00:00.000+0000"
        assert issue.parent is not None
        assert issue.parent["key"] == "PROJ-122"
        assert issue.subtasks is not None
        assert len(issue.subtasks) == 1
        assert issue.subtasks[0]["key"] == "PROJ-124"
        assert issue.security is not None
        assert issue.security["name"] == "Internal"
        assert issue.worklog is not None
        assert issue.worklog["total"] == 0
        assert issue.worklog["maxResults"] == 20

        # Verify custom_fields structure after from_api_response
        assert "customfield_10001" in issue.custom_fields
        assert issue.custom_fields["customfield_10001"] == {
            "value": "Custom Text Field Value",
            "name": "My Custom Text Field",
        }
        assert "customfield_10002" in issue.custom_fields
        assert issue.custom_fields["customfield_10002"] == {
            "value": {"value": "Custom Select Value"},  # Original value is a dict
            "name": "My Custom Select",
        }

    def test_from_api_response_with_new_fields(self):
        """Test creating a JiraIssue focusing on parsing the new fields."""
        # Construct local mock data including the new fields
        local_issue_data = {
            "id": "9999",
            "key": "NEW-1",
            "fields": {
                "summary": "Issue testing new fields",
                "project": {
                    "id": "10001",
                    "key": "NEWPROJ",
                    "name": "New Project",
                    "avatarUrls": {"48x48": "url"},
                },
                "resolution": {"id": "10002", "name": "Fixed"},
                "duedate": "2025-01-31",
                "resolutiondate": "2024-08-01T12:00:00.000+0000",
                "parent": {
                    "id": "9998",
                    "key": "NEW-0",
                    "fields": {"summary": "Parent Task"},
                },
                "subtasks": [
                    {"id": "10000", "key": "NEW-2", "fields": {"summary": "Subtask 1"}},
                    {"id": "10001", "key": "NEW-3", "fields": {"summary": "Subtask 2"}},
                ],
                "security": {"id": "10003", "name": "Dev Only"},
                "worklog": {"total": 2, "maxResults": 20, "worklogs": []},
            },
        }
        issue = JiraIssue.from_api_response(local_issue_data)

        assert issue.id == "9999"
        assert issue.key == "NEW-1"
        assert issue.summary == "Issue testing new fields"

        # Assertions for new fields using LOCAL data
        assert isinstance(issue.project, JiraProject)
        assert issue.project.key == "NEWPROJ"
        assert issue.project.name == "New Project"
        assert isinstance(issue.resolution, JiraResolution)
        assert issue.resolution.name == "Fixed"
        assert issue.duedate == "2025-01-31"
        assert issue.resolutiondate == "2024-08-01T12:00:00.000+0000"
        assert isinstance(issue.parent, dict)
        assert issue.parent["key"] == "NEW-0"
        assert isinstance(issue.subtasks, list)
        assert len(issue.subtasks) == 2
        assert issue.subtasks[0]["key"] == "NEW-2"
        assert isinstance(issue.security, dict)
        assert issue.security["name"] == "Dev Only"
        assert isinstance(issue.worklog, dict)
        assert issue.worklog["total"] == 2

    def test_from_api_response_with_issuelinks(self, jira_issue_data):
        """Test creating a JiraIssue with issue links."""
        # Augment jira_issue_data with mock issuelinks
        mock_issuelinks_data = [
            {
                "id": "10000",
                "type": {
                    "id": "10000",
                    "name": "Blocks",
                    "inward": "is blocked by",
                    "outward": "blocks",
                },
                "outwardIssue": {
                    "id": "10001",
                    "key": "PROJ-789",
                    "self": "https://example.atlassian.net/rest/api/2/issue/10001",
                    "fields": {
                        "summary": "Blocked Issue",
                        "status": {"name": "Open"},
                        "priority": {"name": "High"},
                        "issuetype": {"name": "Task"},
                    },
                },
            },
            {
                "id": "10001",
                "type": {
                    "id": "10001",
                    "name": "Relates to",
                    "inward": "relates to",
                    "outward": "relates to",
                },
                "inwardIssue": {
                    "id": "10002",
                    "key": "PROJ-111",
                    "self": "https://example.atlassian.net/rest/api/2/issue/10002",
                    "fields": {
                        "summary": "Related Issue",
                        "status": {"name": "In Progress"},
                        "priority": {"name": "Medium"},
                        "issuetype": {"name": "Story"},
                    },
                },
            },
        ]
        jira_issue_data_with_links = jira_issue_data.copy()
        # Ensure fields dictionary exists
        if "fields" not in jira_issue_data_with_links:
            jira_issue_data_with_links["fields"] = {}
        jira_issue_data_with_links["fields"]["issuelinks"] = mock_issuelinks_data

        issue = JiraIssue.from_api_response(
            jira_issue_data_with_links, requested_fields="*all"
        )

        assert issue.issuelinks is not None
        assert len(issue.issuelinks) == 2
        assert isinstance(issue.issuelinks[0], JiraIssueLink)

        # Check first link (outward)
        assert issue.issuelinks[0].id == "10000"
        assert issue.issuelinks[0].type is not None
        assert issue.issuelinks[0].type.name == "Blocks"
        assert issue.issuelinks[0].outward_issue is not None
        assert issue.issuelinks[0].outward_issue.key == "PROJ-789"
        assert issue.issuelinks[0].outward_issue.fields is not None
        assert issue.issuelinks[0].outward_issue.fields.summary == "Blocked Issue"
        assert issue.issuelinks[0].inward_issue is None

        # Test simplified dict output
        simplified = issue.to_simplified_dict()
        assert "issuelinks" in simplified
        assert len(simplified["issuelinks"]) == 2
        assert simplified["issuelinks"][0]["type"]["name"] == "Blocks"
        assert simplified["issuelinks"][0]["outward_issue"]["key"] == "PROJ-789"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraIssue from empty data."""
        issue = JiraIssue.from_api_response({})
        assert issue.id == JIRA_DEFAULT_ID
        assert issue.key == "UNKNOWN-0"
        assert issue.summary == EMPTY_STRING
        assert issue.description is None
        assert issue.created == EMPTY_STRING
        assert issue.updated == EMPTY_STRING
        assert issue.status is None
        assert issue.issue_type is None
        assert issue.priority is None
        assert issue.assignee is None
        assert issue.reporter is None
        assert len(issue.labels) == 0
        assert len(issue.comments) == 0
        assert issue.project is None
        assert issue.resolution is None
        assert issue.duedate is None
        assert issue.resolutiondate is None
        assert issue.parent is None
        assert issue.subtasks == []
        assert issue.security is None
        assert issue.worklog is None

    def test_to_simplified_dict(self, jira_issue_data):
        """Test converting a JiraIssue to a simplified dictionary."""
        issue = JiraIssue.from_api_response(jira_issue_data)
        simplified = issue.to_simplified_dict()

        # Essential fields from original test
        assert isinstance(simplified, dict)
        assert "key" in simplified
        assert simplified["key"] == "PROJ-123"
        assert "summary" in simplified
        assert simplified["summary"] == "Test Issue Summary"

        assert "created" in simplified
        assert isinstance(simplified["created"], str)
        assert "updated" in simplified
        assert isinstance(simplified["updated"], str)

        if isinstance(simplified["status"], str):
            assert simplified["status"] == "In Progress"
        elif isinstance(simplified["status"], dict):
            assert simplified["status"]["name"] == "In Progress"

        if isinstance(simplified["issue_type"], str):
            assert simplified["issue_type"] == "Task"
        elif isinstance(simplified["issue_type"], dict):
            assert simplified["issue_type"]["name"] == "Task"

        if isinstance(simplified["priority"], str):
            assert simplified["priority"] == "Medium"
        elif isinstance(simplified["priority"], dict):
            assert simplified["priority"]["name"] == "Medium"

        assert "assignee" in simplified
        assert "reporter" in simplified

        # Test with "*all"
        issue_all = JiraIssue.from_api_response(
            jira_issue_data, requested_fields="*all"
        )
        simplified_all = issue_all.to_simplified_dict()

        # Check keys for all standard fields (new and old) are present
        all_standard_keys = {
            "id",
            "key",
            "summary",
            "description",
            "created",
            "updated",
            "status",
            "issue_type",
            "priority",
            "assignee",
            "reporter",
            "labels",
            "components",
            "timetracking",
            "comments",
            "attachments",
            "url",
            "epic_key",
            "epic_name",
            "fix_versions",
            "project",
            "resolution",
            "duedate",
            "resolutiondate",
            "parent",
            "subtasks",
            "security",
            "worklog",
            # Custom fields present in the mock data should be at the root level when requesting *all
            "customfield_10011",
            "customfield_10014",
            "customfield_10001",
            "customfield_10002",
            "customfield_10003",
        }
        assert all_standard_keys.issubset(simplified_all.keys())

        # Check values for new fields based on mock data
        assert simplified_all["project"]["key"] == "PROJ"
        assert simplified_all["resolution"]["name"] == "Fixed"
        assert simplified_all["duedate"] == "2024-12-31"
        assert simplified_all["resolutiondate"] == "2024-01-15T11:00:00.000+0000"
        assert simplified_all["parent"]["key"] == "PROJ-122"
        assert len(simplified_all["subtasks"]) == 1
        assert simplified_all["security"]["name"] == "Internal"
        assert isinstance(simplified_all["worklog"], dict)

        requested = [
            "key",
            "summary",
            "project",
            "resolution",
            "subtasks",
            "customfield_10011",
        ]
        issue_specific = JiraIssue.from_api_response(
            jira_issue_data, requested_fields=requested
        )
        simplified_specific = issue_specific.to_simplified_dict()

        # Check the requested keys are present
        assert set(simplified_specific.keys()) == {
            "id",
            "key",
            "summary",
            "project",
            "resolution",
            "subtasks",
            "customfield_10011",
        }

        # Check values based on mock data
        assert simplified_specific["project"]["key"] == "PROJ"
        assert simplified_specific["resolution"]["name"] == "Fixed"
        assert len(simplified_specific["subtasks"]) == 1
        # Check custom field output
        assert (
            simplified_specific["customfield_10011"]
            == {
                "value": "Epic Name Example",
                "name": "Epic Name",  # Comes from the "names" map in MOCK_JIRA_ISSUE_RESPONSE
            }
        )

    def test_find_custom_field_in_api_response(self):
        """Test the _find_custom_field_in_api_response method with different field patterns."""
        fields = {
            "customfield_10014": "EPIC-123",
            "customfield_10011": "Epic Name Test",
            "customfield_10000": "Another value",
            "schema": {
                "fields": {
                    "customfield_10014": {"name": "Epic Link", "type": "string"},
                    "customfield_10011": {"name": "Epic Name", "type": "string"},
                    "customfield_10000": {"name": "Custom Field", "type": "string"},
                }
            },
        }

        result = JiraIssue._find_custom_field_in_api_response(fields, ["Epic Link"])
        assert result == "EPIC-123"

        result = JiraIssue._find_custom_field_in_api_response(fields, ["Epic Name"])
        assert result == "Epic Name Test"

        result = JiraIssue._find_custom_field_in_api_response(fields, ["epic link"])
        assert result == "EPIC-123"

        result = JiraIssue._find_custom_field_in_api_response(
            fields, ["epic-link", "epiclink"]
        )
        assert result == "EPIC-123"

        result = JiraIssue._find_custom_field_in_api_response(
            fields, ["Non Existent Field"]
        )
        assert result is None

        result = JiraIssue._find_custom_field_in_api_response({}, ["Epic Link"])
        assert result is None

        result = JiraIssue._find_custom_field_in_api_response(None, ["Epic Link"])
        assert result is None

    def test_epic_field_extraction_different_field_ids(self):
        """Test finding epic fields with different customfield IDs."""
        test_data = {
            "id": "12345",
            "key": "PROJ-123",
            "fields": {
                "summary": "Test Issue",
                "customfield_20100": "EPIC-456",
                "customfield_20200": "My Epic Name",
                "schema": {
                    "fields": {
                        "customfield_20100": {"name": "Epic Link", "type": "string"},
                        "customfield_20200": {"name": "Epic Name", "type": "string"},
                    }
                },
            },
        }
        issue = JiraIssue.from_api_response(test_data)
        assert issue.epic_key == "EPIC-456"
        assert issue.epic_name == "My Epic Name"

    def test_epic_field_extraction_fallback(self):
        """Test using common field names without relying on metadata."""
        test_data = {
            "id": "12345",
            "key": "PROJ-123",
            "fields": {
                "summary": "Test Issue",
                "customfield_10014": "EPIC-456",
                "customfield_10011": "My Epic Name",
            },
        }

        original_method = JiraIssue._find_custom_field_in_api_response
        try:

            def mocked_find_field(fields, name_patterns):
                normalized_patterns = []
                for pattern in name_patterns:
                    norm_pattern = pattern.lower()
                    norm_pattern = re.sub(r"[_\-\s]", "", norm_pattern)
                    normalized_patterns.append(norm_pattern)

                if any("epiclink" in p for p in normalized_patterns):
                    return fields.get("customfield_10014")
                if any("epicname" in p for p in normalized_patterns):
                    return fields.get("customfield_10011")
                return None

            JiraIssue._find_custom_field_in_api_response = staticmethod(
                mocked_find_field
            )

            issue = JiraIssue.from_api_response(test_data)
            assert issue.epic_key == "EPIC-456"
            assert issue.epic_name == "My Epic Name"
        finally:
            JiraIssue._find_custom_field_in_api_response = staticmethod(original_method)

    def test_epic_field_extraction_advanced_patterns(self):
        """Test finding epic fields using various naming patterns."""
        test_data = {
            "id": "12345",
            "key": "PROJ-123",
            "fields": {
                "summary": "Test Issue",
                "customfield_12345": "EPIC-456",
                "customfield_67890": "Epic Name Value",
                "schema": {
                    "fields": {
                        "customfield_12345": {
                            "name": "Epic-Link Field",
                            "type": "string",
                        },
                        "customfield_67890": {"name": "EpicName", "type": "string"},
                    }
                },
            },
        }
        issue = JiraIssue.from_api_response(test_data)
        assert issue.epic_key == "EPIC-456"
        assert issue.epic_name == "Epic Name Value"

    def test_fields_with_names(self):
        """Test using the names to find fields."""

        fields = {
            "customfield_55555": "EPIC-789",
            "customfield_66666": "Special Epic Name",
            "names": {
                "customfield_55555": "Epic Link",
                "customfield_66666": "Epic Name",
            },
        }

        result = JiraIssue._find_custom_field_in_api_response(fields, ["Epic Link"])
        assert result == "EPIC-789"

        test_data = {"id": "12345", "key": "PROJ-123", "fields": fields}
        issue = JiraIssue.from_api_response(test_data)
        assert issue.epic_key == "EPIC-789"
        assert issue.epic_name == "Special Epic Name"

    def test_jira_issue_with_custom_fields(self, jira_issue_data):
        """Test JiraIssue handling of custom fields."""
        issue = JiraIssue.from_api_response(jira_issue_data)
        simplified = issue.to_simplified_dict()
        assert simplified["key"] == "PROJ-123"
        assert simplified["summary"] == "Test Issue Summary"
        # By default (no requested_fields or default set), custom fields are not included
        # unless they are part of DEFAULT_READ_JIRA_FIELDS (which they are not).
        # So, this assertion should be that they are NOT present.
        assert "customfield_10001" not in simplified
        assert "customfield_10002" not in simplified
        assert "customfield_10003" not in simplified

        issue = JiraIssue.from_api_response(
            jira_issue_data, requested_fields="summary,customfield_10001"
        )
        simplified = issue.to_simplified_dict()
        assert "key" in simplified
        assert "summary" in simplified
        assert "customfield_10001" in simplified
        assert simplified["customfield_10001"]["value"] == "Custom Text Field Value"
        assert simplified["customfield_10001"]["name"] == "My Custom Text Field"
        assert "customfield_10002" not in simplified

        issue = JiraIssue.from_api_response(
            jira_issue_data, requested_fields=["key", "customfield_10002"]
        )
        simplified = issue.to_simplified_dict()
        assert "key" in simplified
        assert "customfield_10002" in simplified
        assert "summary" not in simplified
        assert "customfield_10001" not in simplified
        assert simplified["customfield_10002"]["value"] == "Custom Select Value"
        assert simplified["customfield_10002"]["name"] == "My Custom Select"

        issue = JiraIssue.from_api_response(jira_issue_data, requested_fields="*all")
        simplified = issue.to_simplified_dict()
        assert "key" in simplified
        assert "summary" in simplified
        assert "customfield_10001" in simplified
        assert simplified["customfield_10001"]["value"] == "Custom Text Field Value"
        assert simplified["customfield_10001"]["name"] == "My Custom Text Field"
        assert "customfield_10002" in simplified
        assert simplified["customfield_10002"]["value"] == "Custom Select Value"
        assert simplified["customfield_10002"]["name"] == "My Custom Select"
        assert "customfield_10003" in simplified

        issue_specific = JiraIssue.from_api_response(
            jira_issue_data, requested_fields="key,customfield_10014"
        )
        simplified_specific = issue_specific.to_simplified_dict()
        assert "customfield_10014" in simplified_specific
        assert simplified_specific.get("customfield_10014") == {
            "value": "EPIC-KEY-1",
            "name": "Epic Link",
        }

    def test_jira_issue_with_default_fields(self, jira_issue_data):
        """Test that JiraIssue returns only essential fields by default."""
        issue = JiraIssue.from_api_response(jira_issue_data)
        simplified = issue.to_simplified_dict()
        # Check essential fields ARE present
        essential_keys = {
            "id",
            "key",
            "summary",
            "url",
            "description",
            "status",
            "issue_type",
            "priority",
            "project",
            "resolution",
            "duedate",
            "resolutiondate",
            "parent",
            "subtasks",
            "security",
            "worklog",
            "assignee",
            "reporter",
            "labels",
            "components",
            "fix_versions",
            "epic_key",
            "epic_name",
            "timetracking",
            "created",
            "updated",
            "comments",
            "attachments",
        }
        # We check if the key is present; value might be None if not in source data
        for key in essential_keys:
            assert key in simplified, (
                f"Essential key '{key}' missing from default simplified dict"
            )
        assert "customfield_10001" not in simplified
        assert "customfield_10002" not in simplified

        issue = JiraIssue.from_api_response(jira_issue_data, requested_fields="*all")
        simplified = issue.to_simplified_dict()
        assert "customfield_10001" in simplified
        assert "customfield_10002" in simplified

    def test_timetracking_field_processing(self, jira_issue_data):
        """Test that timetracking data is properly processed."""
        issue = JiraIssue.from_api_response(jira_issue_data)
        assert issue.timetracking is not None
        assert issue.timetracking.original_estimate == "1d"
        assert issue.timetracking.remaining_estimate == "4h"
        assert issue.timetracking.time_spent == "4h"
        assert issue.timetracking.original_estimate_seconds == 28800
        assert issue.timetracking.remaining_estimate_seconds == 14400
        assert issue.timetracking.time_spent_seconds == 14400

        issue.requested_fields = "*all"
        simplified = issue.to_simplified_dict()
        assert "timetracking" in simplified
        assert simplified["timetracking"]["original_estimate"] == "1d"

        issue.requested_fields = ["summary", "timetracking"]
        simplified = issue.to_simplified_dict()
        assert "timetracking" in simplified
        assert simplified["timetracking"]["original_estimate"] == "1d"


class TestJiraSearchResult:
    """Tests for the JiraSearchResult model."""

    def test_from_api_response_with_valid_data(self, jira_search_data):
        """Test creating a JiraSearchResult from valid API data."""
        search_result = JiraSearchResult.from_api_response(jira_search_data)
        assert search_result.total == 34
        assert search_result.start_at == 0
        assert search_result.max_results == 5
        assert len(search_result.issues) == 1

        issue = search_result.issues[0]
        assert isinstance(issue, JiraIssue)
        assert issue.key == "PROJ-123"
        assert issue.summary == "Test Issue Summary"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraSearchResult from empty data."""
        result = JiraSearchResult.from_api_response({})
        assert result.total == 0
        assert result.start_at == 0
        assert result.max_results == 0
        assert result.issues == []

    def test_from_api_response_missing_metadata(self, jira_search_data):
        """Test creating a JiraSearchResult when API is missing metadata."""
        # Remove total, startAt, maxResults from mock data
        api_data = dict(jira_search_data)
        api_data.pop("total", None)
        api_data.pop("startAt", None)
        api_data.pop("maxResults", None)

        search_result = JiraSearchResult.from_api_response(api_data)
        # Verify that -1 is used for missing metadata
        assert search_result.total == -1
        assert search_result.start_at == -1
        assert search_result.max_results == -1
        assert len(search_result.issues) == 1  # Assuming mock data has issues

    def test_to_simplified_dict(self, jira_search_data):
        """Test converting JiraSearchResult to a simplified dictionary."""
        search_result = JiraSearchResult.from_api_response(jira_search_data)
        simplified = search_result.to_simplified_dict()

        # Verify the structure and basic metadata
        assert isinstance(simplified, dict)
        assert "total" in simplified
        assert "start_at" in simplified
        assert "max_results" in simplified
        assert "issues" in simplified

        # Verify metadata values
        assert simplified["total"] == 34
        assert simplified["start_at"] == 0
        assert simplified["max_results"] == 5

        # Verify issues array
        assert isinstance(simplified["issues"], list)
        assert len(simplified["issues"]) == 1

        # Verify that each issue is a simplified dict (not a JiraIssue object)
        issue = simplified["issues"][0]
        assert isinstance(issue, dict)
        assert issue["key"] == "PROJ-123"
        assert issue["summary"] == "Test Issue Summary"

        # Verify that the issues are properly simplified (calling to_simplified_dict on each)
        # This ensures field filtering works properly
        assert "id" in issue  # ID is included in simplified version
        assert "expand" not in issue  # Should be filtered out in simplified version

        # Verify that issue contains expected fields
        assert "assignee" in issue
        assert "created" in issue
        assert "updated" in issue

    def test_to_simplified_dict_empty_result(self):
        """Test converting an empty JiraSearchResult to a simplified dictionary."""
        search_result = JiraSearchResult()
        simplified = search_result.to_simplified_dict()

        assert isinstance(simplified, dict)
        assert simplified["total"] == 0
        assert simplified["start_at"] == 0
        assert simplified["max_results"] == 0
        assert simplified["issues"] == []

    def test_to_simplified_dict_with_multiple_issues(self):
        """Test converting JiraSearchResult with multiple issues to a simplified dictionary."""
        # Create mock data with multiple issues
        mock_data = {
            "total": 2,
            "startAt": 0,
            "maxResults": 10,
            "issues": [
                {
                    "id": "12345",
                    "key": "PROJ-123",
                    "fields": {
                        "summary": "First Issue",
                        "status": {"name": "In Progress"},
                    },
                },
                {
                    "id": "12346",
                    "key": "PROJ-124",
                    "fields": {
                        "summary": "Second Issue",
                        "status": {"name": "Done"},
                    },
                },
            ],
        }

        search_result = JiraSearchResult.from_api_response(mock_data)
        simplified = search_result.to_simplified_dict()

        # Verify metadata
        assert simplified["total"] == 2
        assert simplified["start_at"] == 0
        assert simplified["max_results"] == 10

        # Verify issues
        assert len(simplified["issues"]) == 2
        assert simplified["issues"][0]["key"] == "PROJ-123"
        assert simplified["issues"][0]["summary"] == "First Issue"
        assert simplified["issues"][1]["key"] == "PROJ-124"
        assert simplified["issues"][1]["summary"] == "Second Issue"


class TestJiraProject:
    """Tests for the JiraProject model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraProject from valid API data."""
        project_data = {
            "id": "10000",
            "key": "TEST",
            "name": "Test Project",
            "description": "This is a test project",
            "lead": {
                "accountId": "5b10a2844c20165700ede21g",
                "displayName": "John Doe",
                "active": True,
            },
            "self": "https://example.atlassian.net/rest/api/3/project/10000",
            "projectCategory": {
                "id": "10100",
                "name": "Software Projects",
                "description": "Software development projects",
            },
            "avatarUrls": {
                "48x48": "https://example.atlassian.net/secure/projectavatar?pid=10000&avatarId=10011",
                "24x24": "https://example.atlassian.net/secure/projectavatar?pid=10000&size=small&avatarId=10011",
            },
        }
        project = JiraProject.from_api_response(project_data)
        assert project.id == "10000"
        assert project.key == "TEST"
        assert project.name == "Test Project"
        assert project.description == "This is a test project"
        assert project.lead is not None
        assert project.lead.display_name == "John Doe"
        assert project.url == "https://example.atlassian.net/rest/api/3/project/10000"
        assert project.category_name == "Software Projects"
        assert (
            project.avatar_url
            == "https://example.atlassian.net/secure/projectavatar?pid=10000&avatarId=10011"
        )

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraProject from empty data."""
        project = JiraProject.from_api_response({})
        assert project.id == JIRA_DEFAULT_PROJECT
        assert project.key == EMPTY_STRING
        assert project.name == UNKNOWN
        assert project.description is None
        assert project.lead is None
        assert project.url is None
        assert project.category_name is None
        assert project.avatar_url is None

    def test_to_simplified_dict(self):
        """Test converting a JiraProject to a simplified dictionary."""
        project_data = {
            "id": "10000",
            "key": "TEST",
            "name": "Test Project",
            "description": "This is a test project",
            "lead": {
                "accountId": "5b10a2844c20165700ede21g",
                "displayName": "John Doe",
                "active": True,
            },
            "self": "https://example.atlassian.net/rest/api/3/project/10000",
            "projectCategory": {
                "name": "Software Projects",
            },
        }
        project = JiraProject.from_api_response(project_data)
        simplified = project.to_simplified_dict()
        assert simplified["key"] == "TEST"
        assert simplified["name"] == "Test Project"
        assert simplified["description"] == "This is a test project"
        assert simplified["lead"] is not None
        assert simplified["lead"]["display_name"] == "John Doe"
        assert simplified["category"] == "Software Projects"
        assert "id" not in simplified
        assert "url" not in simplified
        assert "avatar_url" not in simplified


class TestJiraTransition:
    """Tests for the JiraTransition model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraTransition from valid API data."""
        transition_data = {
            "id": "10",
            "name": "Start Progress",
            "to": {
                "id": "3",
                "name": "In Progress",
                "statusCategory": {
                    "id": 4,
                    "key": "indeterminate",
                    "name": "In Progress",
                    "colorName": "yellow",
                },
            },
            "hasScreen": True,
            "isGlobal": False,
            "isInitial": False,
            "isConditional": True,
        }
        transition = JiraTransition.from_api_response(transition_data)
        assert transition.id == "10"
        assert transition.name == "Start Progress"
        assert transition.to_status is not None
        assert transition.to_status.id == "3"
        assert transition.to_status.name == "In Progress"
        assert transition.to_status.category is not None
        assert transition.to_status.category.name == "In Progress"
        assert transition.has_screen is True
        assert transition.is_global is False
        assert transition.is_initial is False
        assert transition.is_conditional is True

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraTransition from empty data."""
        transition = JiraTransition.from_api_response({})
        assert transition.id == JIRA_DEFAULT_ID
        assert transition.name == EMPTY_STRING
        assert transition.to_status is None
        assert transition.has_screen is False
        assert transition.is_global is False
        assert transition.is_initial is False
        assert transition.is_conditional is False

    def test_to_simplified_dict(self):
        """Test converting a JiraTransition to a simplified dictionary."""
        transition_data = {
            "id": "10",
            "name": "Start Progress",
            "to": {
                "id": "3",
                "name": "In Progress",
                "statusCategory": {
                    "id": 4,
                    "key": "indeterminate",
                    "name": "In Progress",
                    "colorName": "yellow",
                },
            },
            "hasScreen": True,
        }
        transition = JiraTransition.from_api_response(transition_data)
        simplified = transition.to_simplified_dict()
        assert simplified["id"] == "10"
        assert simplified["name"] == "Start Progress"
        assert simplified["to_status"] is not None
        assert simplified["to_status"]["name"] == "In Progress"
        assert "has_screen" not in simplified
        assert "is_global" not in simplified


class TestJiraIssueLinkType:
    """Tests for the JiraIssueLinkType model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraIssueLinkType from valid API data."""
        data = {
            "id": "10001",
            "name": "Blocks",
            "inward": "is blocked by",
            "outward": "blocks",
            "self": "https://example.atlassian.net/rest/api/3/issueLinkType/10001",
        }
        link_type = JiraIssueLinkType.from_api_response(data)
        assert link_type.id == "10001"
        assert link_type.name == "Blocks"
        assert link_type.inward == "is blocked by"
        assert link_type.outward == "blocks"
        assert (
            link_type.self_url
            == "https://example.atlassian.net/rest/api/3/issueLinkType/10001"
        )

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraIssueLinkType from empty data."""
        link_type = JiraIssueLinkType.from_api_response({})
        assert link_type.id == JIRA_DEFAULT_ID
        assert link_type.name == UNKNOWN
        assert link_type.inward == EMPTY_STRING
        assert link_type.outward == EMPTY_STRING
        assert link_type.self_url is None

    def test_from_api_response_with_none_data(self):
        """Test creating a JiraIssueLinkType from None data."""
        link_type = JiraIssueLinkType.from_api_response(None)
        assert link_type.id == JIRA_DEFAULT_ID
        assert link_type.name == UNKNOWN
        assert link_type.inward == EMPTY_STRING
        assert link_type.outward == EMPTY_STRING
        assert link_type.self_url is None

    def test_to_simplified_dict(self):
        """Test converting JiraIssueLinkType to a simplified dictionary."""
        link_type = JiraIssueLinkType(
            id="10001",
            name="Blocks",
            inward="is blocked by",
            outward="blocks",
            self_url="https://example.atlassian.net/rest/api/3/issueLinkType/10001",
        )
        simplified = link_type.to_simplified_dict()
        assert isinstance(simplified, dict)
        assert simplified["id"] == "10001"
        assert simplified["name"] == "Blocks"
        assert simplified["inward"] == "is blocked by"
        assert simplified["outward"] == "blocks"
        assert "self" in simplified
        assert (
            simplified["self"]
            == "https://example.atlassian.net/rest/api/3/issueLinkType/10001"
        )


class TestJiraLinkedIssueFields:
    """Tests for the JiraLinkedIssueFields model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraLinkedIssueFields from valid API data."""
        data = {
            "summary": "Linked Issue Summary",
            "status": {
                "id": "10000",
                "name": "In Progress",
                "statusCategory": {
                    "id": 4,
                    "key": "indeterminate",
                    "name": "In Progress",
                    "colorName": "yellow",
                },
            },
            "priority": {
                "id": "3",
                "name": "Medium",
                "description": "Medium priority",
                "iconUrl": "https://example.com/medium-priority.png",
            },
            "issuetype": {
                "id": "10000",
                "name": "Task",
                "description": "A task that needs to be done.",
                "iconUrl": "https://example.com/task-icon.png",
            },
        }
        fields = JiraLinkedIssueFields.from_api_response(data)
        assert fields.summary == "Linked Issue Summary"
        assert fields.status is not None
        assert fields.status.name == "In Progress"
        assert fields.priority is not None
        assert fields.priority.name == "Medium"
        assert fields.issuetype is not None
        assert fields.issuetype.name == "Task"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraLinkedIssueFields from empty data."""
        fields = JiraLinkedIssueFields.from_api_response({})
        assert fields.summary == EMPTY_STRING
        assert fields.status is None
        assert fields.priority is None
        assert fields.issuetype is None

    def test_to_simplified_dict(self):
        """Test converting JiraLinkedIssueFields to a simplified dictionary."""
        fields = JiraLinkedIssueFields(
            summary="Linked Issue Summary",
            status=JiraStatus(name="In Progress"),
            priority=JiraPriority(name="Medium"),
            issuetype=JiraIssueType(name="Task"),
        )
        simplified = fields.to_simplified_dict()
        assert simplified["summary"] == "Linked Issue Summary"
        assert simplified["status"]["name"] == "In Progress"
        assert simplified["priority"]["name"] == "Medium"
        assert simplified["issuetype"]["name"] == "Task"


class TestJiraLinkedIssue:
    """Tests for the JiraLinkedIssue model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraLinkedIssue from valid API data."""
        data = {
            "id": "10001",
            "key": "PROJ-456",
            "self": "https://example.atlassian.net/rest/api/2/issue/10001",
            "fields": {
                "summary": "Linked Issue Summary",
                "status": {
                    "id": "10000",
                    "name": "In Progress",
                },
                "priority": {
                    "id": "3",
                    "name": "Medium",
                },
                "issuetype": {
                    "id": "10000",
                    "name": "Task",
                },
            },
        }
        linked_issue = JiraLinkedIssue.from_api_response(data)
        assert linked_issue.id == "10001"
        assert linked_issue.key == "PROJ-456"
        assert (
            linked_issue.self_url
            == "https://example.atlassian.net/rest/api/2/issue/10001"
        )
        assert linked_issue.fields is not None
        assert linked_issue.fields.summary == "Linked Issue Summary"
        assert linked_issue.fields.status is not None
        assert linked_issue.fields.status.name == "In Progress"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraLinkedIssue from empty data."""
        linked_issue = JiraLinkedIssue.from_api_response({})
        assert linked_issue.id == JIRA_DEFAULT_ID
        assert linked_issue.key == EMPTY_STRING
        assert linked_issue.self_url is None
        assert linked_issue.fields is None

    def test_to_simplified_dict(self):
        """Test converting JiraLinkedIssue to a simplified dictionary."""
        linked_issue = JiraLinkedIssue(
            id="10001",
            key="PROJ-456",
            self_url="https://example.atlassian.net/rest/api/2/issue/10001",
            fields=JiraLinkedIssueFields(
                summary="Linked Issue Summary",
                status=JiraStatus(name="In Progress"),
                priority=JiraPriority(name="Medium"),
                issuetype=JiraIssueType(name="Task"),
            ),
        )
        simplified = linked_issue.to_simplified_dict()
        assert simplified["id"] == "10001"
        assert simplified["key"] == "PROJ-456"
        assert (
            simplified["self"] == "https://example.atlassian.net/rest/api/2/issue/10001"
        )
        assert simplified["fields"]["summary"] == "Linked Issue Summary"
        assert simplified["fields"]["status"]["name"] == "In Progress"


class TestJiraIssueLink:
    """Tests for the JiraIssueLink model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraIssueLink from valid API data."""
        data = {
            "id": "10001",
            "type": {
                "id": "10000",
                "name": "Blocks",
                "inward": "is blocked by",
                "outward": "blocks",
                "self": "https://example.atlassian.net/rest/api/2/issueLinkType/10000",
            },
            "inwardIssue": {
                "id": "10002",
                "key": "PROJ-789",
                "self": "https://example.atlassian.net/rest/api/2/issue/10002",
                "fields": {
                    "summary": "Inward Issue Summary",
                    "status": {
                        "id": "10000",
                        "name": "In Progress",
                    },
                },
            },
        }
        issue_link = JiraIssueLink.from_api_response(data)
        assert issue_link.id == "10001"
        assert issue_link.type is not None
        assert issue_link.type.name == "Blocks"
        assert issue_link.inward_issue is not None
        assert issue_link.inward_issue.key == "PROJ-789"
        assert issue_link.outward_issue is None

    def test_from_api_response_with_outward_issue(self):
        """Test creating a JiraIssueLink with an outward issue."""
        data = {
            "id": "10001",
            "type": {
                "id": "10000",
                "name": "Blocks",
                "inward": "is blocked by",
                "outward": "blocks",
            },
            "outwardIssue": {
                "id": "10003",
                "key": "PROJ-101",
                "fields": {
                    "summary": "Outward Issue Summary",
                    "status": {
                        "id": "10000",
                        "name": "In Progress",
                    },
                },
            },
        }
        issue_link = JiraIssueLink.from_api_response(data)
        assert issue_link.id == "10001"
        assert issue_link.type is not None
        assert issue_link.type.name == "Blocks"
        assert issue_link.inward_issue is None
        assert issue_link.outward_issue is not None
        assert issue_link.outward_issue.key == "PROJ-101"

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraIssueLink from empty data."""
        issue_link = JiraIssueLink.from_api_response({})
        assert issue_link.id == JIRA_DEFAULT_ID
        assert issue_link.type is None
        assert issue_link.inward_issue is None
        assert issue_link.outward_issue is None

    def test_to_simplified_dict(self):
        """Test converting JiraIssueLink to a simplified dictionary."""
        issue_link = JiraIssueLink(
            id="10001",
            type=JiraIssueLinkType(
                id="10000",
                name="Blocks",
                inward="is blocked by",
                outward="blocks",
            ),
            inward_issue=JiraLinkedIssue(
                id="10002",
                key="PROJ-789",
                fields=JiraLinkedIssueFields(
                    summary="Inward Issue Summary",
                    status=JiraStatus(name="In Progress"),
                ),
            ),
        )
        simplified = issue_link.to_simplified_dict()
        assert simplified["id"] == "10001"
        assert simplified["type"]["name"] == "Blocks"
        assert simplified["inward_issue"]["key"] == "PROJ-789"
        assert "outward_issue" not in simplified


class TestJiraWorklog:
    """Tests for the JiraWorklog model."""

    def test_from_api_response_with_valid_data(self):
        """Test creating a JiraWorklog from valid API data."""
        worklog_data = {
            "id": "100023",
            "author": {
                "accountId": "5b10a2844c20165700ede21g",
                "displayName": "John Doe",
                "active": True,
            },
            "comment": "Worked on the issue today",
            "created": "2023-05-01T10:00:00.000+0000",
            "updated": "2023-05-01T10:30:00.000+0000",
            "started": "2023-05-01T09:00:00.000+0000",
            "timeSpent": "2h 30m",
            "timeSpentSeconds": 9000,
        }
        worklog = JiraWorklog.from_api_response(worklog_data)
        assert worklog.id == "100023"
        assert worklog.author is not None
        assert worklog.author.display_name == "John Doe"
        assert worklog.comment == "Worked on the issue today"
        assert worklog.created == "2023-05-01T10:00:00.000+0000"
        assert worklog.updated == "2023-05-01T10:30:00.000+0000"
        assert worklog.started == "2023-05-01T09:00:00.000+0000"
        assert worklog.time_spent == "2h 30m"
        assert worklog.time_spent_seconds == 9000

    def test_from_api_response_with_empty_data(self):
        """Test creating a JiraWorklog from empty data."""
        worklog = JiraWorklog.from_api_response({})
        assert worklog.id == JIRA_DEFAULT_ID
        assert worklog.author is None
        assert worklog.comment is None
        assert worklog.created == EMPTY_STRING
        assert worklog.updated == EMPTY_STRING
        assert worklog.started == EMPTY_STRING
        assert worklog.time_spent == EMPTY_STRING
        assert worklog.time_spent_seconds == 0

    def test_to_simplified_dict(self):
        """Test converting a JiraWorklog to a simplified dictionary."""
        worklog_data = {
            "id": "100023",
            "author": {
                "accountId": "5b10a2844c20165700ede21g",
                "displayName": "John Doe",
                "active": True,
            },
            "comment": "Worked on the issue today",
            "created": "2023-05-01T10:00:00.000+0000",
            "updated": "2023-05-01T10:30:00.000+0000",
            "started": "2023-05-01T09:00:00.000+0000",
            "timeSpent": "2h 30m",
            "timeSpentSeconds": 9000,
        }
        worklog = JiraWorklog.from_api_response(worklog_data)
        simplified = worklog.to_simplified_dict()
        assert simplified["time_spent"] == "2h 30m"
        assert simplified["time_spent_seconds"] == 9000
        assert simplified["author"] is not None
        assert simplified["author"]["display_name"] == "John Doe"
        assert simplified["comment"] == "Worked on the issue today"
        assert "created" in simplified
        assert "updated" in simplified
        assert "started" in simplified


class TestRealJiraData:
    """Tests using real Jira data (optional)."""

    # Helper to get client/config
    def _get_client(self) -> IssuesMixin | None:
        if not real_api_available:
            return None
        try:
            config = JiraConfig.from_env()
            return JiraFetcher(config=config)
        except ValueError:
            pytest.skip("Real Jira environment not configured")
            return None

    def _get_project_client(self) -> ProjectsMixin | None:
        if not real_api_available:
            return None
        try:
            config = JiraConfig.from_env()

            return JiraFetcher(config=config)
        except ValueError:
            pytest.skip("Real Jira environment not configured")
            return None

    def _get_transition_client(self) -> TransitionsMixin | None:
        if not real_api_available:
            return None
        try:
            config = JiraConfig.from_env()
            return JiraFetcher(config=config)
        except ValueError:
            pytest.skip("Real Jira environment not configured")
            return None

    def _get_worklog_client(self) -> WorklogMixin | None:
        if not real_api_available:
            return None
        try:
            config = JiraConfig.from_env()
            return JiraFetcher(config=config)
        except ValueError:
            pytest.skip("Real Jira environment not configured")
            return None

    def _get_base_jira_client(self) -> Jira | None:
        if not real_api_available:
            return None
        try:
            config = JiraConfig.from_env()
            if config.auth_type == "basic":
                return Jira(
                    url=config.url,
                    username=config.username,
                    password=config.api_token,
                    cloud=config.is_cloud,
                )
            else:  # token
                return Jira(
                    url=config.url, token=config.personal_token, cloud=config.is_cloud
                )
        except ValueError:
            pytest.skip("Real Jira environment not configured")
            return None

    def test_real_jira_issue(self, use_real_jira_data, default_jira_issue_key):
        """Test that the JiraIssue model works with real Jira API data."""
        if not use_real_jira_data:
            pytest.skip("Skipping real Jira data test")
        issues_client = self._get_client()
        if not issues_client or not default_jira_issue_key:
            pytest.skip("Real Jira client/issue key not available")

        try:
            issue = issues_client.get_issue(default_jira_issue_key)
            assert isinstance(issue, JiraIssue)
            assert issue.key == default_jira_issue_key
            assert issue.id is not None
            assert issue.summary is not None

            assert hasattr(issue, "project")
            assert issue.project is None or isinstance(issue.project, JiraProject)
            assert hasattr(issue, "resolution")
            assert issue.resolution is None or isinstance(
                issue.resolution, JiraResolution
            )
            assert hasattr(issue, "duedate")
            assert issue.duedate is None or isinstance(issue.duedate, str)
            assert hasattr(issue, "resolutiondate")
            assert issue.resolutiondate is None or isinstance(issue.resolutiondate, str)
            assert hasattr(issue, "parent")
            assert issue.parent is None or isinstance(issue.parent, dict)
            assert hasattr(issue, "subtasks")
            assert isinstance(issue.subtasks, list)
            if issue.subtasks:
                assert isinstance(issue.subtasks[0], dict)
            assert hasattr(issue, "security")
            assert issue.security is None or isinstance(issue.security, dict)
            assert hasattr(issue, "worklog")
            assert issue.worklog is None or isinstance(issue.worklog, dict)

            simplified = issue.to_simplified_dict()
            assert simplified["key"] == default_jira_issue_key
        except Exception as e:
            pytest.fail(f"Error testing real Jira issue: {e}")

    def test_real_jira_project(self, use_real_jira_data):
        """Test that the JiraProject model works with real Jira API data."""
        if not use_real_jira_data:
            pytest.skip("Skipping real Jira data test")
        projects_client = self._get_project_client()
        if not projects_client:
            pytest.skip("Real Jira client not available")

        # Check for JIRA_TEST_ISSUE_KEY explicitly
        if not os.environ.get("JIRA_TEST_ISSUE_KEY"):
            pytest.skip("JIRA_TEST_ISSUE_KEY environment variable not set")

        default_issue_key = os.environ.get("JIRA_TEST_ISSUE_KEY")
        project_key = default_issue_key.split("-")[0]

        if not project_key:
            pytest.skip("Could not extract project key from JIRA_TEST_ISSUE_KEY")

        try:
            project = projects_client.get_project_model(project_key)

            if project is None:
                pytest.skip(f"Could not get project model for {project_key}")

            assert isinstance(project, JiraProject)
            assert project.key == project_key
            assert project.id is not None
            assert project.name is not None

            simplified = project.to_simplified_dict()
            assert simplified["key"] == project_key
        except (AttributeError, TypeError, ValueError) as e:
            pytest.skip(f"Error parsing project data: {e}")
        except Exception as e:
            pytest.fail(f"Error testing real Jira project: {e}")

    def test_real_jira_transitions(self, use_real_jira_data, default_jira_issue_key):
        """Test that the JiraTransition model works with real Jira API data."""
        if not use_real_jira_data:
            pytest.skip("Skipping real Jira data test")
        transitions_client = self._get_transition_client()
        if not transitions_client or not default_jira_issue_key:
            pytest.skip("Real Jira client/issue key not available")

        # Use the underlying Atlassian API client directly for raw data
        jira = self._get_base_jira_client()
        if not jira:
            pytest.skip("Base Jira client failed")

        transitions_data = None  # Initialize
        try:
            transitions_data = jira.get_issue_transitions(default_jira_issue_key)

            actual_transitions_list = []
            if isinstance(transitions_data, list):
                actual_transitions_list = transitions_data
            else:
                # Handle unexpected format with test failure
                pytest.fail(
                    f"Unexpected transitions data format received from API: "
                    f"{type(transitions_data)}. Data: {transitions_data}"
                )

            # Verify transitions list is actually a list
            assert isinstance(actual_transitions_list, list)

            if not actual_transitions_list:
                pytest.skip(f"No transitions found for issue {default_jira_issue_key}")

            transition_item = actual_transitions_list[0]
            assert isinstance(transition_item, dict)

            # Check for essential keys in the raw data
            assert "id" in transition_item
            assert "name" in transition_item
            assert "to" in transition_item

            # Only check 'to' field name if it's a dictionary
            if isinstance(transition_item["to"], dict):
                assert "name" in transition_item["to"]

            # Convert to model
            transition = JiraTransition.from_api_response(transition_item)
            assert isinstance(transition, JiraTransition)
            assert transition.id == str(transition_item["id"])  # Ensure ID is string
            assert transition.name == transition_item["name"]

            simplified = transition.to_simplified_dict()
            assert simplified["id"] == str(transition_item["id"])
            assert simplified["name"] == transition_item["name"]

        except Exception as e:
            # Include data type details in error message
            error_details = f"Received data type: {type(transitions_data)}"
            if transitions_data is not None:
                error_details += (
                    f", Data: {str(transitions_data)[:200]}..."  # Show partial data
                )

            pytest.fail(
                f"Error testing real Jira transitions for issue {default_jira_issue_key}: {e}. {error_details}"
            )

    def test_real_jira_worklog(self, use_real_jira_data, default_jira_issue_key):
        """Test that the JiraWorklog model works with real Jira API data."""
        if not use_real_jira_data:
            pytest.skip("Skipping real Jira data test")
        worklog_client = self._get_worklog_client()
        if not worklog_client or not default_jira_issue_key:
            pytest.skip("Real Jira client/issue key not available")

        try:
            # Get worklogs using the model method
            worklogs = worklog_client.get_worklog_models(default_jira_issue_key)
            assert isinstance(worklogs, list)

            if not worklogs:
                pytest.skip(f"Issue {default_jira_issue_key} has no worklogs to test.")

            # Test the first worklog
            worklog = worklogs[0]
            assert isinstance(worklog, JiraWorklog)
            assert worklog.id is not None
            assert worklog.time_spent_seconds >= 0
            if worklog.author:
                assert isinstance(worklog.author, JiraUser)

            simplified = worklog.to_simplified_dict()
            assert "id" in simplified
            assert "time_spent" in simplified

        except Exception as e:
            pytest.fail(f"Error testing real Jira worklog: {e}")

```
Page 10/10FirstPrevNextLast