test_issues.py•64.3 kB
"""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"