test_attachments.py•29.4 kB
"""Tests for the Jira attachments module."""
from unittest.mock import MagicMock, mock_open, patch
import pytest
from mcp_atlassian.jira import JiraFetcher
from mcp_atlassian.jira.attachments import AttachmentsMixin
# Test scenarios for AttachmentsMixin
#
# 1. Single Attachment Download (download_attachment method):
# - Success case: Downloads attachment correctly with proper HTTP response
# - Path handling: Converts relative path to absolute path
# - Error cases:
# - No URL provided
# - HTTP error during download
# - File write error
# - File not created after write operation
#
# 2. Issue Attachments Download (download_issue_attachments method):
# - Success case: Downloads all attachments for an issue
# - Path handling: Converts relative target directory to absolute path
# - Edge cases:
# - Issue has no attachments
# - Issue not found
# - Issue has no fields
# - Some attachments fail to download
# - Attachment has missing URL
#
# 3. Single Attachment Upload (upload_attachment method):
# - Success case: Uploads file correctly
# - Path handling: Converts relative file path to absolute path
# - Error cases:
# - No issue key provided
# - No file path provided
# - File not found
# - API error during upload
# - No response from API
#
# 4. Multiple Attachments Upload (upload_attachments method):
# - Success case: Uploads multiple files correctly
# - Partial success: Some files upload successfully, others fail
# - Error cases:
# - Empty list of file paths
# - No issue key provided
class TestAttachmentsMixin:
"""Tests for the AttachmentsMixin class."""
@pytest.fixture
def attachments_mixin(self, jira_fetcher: JiraFetcher) -> AttachmentsMixin:
"""Set up test fixtures before each test method."""
# Create a mock Jira client
attachments_mixin = jira_fetcher
attachments_mixin.jira = MagicMock()
attachments_mixin.jira._session = MagicMock()
return attachments_mixin
def test_download_attachment_success(self, attachments_mixin: AttachmentsMixin):
"""Test successful attachment download."""
# Mock the response
mock_response = MagicMock()
mock_response.iter_content.return_value = [b"test content"]
mock_response.raise_for_status = MagicMock()
attachments_mixin.jira._session.get.return_value = mock_response
# Mock file operations
with (
patch("builtins.open", mock_open()) as mock_file,
patch("os.path.exists") as mock_exists,
patch("os.path.getsize") as mock_getsize,
patch("os.makedirs") as mock_makedirs,
):
mock_exists.return_value = True
mock_getsize.return_value = 12 # Length of "test content"
# Call the method
result = attachments_mixin.download_attachment(
"https://test.url/attachment", "/tmp/test_file.txt"
)
# Assertions
assert result is True
attachments_mixin.jira._session.get.assert_called_once_with(
"https://test.url/attachment", stream=True
)
mock_file.assert_called_once_with("/tmp/test_file.txt", "wb")
mock_file().write.assert_called_once_with(b"test content")
mock_makedirs.assert_called_once()
def test_download_attachment_relative_path(
self, attachments_mixin: AttachmentsMixin
):
"""Test attachment download with a relative path."""
# Mock the response
mock_response = MagicMock()
mock_response.iter_content.return_value = [b"test content"]
mock_response.raise_for_status = MagicMock()
attachments_mixin.jira._session.get.return_value = mock_response
# Mock file operations and os.path.abspath
with (
patch("builtins.open", mock_open()) as mock_file,
patch("os.path.exists") as mock_exists,
patch("os.path.getsize") as mock_getsize,
patch("os.makedirs") as mock_makedirs,
patch("os.path.abspath") as mock_abspath,
patch("os.path.isabs") as mock_isabs,
):
mock_exists.return_value = True
mock_getsize.return_value = 12
mock_isabs.return_value = False
mock_abspath.return_value = "/absolute/path/test_file.txt"
# Call the method with a relative path
result = attachments_mixin.download_attachment(
"https://test.url/attachment", "test_file.txt"
)
# Assertions
assert result is True
mock_isabs.assert_called_once_with("test_file.txt")
mock_abspath.assert_called_once_with("test_file.txt")
mock_file.assert_called_once_with("/absolute/path/test_file.txt", "wb")
def test_download_attachment_no_url(self, attachments_mixin: AttachmentsMixin):
"""Test attachment download with no URL."""
result = attachments_mixin.download_attachment("", "/tmp/test_file.txt")
assert result is False
def test_download_attachment_http_error(self, attachments_mixin: AttachmentsMixin):
"""Test attachment download with an HTTP error."""
# Mock the response to raise an HTTP error
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP Error")
attachments_mixin.jira._session.get.return_value = mock_response
result = attachments_mixin.download_attachment(
"https://test.url/attachment", "/tmp/test_file.txt"
)
assert result is False
def test_download_attachment_file_write_error(
self, attachments_mixin: AttachmentsMixin
):
"""Test attachment download with a file write error."""
# Mock the response
mock_response = MagicMock()
mock_response.iter_content.return_value = [b"test content"]
mock_response.raise_for_status = MagicMock()
attachments_mixin.jira._session.get.return_value = mock_response
# Mock file operations to raise an exception during write
with (
patch("builtins.open", mock_open()) as mock_file,
patch("os.makedirs") as mock_makedirs,
):
mock_file().write.side_effect = OSError("Write error")
result = attachments_mixin.download_attachment(
"https://test.url/attachment", "/tmp/test_file.txt"
)
assert result is False
def test_download_attachment_file_not_created(
self, attachments_mixin: AttachmentsMixin
):
"""Test attachment download when file is not created."""
# Mock the response
mock_response = MagicMock()
mock_response.iter_content.return_value = [b"test content"]
mock_response.raise_for_status = MagicMock()
attachments_mixin.jira._session.get.return_value = mock_response
# Mock file operations
with (
patch("builtins.open", mock_open()) as mock_file,
patch("os.path.exists") as mock_exists,
patch("os.makedirs") as mock_makedirs,
):
mock_exists.return_value = False # File doesn't exist after write
result = attachments_mixin.download_attachment(
"https://test.url/attachment", "/tmp/test_file.txt"
)
assert result is False
def test_download_issue_attachments_success(
self, attachments_mixin: AttachmentsMixin
):
"""Test successful download of all issue attachments."""
# Mock the issue data
mock_issue = {
"fields": {
"attachment": [
{
"filename": "test1.txt",
"content": "https://test.url/attachment1",
"size": 100,
},
{
"filename": "test2.txt",
"content": "https://test.url/attachment2",
"size": 200,
},
]
}
}
attachments_mixin.jira.issue.return_value = mock_issue
# Mock JiraAttachment.from_api_response
mock_attachment1 = MagicMock()
mock_attachment1.filename = "test1.txt"
mock_attachment1.url = "https://test.url/attachment1"
mock_attachment1.size = 100
mock_attachment2 = MagicMock()
mock_attachment2.filename = "test2.txt"
mock_attachment2.url = "https://test.url/attachment2"
mock_attachment2.size = 200
# Mock the download_attachment method
with (
patch.object(
attachments_mixin, "download_attachment", return_value=True
) as mock_download,
patch("pathlib.Path.mkdir") as mock_mkdir,
patch(
"mcp_atlassian.models.jira.JiraAttachment.from_api_response",
side_effect=[mock_attachment1, mock_attachment2],
),
):
result = attachments_mixin.download_issue_attachments(
"TEST-123", "/tmp/attachments"
)
# Assertions
assert result["success"] is True
assert len(result["downloaded"]) == 2
assert len(result["failed"]) == 0
assert result["total"] == 2
assert result["issue_key"] == "TEST-123"
assert mock_download.call_count == 2
mock_mkdir.assert_called_once()
def test_download_issue_attachments_relative_path(
self, attachments_mixin: AttachmentsMixin
):
"""Test download issue attachments with a relative path."""
# Mock the issue data
mock_issue = {
"fields": {
"attachment": [
{
"filename": "test1.txt",
"content": "https://test.url/attachment1",
"size": 100,
}
]
}
}
attachments_mixin.jira.issue.return_value = mock_issue
# Mock attachment
mock_attachment = MagicMock()
mock_attachment.filename = "test1.txt"
mock_attachment.url = "https://test.url/attachment1"
mock_attachment.size = 100
# Mock path operations
with (
patch.object(
attachments_mixin, "download_attachment", return_value=True
) as mock_download,
patch("pathlib.Path.mkdir") as mock_mkdir,
patch(
"mcp_atlassian.models.jira.JiraAttachment.from_api_response",
return_value=mock_attachment,
),
patch("os.path.isabs") as mock_isabs,
patch("os.path.abspath") as mock_abspath,
):
mock_isabs.return_value = False
mock_abspath.return_value = "/absolute/path/attachments"
result = attachments_mixin.download_issue_attachments(
"TEST-123", "attachments"
)
# Assertions
assert result["success"] is True
mock_isabs.assert_called_once_with("attachments")
mock_abspath.assert_called_once_with("attachments")
def test_download_issue_attachments_no_attachments(
self, attachments_mixin: AttachmentsMixin
):
"""Test download when issue has no attachments."""
# Mock the issue data with no attachments
mock_issue = {"fields": {"attachment": []}}
attachments_mixin.jira.issue.return_value = mock_issue
with patch("pathlib.Path.mkdir") as mock_mkdir:
result = attachments_mixin.download_issue_attachments(
"TEST-123", "/tmp/attachments"
)
# Assertions
assert result["success"] is True
assert "No attachments found" in result["message"]
assert len(result["downloaded"]) == 0
assert len(result["failed"]) == 0
mock_mkdir.assert_called_once()
def test_download_issue_attachments_issue_not_found(
self, attachments_mixin: AttachmentsMixin
):
"""Test download when issue cannot be retrieved."""
attachments_mixin.jira.issue.return_value = None
with pytest.raises(
TypeError,
match="Unexpected return value type from `jira.issue`: <class 'NoneType'>",
):
attachments_mixin.download_issue_attachments("TEST-123", "/tmp/attachments")
def test_download_issue_attachments_no_fields(
self, attachments_mixin: AttachmentsMixin
):
"""Test download when issue has no fields."""
# Mock the issue data with no fields
mock_issue = {} # Missing 'fields' key
attachments_mixin.jira.issue.return_value = mock_issue
result = attachments_mixin.download_issue_attachments(
"TEST-123", "/tmp/attachments"
)
# Assertions
assert result["success"] is False
assert "Could not retrieve issue" in result["error"]
def test_download_issue_attachments_some_failures(
self, attachments_mixin: AttachmentsMixin
):
"""Test download when some attachments fail to download."""
# Mock the issue data
mock_issue = {
"fields": {
"attachment": [
{
"filename": "test1.txt",
"content": "https://test.url/attachment1",
"size": 100,
},
{
"filename": "test2.txt",
"content": "https://test.url/attachment2",
"size": 200,
},
]
}
}
attachments_mixin.jira.issue.return_value = mock_issue
# Mock attachments
mock_attachment1 = MagicMock()
mock_attachment1.filename = "test1.txt"
mock_attachment1.url = "https://test.url/attachment1"
mock_attachment1.size = 100
mock_attachment2 = MagicMock()
mock_attachment2.filename = "test2.txt"
mock_attachment2.url = "https://test.url/attachment2"
mock_attachment2.size = 200
# Mock the download_attachment method to succeed for first attachment and fail for second
with (
patch.object(
attachments_mixin, "download_attachment", side_effect=[True, False]
) as mock_download,
patch("pathlib.Path.mkdir") as mock_mkdir,
patch(
"mcp_atlassian.models.jira.JiraAttachment.from_api_response",
side_effect=[mock_attachment1, mock_attachment2],
),
):
result = attachments_mixin.download_issue_attachments(
"TEST-123", "/tmp/attachments"
)
# Assertions
assert result["success"] is True
assert len(result["downloaded"]) == 1
assert len(result["failed"]) == 1
assert result["downloaded"][0]["filename"] == "test1.txt"
assert result["failed"][0]["filename"] == "test2.txt"
assert mock_download.call_count == 2
def test_download_issue_attachments_missing_url(
self, attachments_mixin: AttachmentsMixin
):
"""Test download when an attachment has no URL."""
# Mock the issue data
mock_issue = {
"fields": {
"attachment": [
{
"filename": "test1.txt",
"content": "https://test.url/attachment1",
"size": 100,
}
]
}
}
attachments_mixin.jira.issue.return_value = mock_issue
# Mock attachment with no URL
mock_attachment = MagicMock()
mock_attachment.filename = "test1.txt"
mock_attachment.url = None # No URL
mock_attachment.size = 100
# Mock path operations
with (
patch("pathlib.Path.mkdir") as mock_mkdir,
patch(
"mcp_atlassian.models.jira.JiraAttachment.from_api_response",
return_value=mock_attachment,
),
):
result = attachments_mixin.download_issue_attachments(
"TEST-123", "/tmp/attachments"
)
# Assertions
assert result["success"] is True
assert len(result["downloaded"]) == 0
assert len(result["failed"]) == 1
assert result["failed"][0]["filename"] == "test1.txt"
assert "No URL available" in result["failed"][0]["error"]
# Tests for upload_attachment method
def test_upload_attachment_success(self, attachments_mixin: AttachmentsMixin):
"""Test successful attachment upload."""
# Mock the Jira API response
mock_attachment_response = {
"id": "12345",
"filename": "test_file.txt",
"size": 100,
}
attachments_mixin.jira.add_attachment.return_value = mock_attachment_response
# Mock file operations
with (
patch("os.path.exists") as mock_exists,
patch("os.path.getsize") as mock_getsize,
patch("os.path.isabs") as mock_isabs,
patch("os.path.abspath") as mock_abspath,
patch("os.path.basename") as mock_basename,
patch("builtins.open", mock_open(read_data=b"test content")),
):
mock_exists.return_value = True
mock_getsize.return_value = 100
mock_isabs.return_value = True
mock_abspath.return_value = "/absolute/path/test_file.txt"
mock_basename.return_value = "test_file.txt"
# Call the method
result = attachments_mixin.upload_attachment(
"TEST-123", "/absolute/path/test_file.txt"
)
# Assertions
assert result["success"] is True
assert result["issue_key"] == "TEST-123"
assert result["filename"] == "test_file.txt"
assert result["size"] == 100
assert result["id"] == "12345"
attachments_mixin.jira.add_attachment.assert_called_once_with(
issue_key="TEST-123", filename="/absolute/path/test_file.txt"
)
def test_upload_attachment_relative_path(self, attachments_mixin: AttachmentsMixin):
"""Test attachment upload with a relative path."""
# Mock the Jira API response
mock_attachment_response = {
"id": "12345",
"filename": "test_file.txt",
"size": 100,
}
attachments_mixin.jira.add_attachment.return_value = mock_attachment_response
# Mock file operations
with (
patch("os.path.exists") as mock_exists,
patch("os.path.getsize") as mock_getsize,
patch("os.path.isabs") as mock_isabs,
patch("os.path.abspath") as mock_abspath,
patch("os.path.basename") as mock_basename,
patch("builtins.open", mock_open(read_data=b"test content")),
):
mock_exists.return_value = True
mock_getsize.return_value = 100
mock_isabs.return_value = False
mock_abspath.return_value = "/absolute/path/test_file.txt"
mock_basename.return_value = "test_file.txt"
# Call the method with a relative path
result = attachments_mixin.upload_attachment("TEST-123", "test_file.txt")
# Assertions
assert result["success"] is True
mock_isabs.assert_called_once_with("test_file.txt")
mock_abspath.assert_called_once_with("test_file.txt")
attachments_mixin.jira.add_attachment.assert_called_once_with(
issue_key="TEST-123", filename="/absolute/path/test_file.txt"
)
def test_upload_attachment_no_issue_key(self, attachments_mixin: AttachmentsMixin):
"""Test attachment upload with no issue key."""
result = attachments_mixin.upload_attachment("", "/path/to/file.txt")
# Assertions
assert result["success"] is False
assert "No issue key provided" in result["error"]
attachments_mixin.jira.add_attachment.assert_not_called()
def test_upload_attachment_no_file_path(self, attachments_mixin: AttachmentsMixin):
"""Test attachment upload with no file path."""
result = attachments_mixin.upload_attachment("TEST-123", "")
# Assertions
assert result["success"] is False
assert "No file path provided" in result["error"]
attachments_mixin.jira.add_attachment.assert_not_called()
def test_upload_attachment_file_not_found(
self, attachments_mixin: AttachmentsMixin
):
"""Test attachment upload when file doesn't exist."""
# Mock file operations
with (
patch("os.path.exists") as mock_exists,
patch("os.path.isabs") as mock_isabs,
patch("os.path.abspath") as mock_abspath,
patch("builtins.open", mock_open(read_data=b"test content")),
):
mock_exists.return_value = False
mock_isabs.return_value = True
mock_abspath.return_value = "/absolute/path/test_file.txt"
result = attachments_mixin.upload_attachment(
"TEST-123", "/absolute/path/test_file.txt"
)
# Assertions
assert result["success"] is False
assert "File not found" in result["error"]
attachments_mixin.jira.add_attachment.assert_not_called()
def test_upload_attachment_api_error(self, attachments_mixin: AttachmentsMixin):
"""Test attachment upload with an API error."""
# Mock the Jira API to raise an exception
attachments_mixin.jira.add_attachment.side_effect = Exception("API Error")
# Mock file operations
with (
patch("os.path.exists") as mock_exists,
patch("os.path.isabs") as mock_isabs,
patch("os.path.abspath") as mock_abspath,
patch("os.path.basename") as mock_basename,
patch("builtins.open", mock_open(read_data=b"test content")),
):
mock_exists.return_value = True
mock_isabs.return_value = True
mock_abspath.return_value = "/absolute/path/test_file.txt"
mock_basename.return_value = "test_file.txt"
result = attachments_mixin.upload_attachment(
"TEST-123", "/absolute/path/test_file.txt"
)
# Assertions
assert result["success"] is False
assert "API Error" in result["error"]
def test_upload_attachment_no_response(self, attachments_mixin: AttachmentsMixin):
"""Test attachment upload when API returns no response."""
# Mock the Jira API to return None
attachments_mixin.jira.add_attachment.return_value = None
# Mock file operations
with (
patch("os.path.exists") as mock_exists,
patch("os.path.isabs") as mock_isabs,
patch("os.path.abspath") as mock_abspath,
patch("os.path.basename") as mock_basename,
patch("builtins.open", mock_open(read_data=b"test content")),
):
mock_exists.return_value = True
mock_isabs.return_value = True
mock_abspath.return_value = "/absolute/path/test_file.txt"
mock_basename.return_value = "test_file.txt"
result = attachments_mixin.upload_attachment(
"TEST-123", "/absolute/path/test_file.txt"
)
# Assertions
assert result["success"] is False
assert "Failed to upload attachment" in result["error"]
# Tests for upload_attachments method
def test_upload_attachments_success(self, attachments_mixin: AttachmentsMixin):
"""Test successful upload of multiple attachments."""
# Set up mock for upload_attachment method to simulate successful uploads
file_paths = [
"/path/to/file1.txt",
"/path/to/file2.pdf",
"/path/to/file3.jpg",
]
# Create mock successful results for each file
mock_results = [
{
"success": True,
"issue_key": "TEST-123",
"filename": f"file{i + 1}.{ext}",
"size": 100 * (i + 1),
"id": f"id{i + 1}",
}
for i, ext in enumerate(["txt", "pdf", "jpg"])
]
with patch.object(
attachments_mixin, "upload_attachment", side_effect=mock_results
) as mock_upload:
# Call the method
result = attachments_mixin.upload_attachments("TEST-123", file_paths)
# Assertions
assert result["success"] is True
assert result["issue_key"] == "TEST-123"
assert result["total"] == 3
assert len(result["uploaded"]) == 3
assert len(result["failed"]) == 0
# Check that upload_attachment was called for each file
assert mock_upload.call_count == 3
mock_upload.assert_any_call("TEST-123", "/path/to/file1.txt")
mock_upload.assert_any_call("TEST-123", "/path/to/file2.pdf")
mock_upload.assert_any_call("TEST-123", "/path/to/file3.jpg")
# Verify uploaded files details
assert result["uploaded"][0]["filename"] == "file1.txt"
assert result["uploaded"][1]["filename"] == "file2.pdf"
assert result["uploaded"][2]["filename"] == "file3.jpg"
assert result["uploaded"][0]["size"] == 100
assert result["uploaded"][1]["size"] == 200
assert result["uploaded"][2]["size"] == 300
assert result["uploaded"][0]["id"] == "id1"
assert result["uploaded"][1]["id"] == "id2"
assert result["uploaded"][2]["id"] == "id3"
def test_upload_attachments_mixed_results(
self, attachments_mixin: AttachmentsMixin
):
"""Test upload of multiple attachments with mixed success and failure."""
# Set up mock for upload_attachment method to simulate mixed results
file_paths = [
"/path/to/file1.txt", # Will succeed
"/path/to/file2.pdf", # Will fail
"/path/to/file3.jpg", # Will succeed
]
# Create mock results with mixed success/failure
mock_results = [
{
"success": True,
"issue_key": "TEST-123",
"filename": "file1.txt",
"size": 100,
"id": "id1",
},
{"success": False, "error": "File not found: /path/to/file2.pdf"},
{
"success": True,
"issue_key": "TEST-123",
"filename": "file3.jpg",
"size": 300,
"id": "id3",
},
]
with patch.object(
attachments_mixin, "upload_attachment", side_effect=mock_results
) as mock_upload:
# Call the method
result = attachments_mixin.upload_attachments("TEST-123", file_paths)
# Assertions
assert (
result["success"] is True
) # Overall success is True even with partial failures
assert result["issue_key"] == "TEST-123"
assert result["total"] == 3
assert len(result["uploaded"]) == 2
assert len(result["failed"]) == 1
# Check that upload_attachment was called for each file
assert mock_upload.call_count == 3
# Verify uploaded files details
assert result["uploaded"][0]["filename"] == "file1.txt"
assert result["uploaded"][1]["filename"] == "file3.jpg"
assert result["uploaded"][0]["size"] == 100
assert result["uploaded"][1]["size"] == 300
assert result["uploaded"][0]["id"] == "id1"
assert result["uploaded"][1]["id"] == "id3"
# Verify failed file details
assert result["failed"][0]["filename"] == "file2.pdf"
assert "File not found" in result["failed"][0]["error"]
def test_upload_attachments_empty_list(self, attachments_mixin: AttachmentsMixin):
"""Test upload with an empty list of file paths."""
# Call the method with an empty list
result = attachments_mixin.upload_attachments("TEST-123", [])
# Assertions
assert result["success"] is False
assert "No file paths provided" in result["error"]
def test_upload_attachments_no_issue_key(self, attachments_mixin: AttachmentsMixin):
"""Test upload with no issue key provided."""
# Call the method with no issue key
result = attachments_mixin.upload_attachments("", ["/path/to/file.txt"])
# Assertions
assert result["success"] is False
assert "No issue key provided" in result["error"]