This is page 13 of 13. Use http://codebase.md/sooperset/mcp-atlassian?lines=true&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
1 | """Tests for the Jira Issues mixin."""
2 |
3 | from unittest.mock import ANY, MagicMock, patch
4 |
5 | import pytest
6 |
7 | from mcp_atlassian.jira import JiraFetcher
8 | from mcp_atlassian.jira.issues import IssuesMixin, logger
9 | from mcp_atlassian.models.jira import JiraIssue
10 |
11 |
12 | class TestIssuesMixin:
13 | """Tests for the IssuesMixin class."""
14 |
15 | @pytest.fixture
16 | def issues_mixin(self, jira_fetcher: JiraFetcher) -> IssuesMixin:
17 | """Create an IssuesMixin instance with mocked dependencies."""
18 | mixin = jira_fetcher
19 |
20 | # Add mock methods that would be provided by other mixins
21 | mixin._get_account_id = MagicMock(return_value="test-account-id")
22 | mixin.get_available_transitions = MagicMock(
23 | return_value=[{"id": "10", "name": "In Progress"}]
24 | )
25 | mixin.transition_issue = MagicMock(
26 | return_value=JiraIssue(id="123", key="TEST-123", summary="Test Issue")
27 | )
28 |
29 | return mixin
30 |
31 | def test_get_issue_basic(self, issues_mixin: IssuesMixin):
32 | """Test retrieving an issue by key."""
33 | # Mock the API response
34 | issues_mixin.jira.get_issue.return_value = {
35 | "id": "10001",
36 | "key": "TEST-123",
37 | "fields": {
38 | "summary": "Test Issue",
39 | "description": "This is a test issue",
40 | "status": {"name": "Open"},
41 | "issuetype": {"name": "Bug"},
42 | "created": "2023-01-01T00:00:00.000+0000",
43 | "updated": "2023-01-02T00:00:00.000+0000",
44 | },
45 | }
46 |
47 | # Call the method
48 | result = issues_mixin.get_issue("TEST-123")
49 |
50 | # Verify API calls
51 | issues_mixin.jira.get_issue.assert_called_once_with(
52 | "TEST-123",
53 | expand=None,
54 | fields=ANY,
55 | properties=None,
56 | update_history=True,
57 | )
58 |
59 | # Verify result structure
60 | assert isinstance(result, JiraIssue)
61 | assert result.key == "TEST-123"
62 | assert result.summary == "Test Issue"
63 | assert result.description == "This is a test issue"
64 |
65 | # Check Jira fields mapping
66 | assert result.status is not None
67 | assert result.status.name == "Open"
68 | assert result.issue_type.name == "Bug"
69 |
70 | def test_get_issue_with_comments(self, issues_mixin: IssuesMixin):
71 | """Test get_issue with comments."""
72 | # Mock the comments data
73 | comments_data = {
74 | "comments": [
75 | {
76 | "id": "1",
77 | "body": "This is a comment",
78 | "author": {"displayName": "John Doe"},
79 | "created": "2023-01-02T00:00:00.000+0000",
80 | "updated": "2023-01-02T00:00:00.000+0000",
81 | }
82 | ]
83 | }
84 |
85 | # Mock the issue data
86 | issue_data = {
87 | "id": "12345",
88 | "key": "TEST-123",
89 | "fields": {
90 | "comment": comments_data,
91 | "summary": "Test Issue",
92 | "description": "Test Description",
93 | "status": {"name": "Open"},
94 | "issuetype": {"name": "Bug"},
95 | "created": "2023-01-01T00:00:00.000+0000",
96 | "updated": "2023-01-02T00:00:00.000+0000",
97 | },
98 | }
99 |
100 | # Set up the mocked responses
101 | issues_mixin.jira.get_issue.return_value = issue_data
102 | issues_mixin.jira.issue_get_comments.return_value = comments_data
103 |
104 | # Call the method
105 | issue = issues_mixin.get_issue(
106 | "TEST-123",
107 | fields="summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype,comment",
108 | )
109 |
110 | # Verify the API calls
111 | issues_mixin.jira.get_issue.assert_called_once_with(
112 | "TEST-123",
113 | expand=None,
114 | fields="summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype,comment",
115 | properties=None,
116 | update_history=True,
117 | )
118 | issues_mixin.jira.issue_get_comments.assert_called_once_with("TEST-123")
119 |
120 | # Verify the comments were added to the issue
121 | assert hasattr(issue, "comments")
122 | assert len(issue.comments) == 1
123 | assert issue.comments[0].body == "This is a comment"
124 |
125 | def test_get_issue_with_epic_info(self, issues_mixin: IssuesMixin):
126 | """Test retrieving issue with epic information."""
127 | try:
128 | # Mock the API responses for get_issue
129 | issues_mixin.jira.get_issue.side_effect = [
130 | # First call - the issue
131 | {
132 | "id": "10001",
133 | "key": "TEST-123",
134 | "fields": {
135 | "summary": "Test Issue",
136 | "description": "This is a test issue",
137 | "status": {"name": "Open"},
138 | "issuetype": {"name": "Story"},
139 | "customfield_10010": "EPIC-456", # Epic Link field
140 | "created": "2023-01-01T00:00:00.000+0000",
141 | "updated": "2023-01-02T00:00:00.000+0000",
142 | },
143 | },
144 | # Second call - the epic
145 | {
146 | "id": "10002",
147 | "key": "EPIC-456",
148 | "fields": {
149 | "summary": "Epic Issue",
150 | "description": "This is an epic",
151 | "status": {"name": "In Progress"},
152 | "issuetype": {"name": "Epic"},
153 | "customfield_10011": "Epic Name Value", # Epic Name field
154 | "created": "2023-01-01T00:00:00.000+0000",
155 | "updated": "2023-01-02T00:00:00.000+0000",
156 | },
157 | },
158 | ]
159 |
160 | # Mock get_field_ids_to_epic
161 | issues_mixin.get_field_ids_to_epic = MagicMock(
162 | return_value={
163 | "epic_link": "customfield_10010",
164 | "epic_name": "customfield_10011",
165 | }
166 | )
167 |
168 | # Call the method - just use get_issue without the include_epic_info parameter
169 | issue = issues_mixin.get_issue("TEST-123")
170 |
171 | # Verify the API calls
172 | issues_mixin.jira.get_issue.assert_any_call(
173 | "TEST-123",
174 | expand=None,
175 | fields=ANY,
176 | properties=None,
177 | update_history=True,
178 | )
179 | issues_mixin.jira.get_issue.assert_any_call(
180 | "EPIC-456",
181 | expand=None,
182 | fields=None,
183 | properties=None,
184 | update_history=True,
185 | )
186 |
187 | # Verify the issue
188 | assert issue.key == "TEST-123"
189 | assert issue.summary == "Test Issue"
190 |
191 | # Verify that the epic information is in the custom fields
192 | assert issue.custom_fields.get("customfield_10010") == {"value": "EPIC-456"}
193 | assert issue.custom_fields.get("customfield_10011") == {
194 | "value": "Epic Name Value"
195 | }
196 |
197 | except Exception as e:
198 | pytest.fail(f"Test failed: {e}")
199 |
200 | def test_get_issue_error_handling(self, issues_mixin: IssuesMixin):
201 | """Test error handling in get_issue."""
202 | # Mock the API to raise an exception
203 | issues_mixin.jira.get_issue.side_effect = Exception("API error")
204 |
205 | # Call the method and verify it raises the expected exception
206 | with pytest.raises(
207 | Exception, match=r"Error retrieving issue TEST-123: API error"
208 | ):
209 | issues_mixin.get_issue("TEST-123")
210 |
211 | def test_normalize_comment_limit(self, issues_mixin: IssuesMixin):
212 | """Test normalizing comment limit."""
213 | # Test with None
214 | assert issues_mixin._normalize_comment_limit(None) is None
215 |
216 | # Test with integer
217 | assert issues_mixin._normalize_comment_limit(5) == 5
218 |
219 | # Test with "all"
220 | assert issues_mixin._normalize_comment_limit("all") is None
221 |
222 | # Test with string number
223 | assert issues_mixin._normalize_comment_limit("10") == 10
224 |
225 | # Test with invalid string
226 | assert issues_mixin._normalize_comment_limit("invalid") == 10
227 |
228 | def test_create_issue_basic(self, issues_mixin: IssuesMixin):
229 | """Test creating a basic issue."""
230 | # Mock create_issue response
231 | create_response = {"id": "12345", "key": "TEST-123"}
232 | issues_mixin.jira.create_issue.return_value = create_response
233 |
234 | # Mock the issue data for get_issue
235 | issue_data = {
236 | "id": "12345",
237 | "key": "TEST-123",
238 | "fields": {
239 | "summary": "Test Issue",
240 | "description": "This is a test issue",
241 | "status": {"name": "Open"},
242 | "issuetype": {"name": "Bug"},
243 | },
244 | }
245 | issues_mixin.jira.get_issue.return_value = issue_data
246 |
247 | # Mock empty comments
248 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
249 |
250 | # Call create_issue
251 | issue = issues_mixin.create_issue(
252 | project_key="TEST",
253 | summary="Test Issue",
254 | issue_type="Bug",
255 | description="This is a test issue",
256 | )
257 |
258 | # Verify API calls
259 | expected_fields = {
260 | "project": {"key": "TEST"},
261 | "summary": "Test Issue",
262 | "issuetype": {"name": "Bug"},
263 | "description": "This is a test issue",
264 | }
265 | issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)
266 | issues_mixin.jira.get_issue.assert_called_once_with("TEST-123")
267 |
268 | # Verify issue
269 | assert issue.key == "TEST-123"
270 | assert issue.summary == "Test Issue"
271 |
272 | def test_create_issue_no_components(self, issues_mixin: IssuesMixin):
273 | """Test creating an issue with no components specified."""
274 | # Mock create_issue response
275 | create_response = {"id": "12345", "key": "TEST-123"}
276 | issues_mixin.jira.create_issue.return_value = create_response
277 |
278 | # Mock the issue data for get_issue
279 | issue_data = {
280 | "id": "12345",
281 | "key": "TEST-123",
282 | "fields": {
283 | "summary": "Test Issue",
284 | "description": "This is a test issue",
285 | "status": {"name": "Open"},
286 | "issuetype": {"name": "Bug"},
287 | },
288 | }
289 | issues_mixin.jira.get_issue.return_value = issue_data
290 |
291 | # Mock empty comments
292 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
293 |
294 | # Call create_issue with components=None
295 | issue = issues_mixin.create_issue(
296 | project_key="TEST",
297 | summary="Test Issue",
298 | issue_type="Bug",
299 | description="This is a test issue",
300 | components=None,
301 | )
302 |
303 | # Verify API calls
304 | expected_fields = {
305 | "project": {"key": "TEST"},
306 | "summary": "Test Issue",
307 | "issuetype": {"name": "Bug"},
308 | "description": "This is a test issue",
309 | }
310 | issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)
311 |
312 | # Verify 'components' is not in the fields
313 | assert "components" not in issues_mixin.jira.create_issue.call_args[1]["fields"]
314 |
315 | def test_create_issue_single_component(self, issues_mixin: IssuesMixin):
316 | """Test creating an issue with a single component."""
317 | # Mock create_issue response
318 | create_response = {"id": "12345", "key": "TEST-123"}
319 | issues_mixin.jira.create_issue.return_value = create_response
320 |
321 | # Mock the issue data for get_issue
322 | issue_data = {
323 | "id": "12345",
324 | "key": "TEST-123",
325 | "fields": {
326 | "summary": "Test Issue",
327 | "description": "This is a test issue",
328 | "status": {"name": "Open"},
329 | "issuetype": {"name": "Bug"},
330 | "components": [{"name": "UI"}],
331 | },
332 | }
333 | issues_mixin.jira.get_issue.return_value = issue_data
334 |
335 | # Mock empty comments
336 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
337 |
338 | # Call create_issue with a single component
339 | issue = issues_mixin.create_issue(
340 | project_key="TEST",
341 | summary="Test Issue",
342 | issue_type="Bug",
343 | description="This is a test issue",
344 | components=["UI"],
345 | )
346 |
347 | # Verify API calls
348 | expected_fields = {
349 | "project": {"key": "TEST"},
350 | "summary": "Test Issue",
351 | "issuetype": {"name": "Bug"},
352 | "description": "This is a test issue",
353 | "components": [{"name": "UI"}],
354 | }
355 | issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)
356 |
357 | # Verify the components field was passed correctly
358 | assert issues_mixin.jira.create_issue.call_args[1]["fields"]["components"] == [
359 | {"name": "UI"}
360 | ]
361 |
362 | def test_create_issue_multiple_components(self, issues_mixin: IssuesMixin):
363 | """Test creating an issue with multiple components."""
364 | # Mock create_issue response
365 | create_response = {"id": "12345", "key": "TEST-123"}
366 | issues_mixin.jira.create_issue.return_value = create_response
367 |
368 | # Mock the issue data for get_issue
369 | issue_data = {
370 | "id": "12345",
371 | "key": "TEST-123",
372 | "fields": {
373 | "summary": "Test Issue",
374 | "description": "This is a test issue",
375 | "status": {"name": "Open"},
376 | "issuetype": {"name": "Bug"},
377 | "components": [{"name": "UI"}, {"name": "API"}],
378 | },
379 | }
380 | issues_mixin.jira.get_issue.return_value = issue_data
381 |
382 | # Mock empty comments
383 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
384 |
385 | # Call create_issue with multiple components
386 | issue = issues_mixin.create_issue(
387 | project_key="TEST",
388 | summary="Test Issue",
389 | issue_type="Bug",
390 | description="This is a test issue",
391 | components=["UI", "API"],
392 | )
393 |
394 | # Verify API calls
395 | expected_fields = {
396 | "project": {"key": "TEST"},
397 | "summary": "Test Issue",
398 | "issuetype": {"name": "Bug"},
399 | "description": "This is a test issue",
400 | "components": [{"name": "UI"}, {"name": "API"}],
401 | }
402 | issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)
403 |
404 | # Verify the components field was passed correctly
405 | assert issues_mixin.jira.create_issue.call_args[1]["fields"]["components"] == [
406 | {"name": "UI"},
407 | {"name": "API"},
408 | ]
409 |
410 | def test_create_issue_components_with_invalid_entries(
411 | self, issues_mixin: IssuesMixin
412 | ):
413 | """Test creating an issue with components list containing invalid entries."""
414 | # Mock create_issue response
415 | create_response = {"id": "12345", "key": "TEST-123"}
416 | issues_mixin.jira.create_issue.return_value = create_response
417 |
418 | # Mock the issue data for get_issue
419 | issue_data = {
420 | "id": "12345",
421 | "key": "TEST-123",
422 | "fields": {
423 | "summary": "Test Issue",
424 | "description": "This is a test issue",
425 | "status": {"name": "Open"},
426 | "issuetype": {"name": "Bug"},
427 | "components": [{"name": "Valid"}, {"name": "Backend"}],
428 | },
429 | }
430 | issues_mixin.jira.get_issue.return_value = issue_data
431 |
432 | # Mock empty comments
433 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
434 |
435 | # Call create_issue with components list containing invalid entries
436 | issue = issues_mixin.create_issue(
437 | project_key="TEST",
438 | summary="Test Issue",
439 | issue_type="Bug",
440 | description="This is a test issue",
441 | components=["Valid", "", None, " Backend "],
442 | )
443 |
444 | # Verify API calls
445 | expected_fields = {
446 | "project": {"key": "TEST"},
447 | "summary": "Test Issue",
448 | "issuetype": {"name": "Bug"},
449 | "description": "This is a test issue",
450 | "components": [{"name": "Valid"}, {"name": "Backend"}],
451 | }
452 | issues_mixin.jira.create_issue.assert_called_once_with(fields=expected_fields)
453 |
454 | # Verify the components field was passed correctly, with invalid entries filtered out
455 | assert issues_mixin.jira.create_issue.call_args[1]["fields"]["components"] == [
456 | {"name": "Valid"},
457 | {"name": "Backend"},
458 | ]
459 |
460 | def test_create_issue_components_precedence(self, issues_mixin, caplog):
461 | """Test that explicit components take precedence over components in additional_fields."""
462 | # Mock create_issue response
463 | create_response = {"id": "12345", "key": "TEST-123"}
464 | issues_mixin.jira.create_issue.return_value = create_response
465 |
466 | # Mock the issue data for get_issue
467 | issue_data = {
468 | "id": "12345",
469 | "key": "TEST-123",
470 | "fields": {
471 | "summary": "Test Issue",
472 | "description": "This is a test issue",
473 | "status": {"name": "Open"},
474 | "issuetype": {"name": "Bug"},
475 | "components": [{"name": "Explicit"}],
476 | },
477 | }
478 | issues_mixin.jira.get_issue.return_value = issue_data
479 |
480 | # Mock empty comments
481 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
482 |
483 | # Direct test for the precedence handling logic
484 | # Create fields dict with components already set by explicit parameter
485 | fields = {
486 | "project": {"key": "TEST"},
487 | "summary": "Test Issue",
488 | "issuetype": {"name": "Bug"},
489 | "description": "This is a test issue",
490 | "components": [{"name": "Explicit"}],
491 | }
492 |
493 | # Create kwargs with a conflicting components entry
494 | kwargs = {"components": [{"name": "Ignored"}]}
495 |
496 | # Directly call the method that would handle the precedence
497 | # This simulates what happens inside create_issue
498 | if "components" in fields and "components" in kwargs:
499 | logger.warning(
500 | "Components provided via both 'components' argument and 'additional_fields'. "
501 | "Using the explicit 'components' argument."
502 | )
503 | # Remove the conflicting key from kwargs to prevent issues later
504 | kwargs.pop("components", None)
505 |
506 | # Verify the warning was logged about the conflict
507 | assert (
508 | "Components provided via both 'components' argument and 'additional_fields'"
509 | in caplog.text
510 | )
511 |
512 | # Verify that kwargs no longer contains components
513 | assert "components" not in kwargs
514 |
515 | # Verify the components field was preserved with the explicit value
516 | assert fields["components"] == [{"name": "Explicit"}]
517 |
518 | def test_create_issue_with_assignee_cloud(self, issues_mixin: IssuesMixin):
519 | """Test creating an issue with an assignee in Jira Cloud."""
520 | # Mock create_issue response
521 | create_response = {"key": "TEST-123"}
522 | issues_mixin.jira.create_issue.return_value = create_response
523 |
524 | # Mock get_issue response
525 | issues_mixin.get_issue = MagicMock(
526 | return_value=JiraIssue(key="TEST-123", description="", summary="Test Issue")
527 | )
528 |
529 | # Mock _get_account_id to return a Cloud account ID
530 | issues_mixin._get_account_id = MagicMock(return_value="cloud-account-id")
531 |
532 | # Configure for Cloud
533 | issues_mixin.config = MagicMock()
534 | issues_mixin.config.is_cloud = True
535 |
536 | # Call the method
537 | issues_mixin.create_issue(
538 | project_key="TEST",
539 | summary="Test Issue",
540 | issue_type="Bug",
541 | assignee="testuser",
542 | )
543 |
544 | # Verify _get_account_id was called with the correct username
545 | issues_mixin._get_account_id.assert_called_once_with("testuser")
546 |
547 | # Verify the assignee was properly set for Cloud (accountId)
548 | fields = issues_mixin.jira.create_issue.call_args[1]["fields"]
549 | assert fields["assignee"] == {"accountId": "cloud-account-id"}
550 |
551 | def test_create_issue_with_assignee_server(self, issues_mixin: IssuesMixin):
552 | """Test creating an issue with an assignee in Jira Server/DC."""
553 | # Mock create_issue response
554 | create_response = {"key": "TEST-456"}
555 | issues_mixin.jira.create_issue.return_value = create_response
556 |
557 | # Mock get_issue response
558 | issues_mixin.get_issue = MagicMock(
559 | return_value=JiraIssue(key="TEST-456", description="", summary="Test Issue")
560 | )
561 |
562 | # Mock _get_account_id to return a Server user ID (typically username)
563 | issues_mixin._get_account_id = MagicMock(return_value="server-user")
564 |
565 | # Configure for Server/DC
566 | issues_mixin.config = MagicMock()
567 | issues_mixin.config.is_cloud = False
568 |
569 | # Call the method
570 | issues_mixin.create_issue(
571 | project_key="TEST",
572 | summary="Test Issue",
573 | issue_type="Bug",
574 | assignee="testuser",
575 | )
576 |
577 | # Verify _get_account_id was called with the correct username
578 | issues_mixin._get_account_id.assert_called_once_with("testuser")
579 |
580 | # Verify the assignee was properly set for Server/DC (name)
581 | fields = issues_mixin.jira.create_issue.call_args[1]["fields"]
582 | assert fields["assignee"] == {"name": "server-user"}
583 |
584 | def test_create_epic(self, issues_mixin: IssuesMixin):
585 | """Test creating an epic."""
586 | # Mock responses
587 | create_response = {"key": "EPIC-123"}
588 | issues_mixin.jira.create_issue.return_value = create_response
589 | issues_mixin.get_issue = MagicMock(
590 | return_value=JiraIssue(key="EPIC-123", description="", summary="Test Epic")
591 | )
592 |
593 | # Mock the prepare_epic_fields method from EpicsMixin
594 | with patch(
595 | "mcp_atlassian.jira.epics.EpicsMixin.prepare_epic_fields", autospec=True
596 | ) as mock_prepare_epic:
597 | # Set up the mock to store epic values in kwargs
598 | # Note: First argument is self because EpicsMixin.prepare_epic_fields is called as a class method
599 | def side_effect(self_args, fields, summary, kwargs, project_key):
600 | kwargs["__epic_name_value"] = summary
601 | kwargs["__epic_name_field"] = "customfield_10011"
602 | return None
603 |
604 | mock_prepare_epic.side_effect = side_effect
605 |
606 | # Mock get_field_ids_to_epic
607 | with patch.object(
608 | issues_mixin,
609 | "get_field_ids_to_epic",
610 | return_value={"Epic Name": "customfield_10011"},
611 | ):
612 | # Call the method
613 | result = issues_mixin.create_issue(
614 | project_key="TEST",
615 | summary="Test Epic",
616 | issue_type="Epic",
617 | )
618 |
619 | # Verify create_issue was called with the right project and summary
620 | create_args = issues_mixin.jira.create_issue.call_args[1]
621 | fields = create_args["fields"]
622 | assert fields["project"]["key"] == "TEST"
623 | assert fields["summary"] == "Test Epic"
624 |
625 | # Verify epic fields are NOT in the fields dictionary (two-step creation)
626 | assert "customfield_10011" not in fields
627 |
628 | # Verify that prepare_epic_fields was called
629 | mock_prepare_epic.assert_called_once()
630 |
631 | # For an Epic, verify that update_issue should be called for the second step
632 | # This would happen in the EpicsMixin.update_epic_fields method which is called
633 | # after the initial creation
634 | assert issues_mixin.get_issue.called
635 | assert result.key == "EPIC-123"
636 |
637 | def test_update_issue_basic(self, issues_mixin: IssuesMixin):
638 | """Test updating an issue with basic fields."""
639 | # Mock the issue data for get_issue
640 | issue_data = {
641 | "id": "12345",
642 | "key": "TEST-123",
643 | "fields": {
644 | "summary": "Updated Summary",
645 | "description": "This is a test issue",
646 | "status": {"name": "In Progress"},
647 | "issuetype": {"name": "Bug"},
648 | },
649 | }
650 | issues_mixin.jira.get_issue.return_value = issue_data
651 |
652 | # Mock empty comments
653 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
654 |
655 | # Call the method
656 | document = issues_mixin.update_issue(
657 | issue_key="TEST-123", fields={"summary": "Updated Summary"}
658 | )
659 |
660 | # Verify the API calls
661 | issues_mixin.jira.update_issue.assert_called_once_with(
662 | issue_key="TEST-123", update={"fields": {"summary": "Updated Summary"}}
663 | )
664 | assert issues_mixin.jira.get_issue.called
665 | assert issues_mixin.jira.get_issue.call_args[0][0] == "TEST-123"
666 |
667 | # Verify the result
668 | assert document.id == "12345"
669 | assert document.key == "TEST-123"
670 | assert document.summary == "Updated Summary"
671 |
672 | def test_update_issue_with_status(self, issues_mixin: IssuesMixin):
673 | """Test updating an issue with a status change."""
674 | # Mock get_issue response
675 | issues_mixin.get_issue = MagicMock(
676 | return_value=JiraIssue(key="TEST-123", description="")
677 | )
678 |
679 | # Mock available transitions (using TransitionsMixin's normalized format)
680 | issues_mixin.get_available_transitions = MagicMock(
681 | return_value=[
682 | {
683 | "id": "21",
684 | "name": "In Progress",
685 | "to_status": "In Progress",
686 | }
687 | ]
688 | )
689 |
690 | # Call the method with status in kwargs instead of fields
691 | issues_mixin.update_issue(issue_key="TEST-123", status="In Progress")
692 |
693 | def test_update_issue_unassign(self, issues_mixin: IssuesMixin):
694 | """Test unassigning an issue."""
695 | issue_data = {
696 | "id": "12345",
697 | "key": "TEST-123",
698 | "fields": {
699 | "summary": "Test Issue",
700 | "description": "This is a test",
701 | "status": {"name": "Open"},
702 | "issuetype": {"name": "Bug"},
703 | },
704 | }
705 | issues_mixin.jira.get_issue.return_value = issue_data
706 | issues_mixin.jira.issue_get_comments.return_value = {"comments": []}
707 | issues_mixin._get_account_id = MagicMock()
708 |
709 | document = issues_mixin.update_issue(issue_key="TEST-123", assignee=None)
710 |
711 | issues_mixin.jira.update_issue.assert_called_once_with(
712 | issue_key="TEST-123", update={"fields": {"assignee": None}}
713 | )
714 | assert not issues_mixin._get_account_id.called
715 | assert document.key == "TEST-123"
716 |
717 | def test_delete_issue(self, issues_mixin: IssuesMixin):
718 | """Test deleting an issue."""
719 | # Call the method
720 | result = issues_mixin.delete_issue("TEST-123")
721 |
722 | # Verify the API call
723 | issues_mixin.jira.delete_issue.assert_called_once_with("TEST-123")
724 | assert result is True
725 |
726 | def test_delete_issue_error(self, issues_mixin: IssuesMixin):
727 | """Test error handling when deleting an issue."""
728 | # Setup mock to throw exception
729 | issues_mixin.jira.delete_issue.side_effect = Exception("Delete failed")
730 |
731 | # Call the method and verify exception is raised correctly
732 | with pytest.raises(
733 | Exception, match="Error deleting issue TEST-123: Delete failed"
734 | ):
735 | issues_mixin.delete_issue("TEST-123")
736 |
737 | def test_process_additional_fields_with_fixversions(
738 | self, issues_mixin: IssuesMixin
739 | ):
740 | """Test _process_additional_fields properly handles fixVersions field."""
741 | # Initialize test data
742 | fields = {}
743 | kwargs = {"fixVersions": [{"name": "TestRelease"}]}
744 |
745 | # Call the method
746 | issues_mixin._process_additional_fields(fields, kwargs)
747 |
748 | # Verify fixVersions was added correctly to fields
749 | assert "fixVersions" in fields
750 | assert fields["fixVersions"] == [{"name": "TestRelease"}]
751 |
752 | def test_create_issue_with_parent_for_task(self, issues_mixin: IssuesMixin):
753 | """Test creating a regular task issue with a parent field."""
754 | # Setup mock response for create_issue
755 | create_response = {
756 | "id": "12345",
757 | "key": "TEST-456",
758 | "self": "https://jira.example.com/rest/api/2/issue/12345",
759 | }
760 | issues_mixin.jira.create_issue.return_value = create_response
761 |
762 | # Setup mock response for issue retrieval
763 | issue_response = {
764 | "id": "12345",
765 | "key": "TEST-456",
766 | "fields": {
767 | "summary": "Test Task with Parent",
768 | "description": "This is a test",
769 | "status": {"name": "Open"},
770 | "issuetype": {"name": "Task"},
771 | "parent": {"key": "TEST-123"},
772 | },
773 | }
774 | issues_mixin.jira.get_issue.return_value = issue_response
775 |
776 | issues_mixin._get_account_id = MagicMock(return_value="user123")
777 |
778 | # Execute - create a Task with parent field
779 | result = issues_mixin.create_issue(
780 | project_key="TEST",
781 | summary="Test Task with Parent",
782 | issue_type="Task",
783 | description="This is a test",
784 | assignee="jdoe",
785 | parent="TEST-123", # Adding parent for a non-subtask
786 | )
787 |
788 | # Verify
789 | issues_mixin.jira.create_issue.assert_called_once()
790 | call_kwargs = issues_mixin.jira.create_issue.call_args[1]
791 | assert "fields" in call_kwargs
792 | fields = call_kwargs["fields"]
793 |
794 | # Verify parent field was included
795 | assert "parent" in fields
796 | assert fields["parent"] == {"key": "TEST-123"}
797 |
798 | # Verify issue method was called after creation
799 | assert issues_mixin.jira.get_issue.called
800 | assert issues_mixin.jira.get_issue.call_args[0][0] == "TEST-456"
801 |
802 | # Verify the issue was created successfully
803 | assert result is not None
804 | assert result.key == "TEST-456"
805 |
806 | def test_create_issue_with_fixversions(self, issues_mixin: IssuesMixin):
807 | """Test creating an issue with fixVersions in additional_fields."""
808 | # Mock create_issue response
809 | create_response = {"id": "12345", "key": "TEST-123"}
810 | issues_mixin.jira.create_issue.return_value = create_response
811 |
812 | # Mock the issue data for get_issue
813 | issue_data = {
814 | "id": "12345",
815 | "key": "TEST-123",
816 | "fields": {
817 | "summary": "Test Issue",
818 | "description": "This is a test issue",
819 | "status": {"name": "Open"},
820 | "issuetype": {"name": "Bug"},
821 | "fixVersions": [{"name": "1.0.0"}],
822 | },
823 | }
824 | issues_mixin.jira.get_issue.return_value = issue_data
825 |
826 | # Create the issue with fixVersions in additional_fields
827 | result = issues_mixin.create_issue(
828 | project_key="TEST",
829 | summary="Test Issue",
830 | issue_type="Bug",
831 | description="This is a test issue",
832 | fixVersions=[{"name": "1.0.0"}],
833 | )
834 |
835 | # Verify API call to create issue
836 | issues_mixin.jira.create_issue.assert_called_once()
837 | call_args = issues_mixin.jira.create_issue.call_args[1]
838 | fields = call_args["fields"]
839 | assert fields["project"]["key"] == "TEST"
840 | assert fields["summary"] == "Test Issue"
841 | assert fields["issuetype"]["name"] == "Bug"
842 | assert fields["description"] == "This is a test issue"
843 | assert "fixVersions" in fields
844 | assert fields["fixVersions"] == [{"name": "1.0.0"}]
845 |
846 | # Verify API call to get issue
847 | issues_mixin.jira.get_issue.assert_called_once_with("TEST-123")
848 |
849 | # Verify result
850 | assert result.key == "TEST-123"
851 | assert result.summary == "Test Issue"
852 | assert result.issue_type and result.issue_type.name == "Bug"
853 | assert hasattr(result, "fix_versions")
854 | assert len(result.fix_versions) == 1
855 | # The JiraIssue model might process fixVersions differently, check the actual structure
856 | # This depends on how JiraIssue.from_api_response handles the fixVersions field
857 | # If it's a list of dictionaries, use:
858 | if hasattr(result.fix_versions[0], "name"):
859 | assert result.fix_versions[0].name == "1.0.0"
860 | else:
861 | # If it's a list of strings or other format, adjust accordingly:
862 | assert "1.0.0" in str(result.fix_versions[0])
863 |
864 | def test_get_issue_with_custom_fields(self, issues_mixin: IssuesMixin):
865 | """Test get_issue with custom fields parameter."""
866 | # Mock the response with custom fields
867 | mock_issue = {
868 | "id": "10001",
869 | "key": "TEST-123",
870 | "fields": {
871 | "summary": "Test issue with custom field",
872 | "customfield_10049": "Custom value",
873 | "customfield_10050": {"value": "Option value"},
874 | "description": "Issue description",
875 | },
876 | }
877 | issues_mixin.jira.get_issue.return_value = mock_issue
878 |
879 | # Test with string format
880 | issue = issues_mixin.get_issue("TEST-123", fields="summary,customfield_10049")
881 |
882 | # Verify the API call
883 | issues_mixin.jira.get_issue.assert_called_with(
884 | "TEST-123",
885 | expand=None,
886 | fields="summary,customfield_10049",
887 | properties=None,
888 | update_history=True,
889 | )
890 |
891 | # Check the result
892 | simplified = issue.to_simplified_dict()
893 | assert "customfield_10049" in simplified
894 | assert simplified["customfield_10049"] == {"value": "Custom value"}
895 | assert "description" not in simplified
896 |
897 | # Test with list format
898 | issues_mixin.jira.get_issue.reset_mock()
899 | issue = issues_mixin.get_issue(
900 | "TEST-123", fields=["summary", "customfield_10050"]
901 | )
902 |
903 | # Verify API call converts list to comma-separated string
904 | issues_mixin.jira.get_issue.assert_called_with(
905 | "TEST-123",
906 | expand=None,
907 | fields="summary,customfield_10050",
908 | properties=None,
909 | update_history=True,
910 | )
911 |
912 | # Check the result
913 | simplified = issue.to_simplified_dict()
914 | assert "customfield_10050" in simplified
915 | assert simplified["customfield_10050"] == {"value": "Option value"}
916 |
917 | def test_get_issue_with_all_fields(self, issues_mixin: IssuesMixin):
918 | """Test get_issue with '*all' fields parameter."""
919 | # Mock the response
920 | mock_issue = {
921 | "id": "10001",
922 | "key": "TEST-123",
923 | "fields": {
924 | "summary": "Test issue",
925 | "description": "Description",
926 | "customfield_10049": "Custom value",
927 | },
928 | }
929 | issues_mixin.jira.get_issue.return_value = mock_issue
930 |
931 | # Test with "*all" parameter
932 | issue = issues_mixin.get_issue("TEST-123", fields="*all")
933 |
934 | # Check that all fields are included
935 | simplified = issue.to_simplified_dict()
936 | assert "summary" in simplified
937 | assert "description" in simplified
938 | assert "customfield_10049" in simplified
939 |
940 | def test_get_issue_with_properties(self, issues_mixin: IssuesMixin):
941 | """Test get_issue with properties parameter."""
942 | # Mock the response
943 | issues_mixin.jira.get_issue.return_value = {
944 | "id": "10001",
945 | "key": "TEST-123",
946 | "fields": {},
947 | }
948 |
949 | # Test with properties parameter as string
950 | issues_mixin.get_issue("TEST-123", properties="property1,property2")
951 |
952 | # Verify API call - should include properties parameter and add 'properties' to fields
953 | issues_mixin.jira.get_issue.assert_called_with(
954 | "TEST-123",
955 | expand=None,
956 | fields=ANY,
957 | properties="property1,property2",
958 | update_history=True,
959 | )
960 |
961 | # Test with properties parameter as list
962 | issues_mixin.jira.get_issue.reset_mock()
963 | issues_mixin.get_issue("TEST-123", properties=["property1", "property2"])
964 |
965 | # Verify API call - should include properties parameter as comma-separated string and add 'properties' to fields
966 | issues_mixin.jira.get_issue.assert_called_with(
967 | "TEST-123",
968 | expand=None,
969 | fields=ANY,
970 | properties="property1,property2",
971 | update_history=True,
972 | )
973 |
974 | def test_get_issue_with_update_history(self, issues_mixin: IssuesMixin):
975 | """Test get_issue with update_history parameter."""
976 | # Mock the response
977 | issues_mixin.jira.get_issue.return_value = {
978 | "id": "10001",
979 | "key": "TEST-123",
980 | "fields": {},
981 | }
982 |
983 | # Test with update_history=False
984 | issues_mixin.get_issue("TEST-123", update_history=False)
985 |
986 | # Verify API call - should include update_history parameter
987 | issues_mixin.jira.get_issue.assert_called_with(
988 | "TEST-123",
989 | expand=None,
990 | fields=ANY,
991 | properties=None,
992 | update_history=False,
993 | )
994 |
995 | def test_batch_create_issues_basic(self, issues_mixin: IssuesMixin):
996 | """Test basic functionality of batch_create_issues."""
997 | # Setup test data
998 | issues = [
999 | {
1000 | "project_key": "TEST",
1001 | "summary": "Test Issue 1",
1002 | "issue_type": "Task",
1003 | "description": "Description 1",
1004 | },
1005 | {
1006 | "project_key": "TEST",
1007 | "summary": "Test Issue 2",
1008 | "issue_type": "Bug",
1009 | "description": "Description 2",
1010 | "assignee": "john.doe",
1011 | "components": ["Frontend"],
1012 | },
1013 | ]
1014 |
1015 | # Mock bulk create response
1016 | bulk_response = {
1017 | "issues": [
1018 | {"id": "1", "key": "TEST-1", "self": "http://example.com/TEST-1"},
1019 | {"id": "2", "key": "TEST-2", "self": "http://example.com/TEST-2"},
1020 | ],
1021 | "errors": [],
1022 | }
1023 | issues_mixin.jira.create_issues.return_value = bulk_response
1024 |
1025 | # Mock get_issue responses
1026 | def get_issue_side_effect(key):
1027 | if key == "TEST-1":
1028 | return {
1029 | "id": "1",
1030 | "key": "TEST-1",
1031 | "fields": {"summary": "Test Issue 1"},
1032 | }
1033 | return {"id": "2", "key": "TEST-2", "fields": {"summary": "Test Issue 2"}}
1034 |
1035 | issues_mixin.jira.get_issue.side_effect = get_issue_side_effect
1036 | issues_mixin._get_account_id.return_value = "user123"
1037 |
1038 | # Call the method
1039 | result = issues_mixin.batch_create_issues(issues)
1040 |
1041 | # Verify results
1042 | assert len(result) == 2
1043 | assert result[0].key == "TEST-1"
1044 | assert result[1].key == "TEST-2"
1045 |
1046 | # Verify bulk create was called correctly
1047 | issues_mixin.jira.create_issues.assert_called_once()
1048 | call_args = issues_mixin.jira.create_issues.call_args[0][0]
1049 | assert len(call_args) == 2
1050 | assert call_args[0]["fields"]["summary"] == "Test Issue 1"
1051 | assert call_args[1]["fields"]["summary"] == "Test Issue 2"
1052 |
1053 | def test_batch_create_issues_validate_only(self, issues_mixin: IssuesMixin):
1054 | """Test batch_create_issues with validate_only=True."""
1055 | # Setup test data
1056 | issues = [
1057 | {
1058 | "project_key": "TEST",
1059 | "summary": "Test Issue 1",
1060 | "issue_type": "Task",
1061 | },
1062 | {
1063 | "project_key": "TEST",
1064 | "summary": "Test Issue 2",
1065 | "issue_type": "Bug",
1066 | },
1067 | ]
1068 |
1069 | # Call the method with validate_only=True
1070 | result = issues_mixin.batch_create_issues(issues, validate_only=True)
1071 |
1072 | # Verify no issues were created
1073 | assert len(result) == 0
1074 | assert not issues_mixin.jira.create_issues.called
1075 |
1076 | def test_batch_create_issues_missing_required_fields(
1077 | self, issues_mixin: IssuesMixin
1078 | ):
1079 | """Test batch_create_issues with missing required fields."""
1080 | # Setup test data with missing fields
1081 | issues = [
1082 | {
1083 | "project_key": "TEST",
1084 | "summary": "Test Issue 1",
1085 | # Missing issue_type
1086 | },
1087 | {
1088 | "project_key": "TEST",
1089 | "summary": "Test Issue 2",
1090 | "issue_type": "Bug",
1091 | },
1092 | ]
1093 |
1094 | # Verify it raises ValueError
1095 | with pytest.raises(ValueError) as exc_info:
1096 | issues_mixin.batch_create_issues(issues)
1097 |
1098 | assert "Missing required fields" in str(exc_info.value)
1099 | assert not issues_mixin.jira.create_issues.called
1100 |
1101 | def test_batch_create_issues_partial_failure(self, issues_mixin: IssuesMixin):
1102 | """Test batch_create_issues when some issues fail to create."""
1103 | # Setup test data
1104 | issues = [
1105 | {
1106 | "project_key": "TEST",
1107 | "summary": "Test Issue 1",
1108 | "issue_type": "Task",
1109 | },
1110 | {
1111 | "project_key": "TEST",
1112 | "summary": "Test Issue 2",
1113 | "issue_type": "Bug",
1114 | },
1115 | ]
1116 |
1117 | # Mock bulk create response with an error
1118 | bulk_response = {
1119 | "issues": [
1120 | {"id": "1", "key": "TEST-1", "self": "http://example.com/TEST-1"},
1121 | ],
1122 | "errors": [{"issue": {"key": None}, "error": "Invalid issue type"}],
1123 | }
1124 | issues_mixin.jira.create_issues.return_value = bulk_response
1125 |
1126 | # Mock get_issue response for successful creation
1127 | issues_mixin.jira.get_issue.return_value = {
1128 | "id": "1",
1129 | "key": "TEST-1",
1130 | "fields": {"summary": "Test Issue 1"},
1131 | }
1132 |
1133 | # Call the method
1134 | result = issues_mixin.batch_create_issues(issues)
1135 |
1136 | # Verify results - should have only the first issue
1137 | assert len(result) == 1
1138 | assert result[0].key == "TEST-1"
1139 |
1140 | # Verify error was logged
1141 | issues_mixin.jira.create_issues.assert_called_once()
1142 | assert len(issues_mixin.jira.get_issue.mock_calls) == 1
1143 |
1144 | def test_batch_create_issues_empty_list(self, issues_mixin: IssuesMixin):
1145 | """Test batch_create_issues with an empty list."""
1146 | result = issues_mixin.batch_create_issues([])
1147 | assert result == []
1148 | assert not issues_mixin.jira.create_issues.called
1149 |
1150 | def test_batch_create_issues_with_components(self, issues_mixin: IssuesMixin):
1151 | """Test batch_create_issues with component handling."""
1152 | # Setup test data with various component formats
1153 | issues = [
1154 | {
1155 | "project_key": "TEST",
1156 | "summary": "Test Issue 1",
1157 | "issue_type": "Task",
1158 | "components": ["Frontend", "", None, " Backend "],
1159 | }
1160 | ]
1161 |
1162 | # Mock responses
1163 | bulk_response = {
1164 | "issues": [
1165 | {"id": "1", "key": "TEST-1", "self": "http://example.com/TEST-1"},
1166 | ],
1167 | "errors": [],
1168 | }
1169 | issues_mixin.jira.create_issues.return_value = bulk_response
1170 | issues_mixin.jira.get_issue.return_value = {
1171 | "id": "1",
1172 | "key": "TEST-1",
1173 | "fields": {"summary": "Test Issue 1"},
1174 | }
1175 |
1176 | # Call the method
1177 | result = issues_mixin.batch_create_issues(issues)
1178 |
1179 | # Verify results
1180 | assert len(result) == 1
1181 |
1182 | # Verify components were properly formatted
1183 | call_args = issues_mixin.jira.create_issues.call_args[0][0]
1184 | assert len(call_args) == 1
1185 | components = call_args[0]["fields"]["components"]
1186 | assert len(components) == 2
1187 | assert components[0]["name"] == "Frontend"
1188 | assert components[1]["name"] == "Backend"
1189 |
1190 | def test_add_assignee_to_fields_cloud(self, issues_mixin: IssuesMixin):
1191 | """Test _add_assignee_to_fields for Cloud instance."""
1192 | # Set up cloud config
1193 | issues_mixin.config = MagicMock()
1194 | issues_mixin.config.is_cloud = True
1195 |
1196 | # Test fields dict
1197 | fields = {}
1198 |
1199 | # Call the method
1200 | issues_mixin._add_assignee_to_fields(fields, "account-123")
1201 |
1202 | # Verify result
1203 | assert fields["assignee"] == {"accountId": "account-123"}
1204 |
1205 | def test_add_assignee_to_fields_server_dc(self, issues_mixin: IssuesMixin):
1206 | """Test _add_assignee_to_fields for Server/Data Center instance."""
1207 | # Set up Server/DC config
1208 | issues_mixin.config = MagicMock()
1209 | issues_mixin.config.is_cloud = False
1210 |
1211 | # Test fields dict
1212 | fields = {}
1213 |
1214 | # Call the method
1215 | issues_mixin._add_assignee_to_fields(fields, "jdoe")
1216 |
1217 | # Verify result
1218 | assert fields["assignee"] == {"name": "jdoe"}
1219 |
1220 | def test_batch_get_changelogs_not_cloud(self, issues_mixin: IssuesMixin):
1221 | """Test batch_get_changelogs method on non-cloud instance."""
1222 | issues_mixin.config = MagicMock()
1223 | issues_mixin.config.is_cloud = False
1224 |
1225 | with pytest.raises(NotImplementedError):
1226 | issues_mixin.batch_get_changelogs(
1227 | issue_ids_or_keys=["TEST-123"],
1228 | fields=["summary", "description"],
1229 | )
1230 |
1231 | def test_batch_get_changelogs_cloud(self, issues_mixin: IssuesMixin):
1232 | """Test batch_get_changelogs method on cloud instance."""
1233 | issues_mixin.config = MagicMock()
1234 | issues_mixin.config.is_cloud = True
1235 |
1236 | # Mock get_paged result
1237 | mock_get_paged_result = [
1238 | {
1239 | "issueChangeLogs": [
1240 | {
1241 | "issueId": "TEST-1",
1242 | "changeHistories": [
1243 | {
1244 | "id": "10001",
1245 | "author": {
1246 | "accountId": "user123",
1247 | "displayName": "Test User 1",
1248 | "active": True,
1249 | "timeZone": "UTC",
1250 | "accountType": "atlassian",
1251 | },
1252 | "created": "2024-01-05T10:06:03.548+0800",
1253 | "items": [
1254 | {
1255 | "field": "IssueParentAssociation",
1256 | "fieldtype": "jira",
1257 | "from": None,
1258 | "fromString": None,
1259 | "to": "1001",
1260 | "toString": "TEST-100",
1261 | }
1262 | ],
1263 | }
1264 | ],
1265 | },
1266 | {
1267 | "issueId": "TEST-2",
1268 | "changeHistories": [
1269 | {
1270 | "id": "10002",
1271 | "author": {
1272 | "accountId": "user456",
1273 | "displayName": "Test User 2",
1274 | "active": True,
1275 | "timeZone": "UTC",
1276 | "accountType": "atlassian",
1277 | },
1278 | "created": "1704106800000", # 2024-01-01
1279 | "items": [
1280 | {
1281 | "field": "Parent",
1282 | "fieldtype": "jira",
1283 | "from": None,
1284 | "fromString": None,
1285 | "to": "1002",
1286 | "toString": "TEST-200",
1287 | }
1288 | ],
1289 | },
1290 | {
1291 | "id": "10003",
1292 | "author": {
1293 | "accountId": "user789",
1294 | "displayName": "Test User 3",
1295 | "active": True,
1296 | "timeZone": "UTC",
1297 | "accountType": "atlassian",
1298 | },
1299 | "created": "2024-01-06T10:06:03.548+0800",
1300 | "items": [
1301 | {
1302 | "field": "Parent",
1303 | "fieldtype": "jira",
1304 | "from": "1002",
1305 | "fromString": "TEST-200",
1306 | "to": "1003",
1307 | "toString": "TEST-300",
1308 | }
1309 | ],
1310 | },
1311 | ],
1312 | },
1313 | ],
1314 | "nextPageToken": "token1",
1315 | },
1316 | {
1317 | "issueChangeLogs": [
1318 | {
1319 | "issueId": "TEST-2",
1320 | "changeHistories": [
1321 | {
1322 | "id": "10004",
1323 | "author": {
1324 | "accountId": "user123",
1325 | "displayName": "Test User 1",
1326 | "active": True,
1327 | "timeZone": "UTC",
1328 | "accountType": "atlassian",
1329 | },
1330 | "created": "2024-01-10T10:06:03.548+0800",
1331 | "items": [
1332 | {
1333 | "field": "Parent",
1334 | "fieldtype": "jira",
1335 | "from": "1003",
1336 | "fromString": "TEST-300",
1337 | "to": "1004",
1338 | "toString": "TEST-400",
1339 | }
1340 | ],
1341 | }
1342 | ],
1343 | }
1344 | ],
1345 | },
1346 | ]
1347 |
1348 | # Expected result
1349 | expected_result = [
1350 | {
1351 | "assignee": {"display_name": "Unassigned"},
1352 | "changelogs": [
1353 | {
1354 | "author": {
1355 | "avatar_url": None,
1356 | "display_name": "Test User 1",
1357 | "email": None,
1358 | "name": "Test User 1",
1359 | },
1360 | "created": "2024-01-05 10:06:03.548000+08:00",
1361 | "items": [
1362 | {
1363 | "field": "IssueParentAssociation",
1364 | "fieldtype": "jira",
1365 | "to_id": "1001",
1366 | "to_string": "TEST-100",
1367 | },
1368 | ],
1369 | },
1370 | ],
1371 | "id": "TEST-1",
1372 | "key": "UNKNOWN-0",
1373 | "summary": "",
1374 | },
1375 | {
1376 | "assignee": {"display_name": "Unassigned"},
1377 | "changelogs": [
1378 | {
1379 | "author": {
1380 | "avatar_url": None,
1381 | "display_name": "Test User 2",
1382 | "email": None,
1383 | "name": "Test User 2",
1384 | },
1385 | "created": "2024-01-01 11:00:00+00:00",
1386 | "items": [
1387 | {
1388 | "field": "Parent",
1389 | "fieldtype": "jira",
1390 | "to_id": "1002",
1391 | "to_string": "TEST-200",
1392 | },
1393 | ],
1394 | },
1395 | {
1396 | "author": {
1397 | "avatar_url": None,
1398 | "display_name": "Test User 3",
1399 | "email": None,
1400 | "name": "Test User 3",
1401 | },
1402 | "created": "2024-01-06 10:06:03.548000+08:00",
1403 | "items": [
1404 | {
1405 | "field": "Parent",
1406 | "fieldtype": "jira",
1407 | "from_id": "1002",
1408 | "from_string": "TEST-200",
1409 | "to_id": "1003",
1410 | "to_string": "TEST-300",
1411 | },
1412 | ],
1413 | },
1414 | {
1415 | "author": {
1416 | "avatar_url": None,
1417 | "display_name": "Test User 1",
1418 | "email": None,
1419 | "name": "Test User 1",
1420 | },
1421 | "created": "2024-01-10 10:06:03.548000+08:00",
1422 | "items": [
1423 | {
1424 | "field": "Parent",
1425 | "fieldtype": "jira",
1426 | "from_id": "1003",
1427 | "from_string": "TEST-300",
1428 | "to_id": "1004",
1429 | "to_string": "TEST-400",
1430 | },
1431 | ],
1432 | },
1433 | ],
1434 | "id": "TEST-2",
1435 | "key": "UNKNOWN-0",
1436 | "summary": "",
1437 | },
1438 | ]
1439 |
1440 | # Mock the get_paged method
1441 | issues_mixin.get_paged = MagicMock(return_value=mock_get_paged_result)
1442 |
1443 | # Call the method
1444 | result = issues_mixin.batch_get_changelogs(
1445 | issue_ids_or_keys=["TEST-1", "TEST-2"],
1446 | fields=["Parent"],
1447 | )
1448 |
1449 | # Verify the result
1450 | simplified_result = [issue.to_simplified_dict() for issue in result]
1451 | assert simplified_result == expected_result
1452 |
1453 | # Verify the method was called with the correct arguments
1454 | issues_mixin.get_paged.assert_called_once_with(
1455 | method="post",
1456 | url=issues_mixin.jira.resource_url("changelog/bulkfetch"),
1457 | params_or_json={
1458 | "fieldIds": ["Parent"],
1459 | "issueIdsOrKeys": ["TEST-1", "TEST-2"],
1460 | },
1461 | )
1462 |
1463 | def test_create_issue_with_labels(self, issues_mixin: IssuesMixin):
1464 | """Test creating an issue with labels in additional_fields."""
1465 | # Mock create_issue response
1466 | create_response = {"id": "12345", "key": "TEST-123"}
1467 | issues_mixin.jira.create_issue.return_value = create_response
1468 |
1469 | # Mock the issue data for get_issue
1470 | issue_data = {
1471 | "id": "12345",
1472 | "key": "TEST-123",
1473 | "fields": {
1474 | "summary": "Test Issue",
1475 | "description": "This is a test issue",
1476 | "status": {"name": "Open"},
1477 | "issuetype": {"name": "Bug"},
1478 | "labels": ["bug", "frontend"],
1479 | },
1480 | }
1481 | issues_mixin.jira.get_issue.return_value = issue_data
1482 |
1483 | # Create the issue with labels as a list
1484 | result = issues_mixin.create_issue(
1485 | project_key="TEST",
1486 | summary="Test Issue",
1487 | issue_type="Bug",
1488 | description="This is a test issue",
1489 | labels=["bug", "frontend"],
1490 | )
1491 |
1492 | # Verify the API call
1493 | issues_mixin.jira.create_issue.assert_called_once()
1494 | call_kwargs = issues_mixin.jira.create_issue.call_args[1]
1495 | assert "fields" in call_kwargs
1496 | fields = call_kwargs["fields"]
1497 |
1498 | # Verify labels were added to the fields
1499 | assert "labels" in fields
1500 | assert fields["labels"] == ["bug", "frontend"]
1501 |
1502 | # Verify result
1503 | assert result.key == "TEST-123"
1504 | assert result.labels == ["bug", "frontend"]
1505 |
1506 | def test_create_issue_with_labels_as_string(self, issues_mixin: IssuesMixin):
1507 | """Test creating an issue with labels as comma-separated string in additional_fields."""
1508 | # Mock create_issue response
1509 | create_response = {"id": "12345", "key": "TEST-123"}
1510 | issues_mixin.jira.create_issue.return_value = create_response
1511 |
1512 | # Mock the issue data for get_issue
1513 | issue_data = {
1514 | "id": "12345",
1515 | "key": "TEST-123",
1516 | "fields": {
1517 | "summary": "Test Issue",
1518 | "description": "This is a test issue",
1519 | "status": {"name": "Open"},
1520 | "issuetype": {"name": "Bug"},
1521 | "labels": ["bug", "frontend"],
1522 | },
1523 | }
1524 | issues_mixin.jira.get_issue.return_value = issue_data
1525 |
1526 | # Create the issue with labels as a comma-separated string
1527 | # Pass labels directly instead of through additional_fields
1528 | result = issues_mixin.create_issue(
1529 | project_key="TEST",
1530 | summary="Test Issue",
1531 | issue_type="Bug",
1532 | description="This is a test issue",
1533 | labels="bug,frontend", # Pass as string and let _format_field_value_for_write handle it
1534 | )
1535 |
1536 | # Verify the API call
1537 | issues_mixin.jira.create_issue.assert_called_once()
1538 | call_kwargs = issues_mixin.jira.create_issue.call_args[1]
1539 | assert "fields" in call_kwargs
1540 | fields = call_kwargs["fields"]
1541 |
1542 | # Verify labels were parsed and added to the fields
1543 | assert "labels" in fields
1544 | assert fields["labels"] == ["bug", "frontend"]
1545 |
1546 | # Verify result
1547 | assert result.key == "TEST-123"
1548 | assert result.labels == ["bug", "frontend"]
1549 |
1550 | def test_get_issue_with_config_projects_filter_restricted(
1551 | self, issues_mixin: IssuesMixin
1552 | ):
1553 | """Test get_issue with projects filter from config - restricted case."""
1554 | # Setup mock response
1555 | mock_issues = {
1556 | "issues": [
1557 | {
1558 | "id": "10001",
1559 | "key": "TEST-123",
1560 | "fields": {
1561 | "summary": "Test issue",
1562 | "issuetype": {"name": "Bug"},
1563 | "status": {"name": "Open"},
1564 | },
1565 | }
1566 | ],
1567 | "total": 1,
1568 | "startAt": 0,
1569 | "maxResults": 50,
1570 | }
1571 | issues_mixin.jira.jql.return_value = mock_issues
1572 | issues_mixin.config.url = "https://example.atlassian.net"
1573 | issues_mixin.config.projects_filter = "DEV"
1574 |
1575 | # Mock the API to raise an exception
1576 | issues_mixin.jira.get_issue.side_effect = Exception("API error")
1577 |
1578 | # Call the method and verify it raises the expected exception
1579 | with pytest.raises(
1580 | Exception,
1581 | match=(
1582 | "Error retrieving issue TEST-123: "
1583 | "Issue with project prefix 'TEST' are restricted by configuration"
1584 | ),
1585 | ):
1586 | issues_mixin.get_issue("TEST-123")
1587 |
1588 | def test_get_issue_with_config_projects_filter_allowed(
1589 | self, issues_mixin: IssuesMixin
1590 | ):
1591 | """Test get_issue with projects filter from config - allowed case."""
1592 | # Setup mock response for a project that matches the filter
1593 | mock_issue_data = {
1594 | "id": "10001",
1595 | "key": "DEV-123",
1596 | "fields": {
1597 | "summary": "Test issue",
1598 | "description": "This is a test issue",
1599 | "status": {"name": "Open"},
1600 | "issuetype": {"name": "Bug"},
1601 | },
1602 | }
1603 | issues_mixin.jira.get_issue.return_value = mock_issue_data
1604 | issues_mixin.config.url = "https://example.atlassian.net"
1605 | issues_mixin.config.projects_filter = "DEV"
1606 |
1607 | # Call the method
1608 | result = issues_mixin.get_issue("DEV-123")
1609 |
1610 | # Verify the API call was made correctly
1611 | issues_mixin.jira.get_issue.assert_called_once_with(
1612 | "DEV-123",
1613 | expand=None,
1614 | fields=ANY,
1615 | properties=None,
1616 | update_history=True,
1617 | )
1618 |
1619 | # Verify the result
1620 | assert isinstance(result, JiraIssue)
1621 | assert result.key == "DEV-123"
1622 | assert result.summary == "Test issue"
1623 |
1624 | def test_get_issue_with_multiple_projects_filter(self, issues_mixin: IssuesMixin):
1625 | """Test get_issue with multiple projects in the filter."""
1626 | # Setup mock response for a project that matches one of the multiple filters
1627 | mock_issue_data = {
1628 | "id": "10001",
1629 | "key": "PROD-123",
1630 | "fields": {
1631 | "summary": "Production issue",
1632 | "description": "This is a production issue",
1633 | "status": {"name": "Open"},
1634 | "issuetype": {"name": "Bug"},
1635 | },
1636 | }
1637 | issues_mixin.jira.get_issue.return_value = mock_issue_data
1638 | issues_mixin.config.url = "https://example.atlassian.net"
1639 | issues_mixin.config.projects_filter = "DEV,PROD"
1640 |
1641 | # Call the method
1642 | result = issues_mixin.get_issue("PROD-123")
1643 |
1644 | # Verify the API call was made correctly
1645 | issues_mixin.jira.get_issue.assert_called_once_with(
1646 | "PROD-123",
1647 | expand=None,
1648 | fields=ANY,
1649 | properties=None,
1650 | update_history=True,
1651 | )
1652 |
1653 | # Verify the result
1654 | assert isinstance(result, JiraIssue)
1655 | assert result.key == "PROD-123"
1656 | assert result.summary == "Production issue"
1657 |
1658 | def test_get_issue_with_whitespace_in_projects_filter(
1659 | self, issues_mixin: IssuesMixin
1660 | ):
1661 | """Test get_issue with extra whitespace in the projects filter."""
1662 | # Setup mock response for a project that matches the filter with whitespace
1663 | mock_issue_data = {
1664 | "id": "10001",
1665 | "key": "DEV-123",
1666 | "fields": {
1667 | "summary": "Development issue",
1668 | "description": "This is a development issue",
1669 | "status": {"name": "Open"},
1670 | "issuetype": {"name": "Bug"},
1671 | },
1672 | }
1673 | issues_mixin.jira.get_issue.return_value = mock_issue_data
1674 | issues_mixin.config.url = "https://example.atlassian.net"
1675 | issues_mixin.config.projects_filter = " DEV , PROD " # Extra whitespace
1676 |
1677 | # Call the method
1678 | result = issues_mixin.get_issue("DEV-123")
1679 |
1680 | # Verify the API call was made correctly
1681 | issues_mixin.jira.get_issue.assert_called_once_with(
1682 | "DEV-123",
1683 | expand=None,
1684 | fields=ANY,
1685 | properties=None,
1686 | update_history=True,
1687 | )
1688 |
1689 | # Verify the result
1690 | assert isinstance(result, JiraIssue)
1691 | assert result.key == "DEV-123"
1692 | assert result.summary == "Development issue"
1693 |
```
--------------------------------------------------------------------------------
/tests/unit/models/test_jira_models.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the Jira Pydantic models.
3 |
4 | These tests validate the conversion of Jira API responses to structured models
5 | and the simplified dictionary conversion for API responses.
6 | """
7 |
8 | import os
9 | import re
10 |
11 | import pytest
12 |
13 | from src.mcp_atlassian.models.constants import (
14 | EMPTY_STRING,
15 | JIRA_DEFAULT_ID,
16 | JIRA_DEFAULT_PROJECT,
17 | UNKNOWN,
18 | )
19 | from src.mcp_atlassian.models.jira import (
20 | JiraComment,
21 | JiraIssue,
22 | JiraIssueLink,
23 | JiraIssueLinkType,
24 | JiraIssueType,
25 | JiraLinkedIssue,
26 | JiraLinkedIssueFields,
27 | JiraPriority,
28 | JiraProject,
29 | JiraResolution,
30 | JiraSearchResult,
31 | JiraStatus,
32 | JiraStatusCategory,
33 | JiraTimetracking,
34 | JiraTransition,
35 | JiraUser,
36 | JiraWorklog,
37 | )
38 |
39 | # Optional: Import real API client for optional real-data testing
40 | try:
41 | from atlassian import Jira
42 |
43 | from src.mcp_atlassian.jira import JiraConfig, JiraFetcher
44 | from src.mcp_atlassian.jira.issues import IssuesMixin
45 | from src.mcp_atlassian.jira.projects import ProjectsMixin
46 | from src.mcp_atlassian.jira.transitions import TransitionsMixin
47 | from src.mcp_atlassian.jira.worklog import WorklogMixin
48 |
49 | real_api_available = True
50 | except ImportError:
51 | real_api_available = False
52 |
53 | # Create a module-level namespace for dummy classes
54 | class _DummyClasses:
55 | """Namespace for dummy classes when real imports fail."""
56 |
57 | class JiraFetcher:
58 | pass
59 |
60 | class JiraConfig:
61 | @staticmethod
62 | def from_env():
63 | return None
64 |
65 | class IssuesMixin:
66 | pass
67 |
68 | class ProjectsMixin:
69 | pass
70 |
71 | class TransitionsMixin:
72 | pass
73 |
74 | class WorklogMixin:
75 | pass
76 |
77 | class Jira:
78 | pass
79 |
80 | # Assign dummy classes to module namespace
81 | JiraFetcher = _DummyClasses.JiraFetcher
82 | JiraConfig = _DummyClasses.JiraConfig
83 | IssuesMixin = _DummyClasses.IssuesMixin
84 | ProjectsMixin = _DummyClasses.ProjectsMixin
85 | TransitionsMixin = _DummyClasses.TransitionsMixin
86 | WorklogMixin = _DummyClasses.WorklogMixin
87 | Jira = _DummyClasses.Jira
88 |
89 |
90 | class TestJiraUser:
91 | """Tests for the JiraUser model."""
92 |
93 | def test_from_api_response_with_valid_data(self):
94 | """Test creating a JiraUser from valid API data."""
95 | user_data = {
96 | "accountId": "user123",
97 | "displayName": "Test User",
98 | "emailAddress": "[email protected]",
99 | "active": True,
100 | "avatarUrls": {
101 | "48x48": "https://example.com/avatar.png",
102 | "24x24": "https://example.com/avatar-small.png",
103 | },
104 | "timeZone": "UTC",
105 | }
106 | user = JiraUser.from_api_response(user_data)
107 | assert user.account_id == "user123"
108 | assert user.display_name == "Test User"
109 | assert user.email == "[email protected]"
110 | assert user.active is True
111 | assert user.avatar_url == "https://example.com/avatar.png"
112 | assert user.time_zone == "UTC"
113 |
114 | def test_from_api_response_with_empty_data(self):
115 | """Test creating a JiraUser from empty data."""
116 | user = JiraUser.from_api_response({})
117 | assert user.account_id is None
118 | assert user.display_name == "Unassigned"
119 | assert user.email is None
120 | assert user.active is True
121 | assert user.avatar_url is None
122 | assert user.time_zone is None
123 |
124 | def test_from_api_response_with_none_data(self):
125 | """Test creating a JiraUser from None data."""
126 | user = JiraUser.from_api_response(None)
127 | assert user.account_id is None
128 | assert user.display_name == "Unassigned"
129 | assert user.email is None
130 | assert user.active is True
131 | assert user.avatar_url is None
132 | assert user.time_zone is None
133 |
134 | def test_to_simplified_dict(self):
135 | """Test converting JiraUser to a simplified dictionary."""
136 | user = JiraUser(
137 | account_id="user123",
138 | display_name="Test User",
139 | email="[email protected]",
140 | active=True,
141 | avatar_url="https://example.com/avatar.png",
142 | time_zone="UTC",
143 | )
144 | simplified = user.to_simplified_dict()
145 | assert isinstance(simplified, dict)
146 | assert simplified["display_name"] == "Test User"
147 | assert simplified["email"] == "[email protected]"
148 | assert simplified["avatar_url"] == "https://example.com/avatar.png"
149 | assert "account_id" not in simplified
150 | assert "time_zone" not in simplified
151 |
152 |
153 | class TestJiraStatusCategory:
154 | """Tests for the JiraStatusCategory model."""
155 |
156 | def test_from_api_response_with_valid_data(self):
157 | """Test creating a JiraStatusCategory from valid API data."""
158 | data = {
159 | "id": 4,
160 | "key": "indeterminate",
161 | "name": "In Progress",
162 | "colorName": "yellow",
163 | }
164 | category = JiraStatusCategory.from_api_response(data)
165 | assert category.id == 4
166 | assert category.key == "indeterminate"
167 | assert category.name == "In Progress"
168 | assert category.color_name == "yellow"
169 |
170 | def test_from_api_response_with_empty_data(self):
171 | """Test creating a JiraStatusCategory from empty data."""
172 | category = JiraStatusCategory.from_api_response({})
173 | assert category.id == 0
174 | assert category.key == EMPTY_STRING
175 | assert category.name == UNKNOWN
176 | assert category.color_name == EMPTY_STRING
177 |
178 |
179 | class TestJiraStatus:
180 | """Tests for the JiraStatus model."""
181 |
182 | def test_from_api_response_with_valid_data(self):
183 | """Test creating a JiraStatus from valid API data."""
184 | data = {
185 | "id": "10000",
186 | "name": "In Progress",
187 | "description": "Work is in progress",
188 | "iconUrl": "https://example.com/icon.png",
189 | "statusCategory": {
190 | "id": 4,
191 | "key": "indeterminate",
192 | "name": "In Progress",
193 | "colorName": "yellow",
194 | },
195 | }
196 | status = JiraStatus.from_api_response(data)
197 | assert status.id == "10000"
198 | assert status.name == "In Progress"
199 | assert status.description == "Work is in progress"
200 | assert status.icon_url == "https://example.com/icon.png"
201 | assert status.category is not None
202 | assert status.category.id == 4
203 | assert status.category.name == "In Progress"
204 | assert status.category.color_name == "yellow"
205 |
206 | def test_from_api_response_with_empty_data(self):
207 | """Test creating a JiraStatus from empty data."""
208 | status = JiraStatus.from_api_response({})
209 | assert status.id == JIRA_DEFAULT_ID
210 | assert status.name == UNKNOWN
211 | assert status.description is None
212 | assert status.icon_url is None
213 | assert status.category is None
214 |
215 | def test_to_simplified_dict(self):
216 | """Test converting JiraStatus to a simplified dictionary."""
217 | status = JiraStatus(
218 | id="10000",
219 | name="In Progress",
220 | description="Work is in progress",
221 | icon_url="https://example.com/icon.png",
222 | category=JiraStatusCategory(
223 | id=4, key="indeterminate", name="In Progress", color_name="yellow"
224 | ),
225 | )
226 | simplified = status.to_simplified_dict()
227 | assert isinstance(simplified, dict)
228 | assert simplified["name"] == "In Progress"
229 | assert "category" in simplified
230 | assert simplified["category"] == "In Progress"
231 | assert "color" in simplified
232 | assert simplified["color"] == "yellow"
233 | assert "description" not in simplified
234 |
235 |
236 | class TestJiraIssueType:
237 | """Tests for the JiraIssueType model."""
238 |
239 | def test_from_api_response_with_valid_data(self):
240 | """Test creating a JiraIssueType from valid API data."""
241 | data = {
242 | "id": "10000",
243 | "name": "Task",
244 | "description": "A task that needs to be done.",
245 | "iconUrl": "https://example.com/task-icon.png",
246 | }
247 | issue_type = JiraIssueType.from_api_response(data)
248 | assert issue_type.id == "10000"
249 | assert issue_type.name == "Task"
250 | assert issue_type.description == "A task that needs to be done."
251 | assert issue_type.icon_url == "https://example.com/task-icon.png"
252 |
253 | def test_from_api_response_with_empty_data(self):
254 | """Test creating a JiraIssueType from empty data."""
255 | issue_type = JiraIssueType.from_api_response({})
256 | assert issue_type.id == JIRA_DEFAULT_ID
257 | assert issue_type.name == UNKNOWN
258 | assert issue_type.description is None
259 | assert issue_type.icon_url is None
260 |
261 | def test_to_simplified_dict(self):
262 | """Test converting JiraIssueType to a simplified dictionary."""
263 | issue_type = JiraIssueType(
264 | id="10000",
265 | name="Task",
266 | description="A task that needs to be done.",
267 | icon_url="https://example.com/task-icon.png",
268 | )
269 | simplified = issue_type.to_simplified_dict()
270 | assert isinstance(simplified, dict)
271 | assert simplified["name"] == "Task"
272 | assert "id" not in simplified
273 | assert "description" not in simplified
274 | assert "icon_url" not in simplified
275 |
276 |
277 | class TestJiraPriority:
278 | """Tests for the JiraPriority model."""
279 |
280 | def test_from_api_response_with_valid_data(self):
281 | """Test creating a JiraPriority from valid API data."""
282 | data = {
283 | "id": "3",
284 | "name": "Medium",
285 | "description": "Medium priority",
286 | "iconUrl": "https://example.com/medium-priority.png",
287 | }
288 | priority = JiraPriority.from_api_response(data)
289 | assert priority.id == "3"
290 | assert priority.name == "Medium"
291 | assert priority.description == "Medium priority"
292 | assert priority.icon_url == "https://example.com/medium-priority.png"
293 |
294 | def test_from_api_response_with_empty_data(self):
295 | """Test creating a JiraPriority from empty data."""
296 | priority = JiraPriority.from_api_response({})
297 | assert priority.id == JIRA_DEFAULT_ID
298 | assert priority.name == "None" # Default for priority is 'None'
299 | assert priority.description is None
300 | assert priority.icon_url is None
301 |
302 | def test_to_simplified_dict(self):
303 | """Test converting JiraPriority to a simplified dictionary."""
304 | priority = JiraPriority(
305 | id="3",
306 | name="Medium",
307 | description="Medium priority",
308 | icon_url="https://example.com/medium-priority.png",
309 | )
310 | simplified = priority.to_simplified_dict()
311 | assert isinstance(simplified, dict)
312 | assert simplified["name"] == "Medium"
313 | assert "id" not in simplified
314 | assert "description" not in simplified
315 | assert "icon_url" not in simplified
316 |
317 |
318 | class TestJiraComment:
319 | """Tests for the JiraComment model."""
320 |
321 | def test_from_api_response_with_valid_data(self):
322 | """Test creating a JiraComment from valid API data."""
323 | data = {
324 | "id": "10000",
325 | "body": "This is a test comment",
326 | "created": "2024-01-01T12:00:00.000+0000",
327 | "updated": "2024-01-01T12:00:00.000+0000",
328 | "author": {
329 | "accountId": "user123",
330 | "displayName": "Comment User",
331 | "active": True,
332 | },
333 | }
334 | comment = JiraComment.from_api_response(data)
335 | assert comment.id == "10000"
336 | assert comment.body == "This is a test comment"
337 | assert comment.created == "2024-01-01T12:00:00.000+0000"
338 | assert comment.updated == "2024-01-01T12:00:00.000+0000"
339 | assert comment.author is not None
340 | assert comment.author.display_name == "Comment User"
341 |
342 | def test_from_api_response_with_empty_data(self):
343 | """Test creating a JiraComment from empty data."""
344 | comment = JiraComment.from_api_response({})
345 | assert comment.id == JIRA_DEFAULT_ID
346 | assert comment.body == EMPTY_STRING
347 | assert comment.created == EMPTY_STRING
348 | assert comment.updated == EMPTY_STRING
349 | assert comment.author is None
350 |
351 | def test_to_simplified_dict(self):
352 | """Test converting JiraComment to a simplified dictionary."""
353 | comment = JiraComment(
354 | id="10000",
355 | body="This is a test comment",
356 | created="2024-01-01T12:00:00.000+0000",
357 | updated="2024-01-01T12:00:00.000+0000",
358 | author=JiraUser(account_id="user123", display_name="Comment User"),
359 | )
360 | simplified = comment.to_simplified_dict()
361 | assert isinstance(simplified, dict)
362 | assert "body" in simplified
363 | assert simplified["body"] == "This is a test comment"
364 | assert "created" in simplified
365 | assert isinstance(simplified["created"], str)
366 | assert "author" in simplified
367 | assert isinstance(simplified["author"], dict)
368 | assert simplified["author"]["display_name"] == "Comment User"
369 |
370 |
371 | class TestJiraTimetracking:
372 | """Tests for the JiraTimetracking model."""
373 |
374 | def test_from_api_response_with_valid_data(self):
375 | """Test creating a JiraTimetracking from valid API data."""
376 | data = {
377 | "originalEstimate": "2h",
378 | "remainingEstimate": "1h 30m",
379 | "timeSpent": "30m",
380 | "originalEstimateSeconds": 7200,
381 | "remainingEstimateSeconds": 5400,
382 | "timeSpentSeconds": 1800,
383 | }
384 | timetracking = JiraTimetracking.from_api_response(data)
385 | assert timetracking.original_estimate == "2h"
386 | assert timetracking.remaining_estimate == "1h 30m"
387 | assert timetracking.time_spent == "30m"
388 | assert timetracking.original_estimate_seconds == 7200
389 | assert timetracking.remaining_estimate_seconds == 5400
390 | assert timetracking.time_spent_seconds == 1800
391 |
392 | def test_from_api_response_with_empty_data(self):
393 | """Test creating a JiraTimetracking from empty data."""
394 | timetracking = JiraTimetracking.from_api_response({})
395 | assert timetracking.original_estimate is None
396 | assert timetracking.remaining_estimate is None
397 | assert timetracking.time_spent is None
398 | assert timetracking.original_estimate_seconds is None
399 | assert timetracking.remaining_estimate_seconds is None
400 | assert timetracking.time_spent_seconds is None
401 |
402 | def test_from_api_response_with_none_data(self):
403 | """Test creating a JiraTimetracking from None data."""
404 | timetracking = JiraTimetracking.from_api_response(None)
405 | assert timetracking is not None
406 | assert timetracking.original_estimate is None
407 | assert timetracking.remaining_estimate is None
408 | assert timetracking.time_spent is None
409 | assert timetracking.original_estimate_seconds is None
410 | assert timetracking.remaining_estimate_seconds is None
411 | assert timetracking.time_spent_seconds is None
412 |
413 | def test_to_simplified_dict(self):
414 | """Test converting JiraTimetracking to a simplified dictionary."""
415 | timetracking = JiraTimetracking(
416 | original_estimate="2h",
417 | remaining_estimate="1h 30m",
418 | time_spent="30m",
419 | original_estimate_seconds=7200,
420 | remaining_estimate_seconds=5400,
421 | time_spent_seconds=1800,
422 | )
423 | simplified = timetracking.to_simplified_dict()
424 | assert isinstance(simplified, dict)
425 | assert simplified["original_estimate"] == "2h"
426 | assert simplified["remaining_estimate"] == "1h 30m"
427 | assert simplified["time_spent"] == "30m"
428 | assert "original_estimate_seconds" not in simplified
429 | assert "remaining_estimate_seconds" not in simplified
430 | assert "time_spent_seconds" not in simplified
431 |
432 |
433 | class TestJiraIssue:
434 | """Tests for the JiraIssue model."""
435 |
436 | def test_from_api_response_with_valid_data(self, jira_issue_data):
437 | """Test creating a JiraIssue from valid API data."""
438 | issue = JiraIssue.from_api_response(jira_issue_data)
439 |
440 | assert issue.id == "12345"
441 | assert issue.key == "PROJ-123"
442 | assert issue.summary == "Test Issue Summary"
443 | assert issue.description == "This is a test issue description"
444 | assert issue.created == "2024-01-01T10:00:00.000+0000"
445 | assert issue.updated == "2024-01-02T15:30:00.000+0000"
446 |
447 | assert issue.status is not None
448 | assert issue.status.name == "In Progress"
449 | assert issue.status.category is not None
450 | assert issue.status.category.name == "In Progress"
451 |
452 | assert issue.issue_type is not None
453 | assert issue.issue_type.name == "Task"
454 |
455 | assert issue.priority is not None
456 | assert issue.priority.name == "Medium"
457 |
458 | assert issue.assignee is not None
459 | assert issue.assignee.display_name == "Test User"
460 |
461 | assert issue.reporter is not None
462 | assert issue.reporter.display_name == "Reporter User"
463 |
464 | assert len(issue.labels) == 1
465 | assert issue.labels[0] == "test-label"
466 |
467 | assert len(issue.comments) == 1
468 | assert issue.comments[0].body == "This is a test comment"
469 |
470 | assert isinstance(issue.fix_versions, list)
471 | assert "v1.0" in issue.fix_versions
472 |
473 | assert isinstance(issue.attachments, list)
474 | assert len(issue.attachments) == 1
475 | assert issue.attachments[0].filename == "test_attachment.txt"
476 |
477 | assert isinstance(issue.timetracking, JiraTimetracking)
478 | assert issue.timetracking.original_estimate == "1d"
479 |
480 | assert issue.project is not None
481 | assert issue.project.key == "PROJ"
482 | assert issue.project.name == "Test Project"
483 | assert issue.resolution is not None
484 | assert issue.resolution.name == "Fixed"
485 | assert issue.duedate == "2024-12-31"
486 | assert issue.resolutiondate == "2024-01-15T11:00:00.000+0000"
487 | assert issue.parent is not None
488 | assert issue.parent["key"] == "PROJ-122"
489 | assert issue.subtasks is not None
490 | assert len(issue.subtasks) == 1
491 | assert issue.subtasks[0]["key"] == "PROJ-124"
492 | assert issue.security is not None
493 | assert issue.security["name"] == "Internal"
494 | assert issue.worklog is not None
495 | assert issue.worklog["total"] == 0
496 | assert issue.worklog["maxResults"] == 20
497 |
498 | # Verify custom_fields structure after from_api_response
499 | assert "customfield_10001" in issue.custom_fields
500 | assert issue.custom_fields["customfield_10001"] == {
501 | "value": "Custom Text Field Value",
502 | "name": "My Custom Text Field",
503 | }
504 | assert "customfield_10002" in issue.custom_fields
505 | assert issue.custom_fields["customfield_10002"] == {
506 | "value": {"value": "Custom Select Value"}, # Original value is a dict
507 | "name": "My Custom Select",
508 | }
509 |
510 | def test_from_api_response_with_new_fields(self):
511 | """Test creating a JiraIssue focusing on parsing the new fields."""
512 | # Construct local mock data including the new fields
513 | local_issue_data = {
514 | "id": "9999",
515 | "key": "NEW-1",
516 | "fields": {
517 | "summary": "Issue testing new fields",
518 | "project": {
519 | "id": "10001",
520 | "key": "NEWPROJ",
521 | "name": "New Project",
522 | "avatarUrls": {"48x48": "url"},
523 | },
524 | "resolution": {"id": "10002", "name": "Fixed"},
525 | "duedate": "2025-01-31",
526 | "resolutiondate": "2024-08-01T12:00:00.000+0000",
527 | "parent": {
528 | "id": "9998",
529 | "key": "NEW-0",
530 | "fields": {"summary": "Parent Task"},
531 | },
532 | "subtasks": [
533 | {"id": "10000", "key": "NEW-2", "fields": {"summary": "Subtask 1"}},
534 | {"id": "10001", "key": "NEW-3", "fields": {"summary": "Subtask 2"}},
535 | ],
536 | "security": {"id": "10003", "name": "Dev Only"},
537 | "worklog": {"total": 2, "maxResults": 20, "worklogs": []},
538 | },
539 | }
540 | issue = JiraIssue.from_api_response(local_issue_data)
541 |
542 | assert issue.id == "9999"
543 | assert issue.key == "NEW-1"
544 | assert issue.summary == "Issue testing new fields"
545 |
546 | # Assertions for new fields using LOCAL data
547 | assert isinstance(issue.project, JiraProject)
548 | assert issue.project.key == "NEWPROJ"
549 | assert issue.project.name == "New Project"
550 | assert isinstance(issue.resolution, JiraResolution)
551 | assert issue.resolution.name == "Fixed"
552 | assert issue.duedate == "2025-01-31"
553 | assert issue.resolutiondate == "2024-08-01T12:00:00.000+0000"
554 | assert isinstance(issue.parent, dict)
555 | assert issue.parent["key"] == "NEW-0"
556 | assert isinstance(issue.subtasks, list)
557 | assert len(issue.subtasks) == 2
558 | assert issue.subtasks[0]["key"] == "NEW-2"
559 | assert isinstance(issue.security, dict)
560 | assert issue.security["name"] == "Dev Only"
561 | assert isinstance(issue.worklog, dict)
562 | assert issue.worklog["total"] == 2
563 |
564 | def test_from_api_response_with_issuelinks(self, jira_issue_data):
565 | """Test creating a JiraIssue with issue links."""
566 | # Augment jira_issue_data with mock issuelinks
567 | mock_issuelinks_data = [
568 | {
569 | "id": "10000",
570 | "type": {
571 | "id": "10000",
572 | "name": "Blocks",
573 | "inward": "is blocked by",
574 | "outward": "blocks",
575 | },
576 | "outwardIssue": {
577 | "id": "10001",
578 | "key": "PROJ-789",
579 | "self": "https://example.atlassian.net/rest/api/2/issue/10001",
580 | "fields": {
581 | "summary": "Blocked Issue",
582 | "status": {"name": "Open"},
583 | "priority": {"name": "High"},
584 | "issuetype": {"name": "Task"},
585 | },
586 | },
587 | },
588 | {
589 | "id": "10001",
590 | "type": {
591 | "id": "10001",
592 | "name": "Relates to",
593 | "inward": "relates to",
594 | "outward": "relates to",
595 | },
596 | "inwardIssue": {
597 | "id": "10002",
598 | "key": "PROJ-111",
599 | "self": "https://example.atlassian.net/rest/api/2/issue/10002",
600 | "fields": {
601 | "summary": "Related Issue",
602 | "status": {"name": "In Progress"},
603 | "priority": {"name": "Medium"},
604 | "issuetype": {"name": "Story"},
605 | },
606 | },
607 | },
608 | ]
609 | jira_issue_data_with_links = jira_issue_data.copy()
610 | # Ensure fields dictionary exists
611 | if "fields" not in jira_issue_data_with_links:
612 | jira_issue_data_with_links["fields"] = {}
613 | jira_issue_data_with_links["fields"]["issuelinks"] = mock_issuelinks_data
614 |
615 | issue = JiraIssue.from_api_response(
616 | jira_issue_data_with_links, requested_fields="*all"
617 | )
618 |
619 | assert issue.issuelinks is not None
620 | assert len(issue.issuelinks) == 2
621 | assert isinstance(issue.issuelinks[0], JiraIssueLink)
622 |
623 | # Check first link (outward)
624 | assert issue.issuelinks[0].id == "10000"
625 | assert issue.issuelinks[0].type is not None
626 | assert issue.issuelinks[0].type.name == "Blocks"
627 | assert issue.issuelinks[0].outward_issue is not None
628 | assert issue.issuelinks[0].outward_issue.key == "PROJ-789"
629 | assert issue.issuelinks[0].outward_issue.fields is not None
630 | assert issue.issuelinks[0].outward_issue.fields.summary == "Blocked Issue"
631 | assert issue.issuelinks[0].inward_issue is None
632 |
633 | # Test simplified dict output
634 | simplified = issue.to_simplified_dict()
635 | assert "issuelinks" in simplified
636 | assert len(simplified["issuelinks"]) == 2
637 | assert simplified["issuelinks"][0]["type"]["name"] == "Blocks"
638 | assert simplified["issuelinks"][0]["outward_issue"]["key"] == "PROJ-789"
639 |
640 | def test_from_api_response_with_empty_data(self):
641 | """Test creating a JiraIssue from empty data."""
642 | issue = JiraIssue.from_api_response({})
643 | assert issue.id == JIRA_DEFAULT_ID
644 | assert issue.key == "UNKNOWN-0"
645 | assert issue.summary == EMPTY_STRING
646 | assert issue.description is None
647 | assert issue.created == EMPTY_STRING
648 | assert issue.updated == EMPTY_STRING
649 | assert issue.status is None
650 | assert issue.issue_type is None
651 | assert issue.priority is None
652 | assert issue.assignee is None
653 | assert issue.reporter is None
654 | assert len(issue.labels) == 0
655 | assert len(issue.comments) == 0
656 | assert issue.project is None
657 | assert issue.resolution is None
658 | assert issue.duedate is None
659 | assert issue.resolutiondate is None
660 | assert issue.parent is None
661 | assert issue.subtasks == []
662 | assert issue.security is None
663 | assert issue.worklog is None
664 |
665 | def test_to_simplified_dict(self, jira_issue_data):
666 | """Test converting a JiraIssue to a simplified dictionary."""
667 | issue = JiraIssue.from_api_response(jira_issue_data)
668 | simplified = issue.to_simplified_dict()
669 |
670 | # Essential fields from original test
671 | assert isinstance(simplified, dict)
672 | assert "key" in simplified
673 | assert simplified["key"] == "PROJ-123"
674 | assert "summary" in simplified
675 | assert simplified["summary"] == "Test Issue Summary"
676 |
677 | assert "created" in simplified
678 | assert isinstance(simplified["created"], str)
679 | assert "updated" in simplified
680 | assert isinstance(simplified["updated"], str)
681 |
682 | if isinstance(simplified["status"], str):
683 | assert simplified["status"] == "In Progress"
684 | elif isinstance(simplified["status"], dict):
685 | assert simplified["status"]["name"] == "In Progress"
686 |
687 | if isinstance(simplified["issue_type"], str):
688 | assert simplified["issue_type"] == "Task"
689 | elif isinstance(simplified["issue_type"], dict):
690 | assert simplified["issue_type"]["name"] == "Task"
691 |
692 | if isinstance(simplified["priority"], str):
693 | assert simplified["priority"] == "Medium"
694 | elif isinstance(simplified["priority"], dict):
695 | assert simplified["priority"]["name"] == "Medium"
696 |
697 | assert "assignee" in simplified
698 | assert "reporter" in simplified
699 |
700 | # Test with "*all"
701 | issue_all = JiraIssue.from_api_response(
702 | jira_issue_data, requested_fields="*all"
703 | )
704 | simplified_all = issue_all.to_simplified_dict()
705 |
706 | # Check keys for all standard fields (new and old) are present
707 | all_standard_keys = {
708 | "id",
709 | "key",
710 | "summary",
711 | "description",
712 | "created",
713 | "updated",
714 | "status",
715 | "issue_type",
716 | "priority",
717 | "assignee",
718 | "reporter",
719 | "labels",
720 | "components",
721 | "timetracking",
722 | "comments",
723 | "attachments",
724 | "url",
725 | "epic_key",
726 | "epic_name",
727 | "fix_versions",
728 | "project",
729 | "resolution",
730 | "duedate",
731 | "resolutiondate",
732 | "parent",
733 | "subtasks",
734 | "security",
735 | "worklog",
736 | # Custom fields present in the mock data should be at the root level when requesting *all
737 | "customfield_10011",
738 | "customfield_10014",
739 | "customfield_10001",
740 | "customfield_10002",
741 | "customfield_10003",
742 | }
743 | assert all_standard_keys.issubset(simplified_all.keys())
744 |
745 | # Check values for new fields based on mock data
746 | assert simplified_all["project"]["key"] == "PROJ"
747 | assert simplified_all["resolution"]["name"] == "Fixed"
748 | assert simplified_all["duedate"] == "2024-12-31"
749 | assert simplified_all["resolutiondate"] == "2024-01-15T11:00:00.000+0000"
750 | assert simplified_all["parent"]["key"] == "PROJ-122"
751 | assert len(simplified_all["subtasks"]) == 1
752 | assert simplified_all["security"]["name"] == "Internal"
753 | assert isinstance(simplified_all["worklog"], dict)
754 |
755 | requested = [
756 | "key",
757 | "summary",
758 | "project",
759 | "resolution",
760 | "subtasks",
761 | "customfield_10011",
762 | ]
763 | issue_specific = JiraIssue.from_api_response(
764 | jira_issue_data, requested_fields=requested
765 | )
766 | simplified_specific = issue_specific.to_simplified_dict()
767 |
768 | # Check the requested keys are present
769 | assert set(simplified_specific.keys()) == {
770 | "id",
771 | "key",
772 | "summary",
773 | "project",
774 | "resolution",
775 | "subtasks",
776 | "customfield_10011",
777 | }
778 |
779 | # Check values based on mock data
780 | assert simplified_specific["project"]["key"] == "PROJ"
781 | assert simplified_specific["resolution"]["name"] == "Fixed"
782 | assert len(simplified_specific["subtasks"]) == 1
783 | # Check custom field output
784 | assert (
785 | simplified_specific["customfield_10011"]
786 | == {
787 | "value": "Epic Name Example",
788 | "name": "Epic Name", # Comes from the "names" map in MOCK_JIRA_ISSUE_RESPONSE
789 | }
790 | )
791 |
792 | def test_find_custom_field_in_api_response(self):
793 | """Test the _find_custom_field_in_api_response method with different field patterns."""
794 | fields = {
795 | "customfield_10014": "EPIC-123",
796 | "customfield_10011": "Epic Name Test",
797 | "customfield_10000": "Another value",
798 | "schema": {
799 | "fields": {
800 | "customfield_10014": {"name": "Epic Link", "type": "string"},
801 | "customfield_10011": {"name": "Epic Name", "type": "string"},
802 | "customfield_10000": {"name": "Custom Field", "type": "string"},
803 | }
804 | },
805 | }
806 |
807 | result = JiraIssue._find_custom_field_in_api_response(fields, ["Epic Link"])
808 | assert result == "EPIC-123"
809 |
810 | result = JiraIssue._find_custom_field_in_api_response(fields, ["Epic Name"])
811 | assert result == "Epic Name Test"
812 |
813 | result = JiraIssue._find_custom_field_in_api_response(fields, ["epic link"])
814 | assert result == "EPIC-123"
815 |
816 | result = JiraIssue._find_custom_field_in_api_response(
817 | fields, ["epic-link", "epiclink"]
818 | )
819 | assert result == "EPIC-123"
820 |
821 | result = JiraIssue._find_custom_field_in_api_response(
822 | fields, ["Non Existent Field"]
823 | )
824 | assert result is None
825 |
826 | result = JiraIssue._find_custom_field_in_api_response({}, ["Epic Link"])
827 | assert result is None
828 |
829 | result = JiraIssue._find_custom_field_in_api_response(None, ["Epic Link"])
830 | assert result is None
831 |
832 | def test_epic_field_extraction_different_field_ids(self):
833 | """Test finding epic fields with different customfield IDs."""
834 | test_data = {
835 | "id": "12345",
836 | "key": "PROJ-123",
837 | "fields": {
838 | "summary": "Test Issue",
839 | "customfield_20100": "EPIC-456",
840 | "customfield_20200": "My Epic Name",
841 | "schema": {
842 | "fields": {
843 | "customfield_20100": {"name": "Epic Link", "type": "string"},
844 | "customfield_20200": {"name": "Epic Name", "type": "string"},
845 | }
846 | },
847 | },
848 | }
849 | issue = JiraIssue.from_api_response(test_data)
850 | assert issue.epic_key == "EPIC-456"
851 | assert issue.epic_name == "My Epic Name"
852 |
853 | def test_epic_field_extraction_fallback(self):
854 | """Test using common field names without relying on metadata."""
855 | test_data = {
856 | "id": "12345",
857 | "key": "PROJ-123",
858 | "fields": {
859 | "summary": "Test Issue",
860 | "customfield_10014": "EPIC-456",
861 | "customfield_10011": "My Epic Name",
862 | },
863 | }
864 |
865 | original_method = JiraIssue._find_custom_field_in_api_response
866 | try:
867 |
868 | def mocked_find_field(fields, name_patterns):
869 | normalized_patterns = []
870 | for pattern in name_patterns:
871 | norm_pattern = pattern.lower()
872 | norm_pattern = re.sub(r"[_\-\s]", "", norm_pattern)
873 | normalized_patterns.append(norm_pattern)
874 |
875 | if any("epiclink" in p for p in normalized_patterns):
876 | return fields.get("customfield_10014")
877 | if any("epicname" in p for p in normalized_patterns):
878 | return fields.get("customfield_10011")
879 | return None
880 |
881 | JiraIssue._find_custom_field_in_api_response = staticmethod(
882 | mocked_find_field
883 | )
884 |
885 | issue = JiraIssue.from_api_response(test_data)
886 | assert issue.epic_key == "EPIC-456"
887 | assert issue.epic_name == "My Epic Name"
888 | finally:
889 | JiraIssue._find_custom_field_in_api_response = staticmethod(original_method)
890 |
891 | def test_epic_field_extraction_advanced_patterns(self):
892 | """Test finding epic fields using various naming patterns."""
893 | test_data = {
894 | "id": "12345",
895 | "key": "PROJ-123",
896 | "fields": {
897 | "summary": "Test Issue",
898 | "customfield_12345": "EPIC-456",
899 | "customfield_67890": "Epic Name Value",
900 | "schema": {
901 | "fields": {
902 | "customfield_12345": {
903 | "name": "Epic-Link Field",
904 | "type": "string",
905 | },
906 | "customfield_67890": {"name": "EpicName", "type": "string"},
907 | }
908 | },
909 | },
910 | }
911 | issue = JiraIssue.from_api_response(test_data)
912 | assert issue.epic_key == "EPIC-456"
913 | assert issue.epic_name == "Epic Name Value"
914 |
915 | def test_fields_with_names(self):
916 | """Test using the names to find fields."""
917 |
918 | fields = {
919 | "customfield_55555": "EPIC-789",
920 | "customfield_66666": "Special Epic Name",
921 | "names": {
922 | "customfield_55555": "Epic Link",
923 | "customfield_66666": "Epic Name",
924 | },
925 | }
926 |
927 | result = JiraIssue._find_custom_field_in_api_response(fields, ["Epic Link"])
928 | assert result == "EPIC-789"
929 |
930 | test_data = {"id": "12345", "key": "PROJ-123", "fields": fields}
931 | issue = JiraIssue.from_api_response(test_data)
932 | assert issue.epic_key == "EPIC-789"
933 | assert issue.epic_name == "Special Epic Name"
934 |
935 | def test_jira_issue_with_custom_fields(self, jira_issue_data):
936 | """Test JiraIssue handling of custom fields."""
937 | issue = JiraIssue.from_api_response(jira_issue_data)
938 | simplified = issue.to_simplified_dict()
939 | assert simplified["key"] == "PROJ-123"
940 | assert simplified["summary"] == "Test Issue Summary"
941 | # By default (no requested_fields or default set), custom fields are not included
942 | # unless they are part of DEFAULT_READ_JIRA_FIELDS (which they are not).
943 | # So, this assertion should be that they are NOT present.
944 | assert "customfield_10001" not in simplified
945 | assert "customfield_10002" not in simplified
946 | assert "customfield_10003" not in simplified
947 |
948 | issue = JiraIssue.from_api_response(
949 | jira_issue_data, requested_fields="summary,customfield_10001"
950 | )
951 | simplified = issue.to_simplified_dict()
952 | assert "key" in simplified
953 | assert "summary" in simplified
954 | assert "customfield_10001" in simplified
955 | assert simplified["customfield_10001"]["value"] == "Custom Text Field Value"
956 | assert simplified["customfield_10001"]["name"] == "My Custom Text Field"
957 | assert "customfield_10002" not in simplified
958 |
959 | issue = JiraIssue.from_api_response(
960 | jira_issue_data, requested_fields=["key", "customfield_10002"]
961 | )
962 | simplified = issue.to_simplified_dict()
963 | assert "key" in simplified
964 | assert "customfield_10002" in simplified
965 | assert "summary" not in simplified
966 | assert "customfield_10001" not in simplified
967 | assert simplified["customfield_10002"]["value"] == "Custom Select Value"
968 | assert simplified["customfield_10002"]["name"] == "My Custom Select"
969 |
970 | issue = JiraIssue.from_api_response(jira_issue_data, requested_fields="*all")
971 | simplified = issue.to_simplified_dict()
972 | assert "key" in simplified
973 | assert "summary" in simplified
974 | assert "customfield_10001" in simplified
975 | assert simplified["customfield_10001"]["value"] == "Custom Text Field Value"
976 | assert simplified["customfield_10001"]["name"] == "My Custom Text Field"
977 | assert "customfield_10002" in simplified
978 | assert simplified["customfield_10002"]["value"] == "Custom Select Value"
979 | assert simplified["customfield_10002"]["name"] == "My Custom Select"
980 | assert "customfield_10003" in simplified
981 |
982 | issue_specific = JiraIssue.from_api_response(
983 | jira_issue_data, requested_fields="key,customfield_10014"
984 | )
985 | simplified_specific = issue_specific.to_simplified_dict()
986 | assert "customfield_10014" in simplified_specific
987 | assert simplified_specific.get("customfield_10014") == {
988 | "value": "EPIC-KEY-1",
989 | "name": "Epic Link",
990 | }
991 |
992 | def test_jira_issue_with_default_fields(self, jira_issue_data):
993 | """Test that JiraIssue returns only essential fields by default."""
994 | issue = JiraIssue.from_api_response(jira_issue_data)
995 | simplified = issue.to_simplified_dict()
996 | # Check essential fields ARE present
997 | essential_keys = {
998 | "id",
999 | "key",
1000 | "summary",
1001 | "url",
1002 | "description",
1003 | "status",
1004 | "issue_type",
1005 | "priority",
1006 | "project",
1007 | "resolution",
1008 | "duedate",
1009 | "resolutiondate",
1010 | "parent",
1011 | "subtasks",
1012 | "security",
1013 | "worklog",
1014 | "assignee",
1015 | "reporter",
1016 | "labels",
1017 | "components",
1018 | "fix_versions",
1019 | "epic_key",
1020 | "epic_name",
1021 | "timetracking",
1022 | "created",
1023 | "updated",
1024 | "comments",
1025 | "attachments",
1026 | }
1027 | # We check if the key is present; value might be None if not in source data
1028 | for key in essential_keys:
1029 | assert key in simplified, (
1030 | f"Essential key '{key}' missing from default simplified dict"
1031 | )
1032 | assert "customfield_10001" not in simplified
1033 | assert "customfield_10002" not in simplified
1034 |
1035 | issue = JiraIssue.from_api_response(jira_issue_data, requested_fields="*all")
1036 | simplified = issue.to_simplified_dict()
1037 | assert "customfield_10001" in simplified
1038 | assert "customfield_10002" in simplified
1039 |
1040 | def test_timetracking_field_processing(self, jira_issue_data):
1041 | """Test that timetracking data is properly processed."""
1042 | issue = JiraIssue.from_api_response(jira_issue_data)
1043 | assert issue.timetracking is not None
1044 | assert issue.timetracking.original_estimate == "1d"
1045 | assert issue.timetracking.remaining_estimate == "4h"
1046 | assert issue.timetracking.time_spent == "4h"
1047 | assert issue.timetracking.original_estimate_seconds == 28800
1048 | assert issue.timetracking.remaining_estimate_seconds == 14400
1049 | assert issue.timetracking.time_spent_seconds == 14400
1050 |
1051 | issue.requested_fields = "*all"
1052 | simplified = issue.to_simplified_dict()
1053 | assert "timetracking" in simplified
1054 | assert simplified["timetracking"]["original_estimate"] == "1d"
1055 |
1056 | issue.requested_fields = ["summary", "timetracking"]
1057 | simplified = issue.to_simplified_dict()
1058 | assert "timetracking" in simplified
1059 | assert simplified["timetracking"]["original_estimate"] == "1d"
1060 |
1061 |
1062 | class TestJiraSearchResult:
1063 | """Tests for the JiraSearchResult model."""
1064 |
1065 | def test_from_api_response_with_valid_data(self, jira_search_data):
1066 | """Test creating a JiraSearchResult from valid API data."""
1067 | search_result = JiraSearchResult.from_api_response(jira_search_data)
1068 | assert search_result.total == 34
1069 | assert search_result.start_at == 0
1070 | assert search_result.max_results == 5
1071 | assert len(search_result.issues) == 1
1072 |
1073 | issue = search_result.issues[0]
1074 | assert isinstance(issue, JiraIssue)
1075 | assert issue.key == "PROJ-123"
1076 | assert issue.summary == "Test Issue Summary"
1077 |
1078 | def test_from_api_response_with_empty_data(self):
1079 | """Test creating a JiraSearchResult from empty data."""
1080 | result = JiraSearchResult.from_api_response({})
1081 | assert result.total == 0
1082 | assert result.start_at == 0
1083 | assert result.max_results == 0
1084 | assert result.issues == []
1085 |
1086 | def test_from_api_response_missing_metadata(self, jira_search_data):
1087 | """Test creating a JiraSearchResult when API is missing metadata."""
1088 | # Remove total, startAt, maxResults from mock data
1089 | api_data = dict(jira_search_data)
1090 | api_data.pop("total", None)
1091 | api_data.pop("startAt", None)
1092 | api_data.pop("maxResults", None)
1093 |
1094 | search_result = JiraSearchResult.from_api_response(api_data)
1095 | # Verify that -1 is used for missing metadata
1096 | assert search_result.total == -1
1097 | assert search_result.start_at == -1
1098 | assert search_result.max_results == -1
1099 | assert len(search_result.issues) == 1 # Assuming mock data has issues
1100 |
1101 | def test_to_simplified_dict(self, jira_search_data):
1102 | """Test converting JiraSearchResult to a simplified dictionary."""
1103 | search_result = JiraSearchResult.from_api_response(jira_search_data)
1104 | simplified = search_result.to_simplified_dict()
1105 |
1106 | # Verify the structure and basic metadata
1107 | assert isinstance(simplified, dict)
1108 | assert "total" in simplified
1109 | assert "start_at" in simplified
1110 | assert "max_results" in simplified
1111 | assert "issues" in simplified
1112 |
1113 | # Verify metadata values
1114 | assert simplified["total"] == 34
1115 | assert simplified["start_at"] == 0
1116 | assert simplified["max_results"] == 5
1117 |
1118 | # Verify issues array
1119 | assert isinstance(simplified["issues"], list)
1120 | assert len(simplified["issues"]) == 1
1121 |
1122 | # Verify that each issue is a simplified dict (not a JiraIssue object)
1123 | issue = simplified["issues"][0]
1124 | assert isinstance(issue, dict)
1125 | assert issue["key"] == "PROJ-123"
1126 | assert issue["summary"] == "Test Issue Summary"
1127 |
1128 | # Verify that the issues are properly simplified (calling to_simplified_dict on each)
1129 | # This ensures field filtering works properly
1130 | assert "id" in issue # ID is included in simplified version
1131 | assert "expand" not in issue # Should be filtered out in simplified version
1132 |
1133 | # Verify that issue contains expected fields
1134 | assert "assignee" in issue
1135 | assert "created" in issue
1136 | assert "updated" in issue
1137 |
1138 | def test_to_simplified_dict_empty_result(self):
1139 | """Test converting an empty JiraSearchResult to a simplified dictionary."""
1140 | search_result = JiraSearchResult()
1141 | simplified = search_result.to_simplified_dict()
1142 |
1143 | assert isinstance(simplified, dict)
1144 | assert simplified["total"] == 0
1145 | assert simplified["start_at"] == 0
1146 | assert simplified["max_results"] == 0
1147 | assert simplified["issues"] == []
1148 |
1149 | def test_to_simplified_dict_with_multiple_issues(self):
1150 | """Test converting JiraSearchResult with multiple issues to a simplified dictionary."""
1151 | # Create mock data with multiple issues
1152 | mock_data = {
1153 | "total": 2,
1154 | "startAt": 0,
1155 | "maxResults": 10,
1156 | "issues": [
1157 | {
1158 | "id": "12345",
1159 | "key": "PROJ-123",
1160 | "fields": {
1161 | "summary": "First Issue",
1162 | "status": {"name": "In Progress"},
1163 | },
1164 | },
1165 | {
1166 | "id": "12346",
1167 | "key": "PROJ-124",
1168 | "fields": {
1169 | "summary": "Second Issue",
1170 | "status": {"name": "Done"},
1171 | },
1172 | },
1173 | ],
1174 | }
1175 |
1176 | search_result = JiraSearchResult.from_api_response(mock_data)
1177 | simplified = search_result.to_simplified_dict()
1178 |
1179 | # Verify metadata
1180 | assert simplified["total"] == 2
1181 | assert simplified["start_at"] == 0
1182 | assert simplified["max_results"] == 10
1183 |
1184 | # Verify issues
1185 | assert len(simplified["issues"]) == 2
1186 | assert simplified["issues"][0]["key"] == "PROJ-123"
1187 | assert simplified["issues"][0]["summary"] == "First Issue"
1188 | assert simplified["issues"][1]["key"] == "PROJ-124"
1189 | assert simplified["issues"][1]["summary"] == "Second Issue"
1190 |
1191 |
1192 | class TestJiraProject:
1193 | """Tests for the JiraProject model."""
1194 |
1195 | def test_from_api_response_with_valid_data(self):
1196 | """Test creating a JiraProject from valid API data."""
1197 | project_data = {
1198 | "id": "10000",
1199 | "key": "TEST",
1200 | "name": "Test Project",
1201 | "description": "This is a test project",
1202 | "lead": {
1203 | "accountId": "5b10a2844c20165700ede21g",
1204 | "displayName": "John Doe",
1205 | "active": True,
1206 | },
1207 | "self": "https://example.atlassian.net/rest/api/3/project/10000",
1208 | "projectCategory": {
1209 | "id": "10100",
1210 | "name": "Software Projects",
1211 | "description": "Software development projects",
1212 | },
1213 | "avatarUrls": {
1214 | "48x48": "https://example.atlassian.net/secure/projectavatar?pid=10000&avatarId=10011",
1215 | "24x24": "https://example.atlassian.net/secure/projectavatar?pid=10000&size=small&avatarId=10011",
1216 | },
1217 | }
1218 | project = JiraProject.from_api_response(project_data)
1219 | assert project.id == "10000"
1220 | assert project.key == "TEST"
1221 | assert project.name == "Test Project"
1222 | assert project.description == "This is a test project"
1223 | assert project.lead is not None
1224 | assert project.lead.display_name == "John Doe"
1225 | assert project.url == "https://example.atlassian.net/rest/api/3/project/10000"
1226 | assert project.category_name == "Software Projects"
1227 | assert (
1228 | project.avatar_url
1229 | == "https://example.atlassian.net/secure/projectavatar?pid=10000&avatarId=10011"
1230 | )
1231 |
1232 | def test_from_api_response_with_empty_data(self):
1233 | """Test creating a JiraProject from empty data."""
1234 | project = JiraProject.from_api_response({})
1235 | assert project.id == JIRA_DEFAULT_PROJECT
1236 | assert project.key == EMPTY_STRING
1237 | assert project.name == UNKNOWN
1238 | assert project.description is None
1239 | assert project.lead is None
1240 | assert project.url is None
1241 | assert project.category_name is None
1242 | assert project.avatar_url is None
1243 |
1244 | def test_to_simplified_dict(self):
1245 | """Test converting a JiraProject to a simplified dictionary."""
1246 | project_data = {
1247 | "id": "10000",
1248 | "key": "TEST",
1249 | "name": "Test Project",
1250 | "description": "This is a test project",
1251 | "lead": {
1252 | "accountId": "5b10a2844c20165700ede21g",
1253 | "displayName": "John Doe",
1254 | "active": True,
1255 | },
1256 | "self": "https://example.atlassian.net/rest/api/3/project/10000",
1257 | "projectCategory": {
1258 | "name": "Software Projects",
1259 | },
1260 | }
1261 | project = JiraProject.from_api_response(project_data)
1262 | simplified = project.to_simplified_dict()
1263 | assert simplified["key"] == "TEST"
1264 | assert simplified["name"] == "Test Project"
1265 | assert simplified["description"] == "This is a test project"
1266 | assert simplified["lead"] is not None
1267 | assert simplified["lead"]["display_name"] == "John Doe"
1268 | assert simplified["category"] == "Software Projects"
1269 | assert "id" not in simplified
1270 | assert "url" not in simplified
1271 | assert "avatar_url" not in simplified
1272 |
1273 |
1274 | class TestJiraTransition:
1275 | """Tests for the JiraTransition model."""
1276 |
1277 | def test_from_api_response_with_valid_data(self):
1278 | """Test creating a JiraTransition from valid API data."""
1279 | transition_data = {
1280 | "id": "10",
1281 | "name": "Start Progress",
1282 | "to": {
1283 | "id": "3",
1284 | "name": "In Progress",
1285 | "statusCategory": {
1286 | "id": 4,
1287 | "key": "indeterminate",
1288 | "name": "In Progress",
1289 | "colorName": "yellow",
1290 | },
1291 | },
1292 | "hasScreen": True,
1293 | "isGlobal": False,
1294 | "isInitial": False,
1295 | "isConditional": True,
1296 | }
1297 | transition = JiraTransition.from_api_response(transition_data)
1298 | assert transition.id == "10"
1299 | assert transition.name == "Start Progress"
1300 | assert transition.to_status is not None
1301 | assert transition.to_status.id == "3"
1302 | assert transition.to_status.name == "In Progress"
1303 | assert transition.to_status.category is not None
1304 | assert transition.to_status.category.name == "In Progress"
1305 | assert transition.has_screen is True
1306 | assert transition.is_global is False
1307 | assert transition.is_initial is False
1308 | assert transition.is_conditional is True
1309 |
1310 | def test_from_api_response_with_empty_data(self):
1311 | """Test creating a JiraTransition from empty data."""
1312 | transition = JiraTransition.from_api_response({})
1313 | assert transition.id == JIRA_DEFAULT_ID
1314 | assert transition.name == EMPTY_STRING
1315 | assert transition.to_status is None
1316 | assert transition.has_screen is False
1317 | assert transition.is_global is False
1318 | assert transition.is_initial is False
1319 | assert transition.is_conditional is False
1320 |
1321 | def test_to_simplified_dict(self):
1322 | """Test converting a JiraTransition to a simplified dictionary."""
1323 | transition_data = {
1324 | "id": "10",
1325 | "name": "Start Progress",
1326 | "to": {
1327 | "id": "3",
1328 | "name": "In Progress",
1329 | "statusCategory": {
1330 | "id": 4,
1331 | "key": "indeterminate",
1332 | "name": "In Progress",
1333 | "colorName": "yellow",
1334 | },
1335 | },
1336 | "hasScreen": True,
1337 | }
1338 | transition = JiraTransition.from_api_response(transition_data)
1339 | simplified = transition.to_simplified_dict()
1340 | assert simplified["id"] == "10"
1341 | assert simplified["name"] == "Start Progress"
1342 | assert simplified["to_status"] is not None
1343 | assert simplified["to_status"]["name"] == "In Progress"
1344 | assert "has_screen" not in simplified
1345 | assert "is_global" not in simplified
1346 |
1347 |
1348 | class TestJiraIssueLinkType:
1349 | """Tests for the JiraIssueLinkType model."""
1350 |
1351 | def test_from_api_response_with_valid_data(self):
1352 | """Test creating a JiraIssueLinkType from valid API data."""
1353 | data = {
1354 | "id": "10001",
1355 | "name": "Blocks",
1356 | "inward": "is blocked by",
1357 | "outward": "blocks",
1358 | "self": "https://example.atlassian.net/rest/api/3/issueLinkType/10001",
1359 | }
1360 | link_type = JiraIssueLinkType.from_api_response(data)
1361 | assert link_type.id == "10001"
1362 | assert link_type.name == "Blocks"
1363 | assert link_type.inward == "is blocked by"
1364 | assert link_type.outward == "blocks"
1365 | assert (
1366 | link_type.self_url
1367 | == "https://example.atlassian.net/rest/api/3/issueLinkType/10001"
1368 | )
1369 |
1370 | def test_from_api_response_with_empty_data(self):
1371 | """Test creating a JiraIssueLinkType from empty data."""
1372 | link_type = JiraIssueLinkType.from_api_response({})
1373 | assert link_type.id == JIRA_DEFAULT_ID
1374 | assert link_type.name == UNKNOWN
1375 | assert link_type.inward == EMPTY_STRING
1376 | assert link_type.outward == EMPTY_STRING
1377 | assert link_type.self_url is None
1378 |
1379 | def test_from_api_response_with_none_data(self):
1380 | """Test creating a JiraIssueLinkType from None data."""
1381 | link_type = JiraIssueLinkType.from_api_response(None)
1382 | assert link_type.id == JIRA_DEFAULT_ID
1383 | assert link_type.name == UNKNOWN
1384 | assert link_type.inward == EMPTY_STRING
1385 | assert link_type.outward == EMPTY_STRING
1386 | assert link_type.self_url is None
1387 |
1388 | def test_to_simplified_dict(self):
1389 | """Test converting JiraIssueLinkType to a simplified dictionary."""
1390 | link_type = JiraIssueLinkType(
1391 | id="10001",
1392 | name="Blocks",
1393 | inward="is blocked by",
1394 | outward="blocks",
1395 | self_url="https://example.atlassian.net/rest/api/3/issueLinkType/10001",
1396 | )
1397 | simplified = link_type.to_simplified_dict()
1398 | assert isinstance(simplified, dict)
1399 | assert simplified["id"] == "10001"
1400 | assert simplified["name"] == "Blocks"
1401 | assert simplified["inward"] == "is blocked by"
1402 | assert simplified["outward"] == "blocks"
1403 | assert "self" in simplified
1404 | assert (
1405 | simplified["self"]
1406 | == "https://example.atlassian.net/rest/api/3/issueLinkType/10001"
1407 | )
1408 |
1409 |
1410 | class TestJiraLinkedIssueFields:
1411 | """Tests for the JiraLinkedIssueFields model."""
1412 |
1413 | def test_from_api_response_with_valid_data(self):
1414 | """Test creating a JiraLinkedIssueFields from valid API data."""
1415 | data = {
1416 | "summary": "Linked Issue Summary",
1417 | "status": {
1418 | "id": "10000",
1419 | "name": "In Progress",
1420 | "statusCategory": {
1421 | "id": 4,
1422 | "key": "indeterminate",
1423 | "name": "In Progress",
1424 | "colorName": "yellow",
1425 | },
1426 | },
1427 | "priority": {
1428 | "id": "3",
1429 | "name": "Medium",
1430 | "description": "Medium priority",
1431 | "iconUrl": "https://example.com/medium-priority.png",
1432 | },
1433 | "issuetype": {
1434 | "id": "10000",
1435 | "name": "Task",
1436 | "description": "A task that needs to be done.",
1437 | "iconUrl": "https://example.com/task-icon.png",
1438 | },
1439 | }
1440 | fields = JiraLinkedIssueFields.from_api_response(data)
1441 | assert fields.summary == "Linked Issue Summary"
1442 | assert fields.status is not None
1443 | assert fields.status.name == "In Progress"
1444 | assert fields.priority is not None
1445 | assert fields.priority.name == "Medium"
1446 | assert fields.issuetype is not None
1447 | assert fields.issuetype.name == "Task"
1448 |
1449 | def test_from_api_response_with_empty_data(self):
1450 | """Test creating a JiraLinkedIssueFields from empty data."""
1451 | fields = JiraLinkedIssueFields.from_api_response({})
1452 | assert fields.summary == EMPTY_STRING
1453 | assert fields.status is None
1454 | assert fields.priority is None
1455 | assert fields.issuetype is None
1456 |
1457 | def test_to_simplified_dict(self):
1458 | """Test converting JiraLinkedIssueFields to a simplified dictionary."""
1459 | fields = JiraLinkedIssueFields(
1460 | summary="Linked Issue Summary",
1461 | status=JiraStatus(name="In Progress"),
1462 | priority=JiraPriority(name="Medium"),
1463 | issuetype=JiraIssueType(name="Task"),
1464 | )
1465 | simplified = fields.to_simplified_dict()
1466 | assert simplified["summary"] == "Linked Issue Summary"
1467 | assert simplified["status"]["name"] == "In Progress"
1468 | assert simplified["priority"]["name"] == "Medium"
1469 | assert simplified["issuetype"]["name"] == "Task"
1470 |
1471 |
1472 | class TestJiraLinkedIssue:
1473 | """Tests for the JiraLinkedIssue model."""
1474 |
1475 | def test_from_api_response_with_valid_data(self):
1476 | """Test creating a JiraLinkedIssue from valid API data."""
1477 | data = {
1478 | "id": "10001",
1479 | "key": "PROJ-456",
1480 | "self": "https://example.atlassian.net/rest/api/2/issue/10001",
1481 | "fields": {
1482 | "summary": "Linked Issue Summary",
1483 | "status": {
1484 | "id": "10000",
1485 | "name": "In Progress",
1486 | },
1487 | "priority": {
1488 | "id": "3",
1489 | "name": "Medium",
1490 | },
1491 | "issuetype": {
1492 | "id": "10000",
1493 | "name": "Task",
1494 | },
1495 | },
1496 | }
1497 | linked_issue = JiraLinkedIssue.from_api_response(data)
1498 | assert linked_issue.id == "10001"
1499 | assert linked_issue.key == "PROJ-456"
1500 | assert (
1501 | linked_issue.self_url
1502 | == "https://example.atlassian.net/rest/api/2/issue/10001"
1503 | )
1504 | assert linked_issue.fields is not None
1505 | assert linked_issue.fields.summary == "Linked Issue Summary"
1506 | assert linked_issue.fields.status is not None
1507 | assert linked_issue.fields.status.name == "In Progress"
1508 |
1509 | def test_from_api_response_with_empty_data(self):
1510 | """Test creating a JiraLinkedIssue from empty data."""
1511 | linked_issue = JiraLinkedIssue.from_api_response({})
1512 | assert linked_issue.id == JIRA_DEFAULT_ID
1513 | assert linked_issue.key == EMPTY_STRING
1514 | assert linked_issue.self_url is None
1515 | assert linked_issue.fields is None
1516 |
1517 | def test_to_simplified_dict(self):
1518 | """Test converting JiraLinkedIssue to a simplified dictionary."""
1519 | linked_issue = JiraLinkedIssue(
1520 | id="10001",
1521 | key="PROJ-456",
1522 | self_url="https://example.atlassian.net/rest/api/2/issue/10001",
1523 | fields=JiraLinkedIssueFields(
1524 | summary="Linked Issue Summary",
1525 | status=JiraStatus(name="In Progress"),
1526 | priority=JiraPriority(name="Medium"),
1527 | issuetype=JiraIssueType(name="Task"),
1528 | ),
1529 | )
1530 | simplified = linked_issue.to_simplified_dict()
1531 | assert simplified["id"] == "10001"
1532 | assert simplified["key"] == "PROJ-456"
1533 | assert (
1534 | simplified["self"] == "https://example.atlassian.net/rest/api/2/issue/10001"
1535 | )
1536 | assert simplified["fields"]["summary"] == "Linked Issue Summary"
1537 | assert simplified["fields"]["status"]["name"] == "In Progress"
1538 |
1539 |
1540 | class TestJiraIssueLink:
1541 | """Tests for the JiraIssueLink model."""
1542 |
1543 | def test_from_api_response_with_valid_data(self):
1544 | """Test creating a JiraIssueLink from valid API data."""
1545 | data = {
1546 | "id": "10001",
1547 | "type": {
1548 | "id": "10000",
1549 | "name": "Blocks",
1550 | "inward": "is blocked by",
1551 | "outward": "blocks",
1552 | "self": "https://example.atlassian.net/rest/api/2/issueLinkType/10000",
1553 | },
1554 | "inwardIssue": {
1555 | "id": "10002",
1556 | "key": "PROJ-789",
1557 | "self": "https://example.atlassian.net/rest/api/2/issue/10002",
1558 | "fields": {
1559 | "summary": "Inward Issue Summary",
1560 | "status": {
1561 | "id": "10000",
1562 | "name": "In Progress",
1563 | },
1564 | },
1565 | },
1566 | }
1567 | issue_link = JiraIssueLink.from_api_response(data)
1568 | assert issue_link.id == "10001"
1569 | assert issue_link.type is not None
1570 | assert issue_link.type.name == "Blocks"
1571 | assert issue_link.inward_issue is not None
1572 | assert issue_link.inward_issue.key == "PROJ-789"
1573 | assert issue_link.outward_issue is None
1574 |
1575 | def test_from_api_response_with_outward_issue(self):
1576 | """Test creating a JiraIssueLink with an outward issue."""
1577 | data = {
1578 | "id": "10001",
1579 | "type": {
1580 | "id": "10000",
1581 | "name": "Blocks",
1582 | "inward": "is blocked by",
1583 | "outward": "blocks",
1584 | },
1585 | "outwardIssue": {
1586 | "id": "10003",
1587 | "key": "PROJ-101",
1588 | "fields": {
1589 | "summary": "Outward Issue Summary",
1590 | "status": {
1591 | "id": "10000",
1592 | "name": "In Progress",
1593 | },
1594 | },
1595 | },
1596 | }
1597 | issue_link = JiraIssueLink.from_api_response(data)
1598 | assert issue_link.id == "10001"
1599 | assert issue_link.type is not None
1600 | assert issue_link.type.name == "Blocks"
1601 | assert issue_link.inward_issue is None
1602 | assert issue_link.outward_issue is not None
1603 | assert issue_link.outward_issue.key == "PROJ-101"
1604 |
1605 | def test_from_api_response_with_empty_data(self):
1606 | """Test creating a JiraIssueLink from empty data."""
1607 | issue_link = JiraIssueLink.from_api_response({})
1608 | assert issue_link.id == JIRA_DEFAULT_ID
1609 | assert issue_link.type is None
1610 | assert issue_link.inward_issue is None
1611 | assert issue_link.outward_issue is None
1612 |
1613 | def test_to_simplified_dict(self):
1614 | """Test converting JiraIssueLink to a simplified dictionary."""
1615 | issue_link = JiraIssueLink(
1616 | id="10001",
1617 | type=JiraIssueLinkType(
1618 | id="10000",
1619 | name="Blocks",
1620 | inward="is blocked by",
1621 | outward="blocks",
1622 | ),
1623 | inward_issue=JiraLinkedIssue(
1624 | id="10002",
1625 | key="PROJ-789",
1626 | fields=JiraLinkedIssueFields(
1627 | summary="Inward Issue Summary",
1628 | status=JiraStatus(name="In Progress"),
1629 | ),
1630 | ),
1631 | )
1632 | simplified = issue_link.to_simplified_dict()
1633 | assert simplified["id"] == "10001"
1634 | assert simplified["type"]["name"] == "Blocks"
1635 | assert simplified["inward_issue"]["key"] == "PROJ-789"
1636 | assert "outward_issue" not in simplified
1637 |
1638 |
1639 | class TestJiraWorklog:
1640 | """Tests for the JiraWorklog model."""
1641 |
1642 | def test_from_api_response_with_valid_data(self):
1643 | """Test creating a JiraWorklog from valid API data."""
1644 | worklog_data = {
1645 | "id": "100023",
1646 | "author": {
1647 | "accountId": "5b10a2844c20165700ede21g",
1648 | "displayName": "John Doe",
1649 | "active": True,
1650 | },
1651 | "comment": "Worked on the issue today",
1652 | "created": "2023-05-01T10:00:00.000+0000",
1653 | "updated": "2023-05-01T10:30:00.000+0000",
1654 | "started": "2023-05-01T09:00:00.000+0000",
1655 | "timeSpent": "2h 30m",
1656 | "timeSpentSeconds": 9000,
1657 | }
1658 | worklog = JiraWorklog.from_api_response(worklog_data)
1659 | assert worklog.id == "100023"
1660 | assert worklog.author is not None
1661 | assert worklog.author.display_name == "John Doe"
1662 | assert worklog.comment == "Worked on the issue today"
1663 | assert worklog.created == "2023-05-01T10:00:00.000+0000"
1664 | assert worklog.updated == "2023-05-01T10:30:00.000+0000"
1665 | assert worklog.started == "2023-05-01T09:00:00.000+0000"
1666 | assert worklog.time_spent == "2h 30m"
1667 | assert worklog.time_spent_seconds == 9000
1668 |
1669 | def test_from_api_response_with_empty_data(self):
1670 | """Test creating a JiraWorklog from empty data."""
1671 | worklog = JiraWorklog.from_api_response({})
1672 | assert worklog.id == JIRA_DEFAULT_ID
1673 | assert worklog.author is None
1674 | assert worklog.comment is None
1675 | assert worklog.created == EMPTY_STRING
1676 | assert worklog.updated == EMPTY_STRING
1677 | assert worklog.started == EMPTY_STRING
1678 | assert worklog.time_spent == EMPTY_STRING
1679 | assert worklog.time_spent_seconds == 0
1680 |
1681 | def test_to_simplified_dict(self):
1682 | """Test converting a JiraWorklog to a simplified dictionary."""
1683 | worklog_data = {
1684 | "id": "100023",
1685 | "author": {
1686 | "accountId": "5b10a2844c20165700ede21g",
1687 | "displayName": "John Doe",
1688 | "active": True,
1689 | },
1690 | "comment": "Worked on the issue today",
1691 | "created": "2023-05-01T10:00:00.000+0000",
1692 | "updated": "2023-05-01T10:30:00.000+0000",
1693 | "started": "2023-05-01T09:00:00.000+0000",
1694 | "timeSpent": "2h 30m",
1695 | "timeSpentSeconds": 9000,
1696 | }
1697 | worklog = JiraWorklog.from_api_response(worklog_data)
1698 | simplified = worklog.to_simplified_dict()
1699 | assert simplified["time_spent"] == "2h 30m"
1700 | assert simplified["time_spent_seconds"] == 9000
1701 | assert simplified["author"] is not None
1702 | assert simplified["author"]["display_name"] == "John Doe"
1703 | assert simplified["comment"] == "Worked on the issue today"
1704 | assert "created" in simplified
1705 | assert "updated" in simplified
1706 | assert "started" in simplified
1707 |
1708 |
1709 | class TestRealJiraData:
1710 | """Tests using real Jira data (optional)."""
1711 |
1712 | # Helper to get client/config
1713 | def _get_client(self) -> IssuesMixin | None:
1714 | if not real_api_available:
1715 | return None
1716 | try:
1717 | config = JiraConfig.from_env()
1718 | return JiraFetcher(config=config)
1719 | except ValueError:
1720 | pytest.skip("Real Jira environment not configured")
1721 | return None
1722 |
1723 | def _get_project_client(self) -> ProjectsMixin | None:
1724 | if not real_api_available:
1725 | return None
1726 | try:
1727 | config = JiraConfig.from_env()
1728 |
1729 | return JiraFetcher(config=config)
1730 | except ValueError:
1731 | pytest.skip("Real Jira environment not configured")
1732 | return None
1733 |
1734 | def _get_transition_client(self) -> TransitionsMixin | None:
1735 | if not real_api_available:
1736 | return None
1737 | try:
1738 | config = JiraConfig.from_env()
1739 | return JiraFetcher(config=config)
1740 | except ValueError:
1741 | pytest.skip("Real Jira environment not configured")
1742 | return None
1743 |
1744 | def _get_worklog_client(self) -> WorklogMixin | None:
1745 | if not real_api_available:
1746 | return None
1747 | try:
1748 | config = JiraConfig.from_env()
1749 | return JiraFetcher(config=config)
1750 | except ValueError:
1751 | pytest.skip("Real Jira environment not configured")
1752 | return None
1753 |
1754 | def _get_base_jira_client(self) -> Jira | None:
1755 | if not real_api_available:
1756 | return None
1757 | try:
1758 | config = JiraConfig.from_env()
1759 | if config.auth_type == "basic":
1760 | return Jira(
1761 | url=config.url,
1762 | username=config.username,
1763 | password=config.api_token,
1764 | cloud=config.is_cloud,
1765 | )
1766 | else: # token
1767 | return Jira(
1768 | url=config.url, token=config.personal_token, cloud=config.is_cloud
1769 | )
1770 | except ValueError:
1771 | pytest.skip("Real Jira environment not configured")
1772 | return None
1773 |
1774 | def test_real_jira_issue(self, use_real_jira_data, default_jira_issue_key):
1775 | """Test that the JiraIssue model works with real Jira API data."""
1776 | if not use_real_jira_data:
1777 | pytest.skip("Skipping real Jira data test")
1778 | issues_client = self._get_client()
1779 | if not issues_client or not default_jira_issue_key:
1780 | pytest.skip("Real Jira client/issue key not available")
1781 |
1782 | try:
1783 | issue = issues_client.get_issue(default_jira_issue_key)
1784 | assert isinstance(issue, JiraIssue)
1785 | assert issue.key == default_jira_issue_key
1786 | assert issue.id is not None
1787 | assert issue.summary is not None
1788 |
1789 | assert hasattr(issue, "project")
1790 | assert issue.project is None or isinstance(issue.project, JiraProject)
1791 | assert hasattr(issue, "resolution")
1792 | assert issue.resolution is None or isinstance(
1793 | issue.resolution, JiraResolution
1794 | )
1795 | assert hasattr(issue, "duedate")
1796 | assert issue.duedate is None or isinstance(issue.duedate, str)
1797 | assert hasattr(issue, "resolutiondate")
1798 | assert issue.resolutiondate is None or isinstance(issue.resolutiondate, str)
1799 | assert hasattr(issue, "parent")
1800 | assert issue.parent is None or isinstance(issue.parent, dict)
1801 | assert hasattr(issue, "subtasks")
1802 | assert isinstance(issue.subtasks, list)
1803 | if issue.subtasks:
1804 | assert isinstance(issue.subtasks[0], dict)
1805 | assert hasattr(issue, "security")
1806 | assert issue.security is None or isinstance(issue.security, dict)
1807 | assert hasattr(issue, "worklog")
1808 | assert issue.worklog is None or isinstance(issue.worklog, dict)
1809 |
1810 | simplified = issue.to_simplified_dict()
1811 | assert simplified["key"] == default_jira_issue_key
1812 | except Exception as e:
1813 | pytest.fail(f"Error testing real Jira issue: {e}")
1814 |
1815 | def test_real_jira_project(self, use_real_jira_data):
1816 | """Test that the JiraProject model works with real Jira API data."""
1817 | if not use_real_jira_data:
1818 | pytest.skip("Skipping real Jira data test")
1819 | projects_client = self._get_project_client()
1820 | if not projects_client:
1821 | pytest.skip("Real Jira client not available")
1822 |
1823 | # Check for JIRA_TEST_ISSUE_KEY explicitly
1824 | if not os.environ.get("JIRA_TEST_ISSUE_KEY"):
1825 | pytest.skip("JIRA_TEST_ISSUE_KEY environment variable not set")
1826 |
1827 | default_issue_key = os.environ.get("JIRA_TEST_ISSUE_KEY")
1828 | project_key = default_issue_key.split("-")[0]
1829 |
1830 | if not project_key:
1831 | pytest.skip("Could not extract project key from JIRA_TEST_ISSUE_KEY")
1832 |
1833 | try:
1834 | project = projects_client.get_project_model(project_key)
1835 |
1836 | if project is None:
1837 | pytest.skip(f"Could not get project model for {project_key}")
1838 |
1839 | assert isinstance(project, JiraProject)
1840 | assert project.key == project_key
1841 | assert project.id is not None
1842 | assert project.name is not None
1843 |
1844 | simplified = project.to_simplified_dict()
1845 | assert simplified["key"] == project_key
1846 | except (AttributeError, TypeError, ValueError) as e:
1847 | pytest.skip(f"Error parsing project data: {e}")
1848 | except Exception as e:
1849 | pytest.fail(f"Error testing real Jira project: {e}")
1850 |
1851 | def test_real_jira_transitions(self, use_real_jira_data, default_jira_issue_key):
1852 | """Test that the JiraTransition model works with real Jira API data."""
1853 | if not use_real_jira_data:
1854 | pytest.skip("Skipping real Jira data test")
1855 | transitions_client = self._get_transition_client()
1856 | if not transitions_client or not default_jira_issue_key:
1857 | pytest.skip("Real Jira client/issue key not available")
1858 |
1859 | # Use the underlying Atlassian API client directly for raw data
1860 | jira = self._get_base_jira_client()
1861 | if not jira:
1862 | pytest.skip("Base Jira client failed")
1863 |
1864 | transitions_data = None # Initialize
1865 | try:
1866 | transitions_data = jira.get_issue_transitions(default_jira_issue_key)
1867 |
1868 | actual_transitions_list = []
1869 | if isinstance(transitions_data, list):
1870 | actual_transitions_list = transitions_data
1871 | else:
1872 | # Handle unexpected format with test failure
1873 | pytest.fail(
1874 | f"Unexpected transitions data format received from API: "
1875 | f"{type(transitions_data)}. Data: {transitions_data}"
1876 | )
1877 |
1878 | # Verify transitions list is actually a list
1879 | assert isinstance(actual_transitions_list, list)
1880 |
1881 | if not actual_transitions_list:
1882 | pytest.skip(f"No transitions found for issue {default_jira_issue_key}")
1883 |
1884 | transition_item = actual_transitions_list[0]
1885 | assert isinstance(transition_item, dict)
1886 |
1887 | # Check for essential keys in the raw data
1888 | assert "id" in transition_item
1889 | assert "name" in transition_item
1890 | assert "to" in transition_item
1891 |
1892 | # Only check 'to' field name if it's a dictionary
1893 | if isinstance(transition_item["to"], dict):
1894 | assert "name" in transition_item["to"]
1895 |
1896 | # Convert to model
1897 | transition = JiraTransition.from_api_response(transition_item)
1898 | assert isinstance(transition, JiraTransition)
1899 | assert transition.id == str(transition_item["id"]) # Ensure ID is string
1900 | assert transition.name == transition_item["name"]
1901 |
1902 | simplified = transition.to_simplified_dict()
1903 | assert simplified["id"] == str(transition_item["id"])
1904 | assert simplified["name"] == transition_item["name"]
1905 |
1906 | except Exception as e:
1907 | # Include data type details in error message
1908 | error_details = f"Received data type: {type(transitions_data)}"
1909 | if transitions_data is not None:
1910 | error_details += (
1911 | f", Data: {str(transitions_data)[:200]}..." # Show partial data
1912 | )
1913 |
1914 | pytest.fail(
1915 | f"Error testing real Jira transitions for issue {default_jira_issue_key}: {e}. {error_details}"
1916 | )
1917 |
1918 | def test_real_jira_worklog(self, use_real_jira_data, default_jira_issue_key):
1919 | """Test that the JiraWorklog model works with real Jira API data."""
1920 | if not use_real_jira_data:
1921 | pytest.skip("Skipping real Jira data test")
1922 | worklog_client = self._get_worklog_client()
1923 | if not worklog_client or not default_jira_issue_key:
1924 | pytest.skip("Real Jira client/issue key not available")
1925 |
1926 | try:
1927 | # Get worklogs using the model method
1928 | worklogs = worklog_client.get_worklog_models(default_jira_issue_key)
1929 | assert isinstance(worklogs, list)
1930 |
1931 | if not worklogs:
1932 | pytest.skip(f"Issue {default_jira_issue_key} has no worklogs to test.")
1933 |
1934 | # Test the first worklog
1935 | worklog = worklogs[0]
1936 | assert isinstance(worklog, JiraWorklog)
1937 | assert worklog.id is not None
1938 | assert worklog.time_spent_seconds >= 0
1939 | if worklog.author:
1940 | assert isinstance(worklog.author, JiraUser)
1941 |
1942 | simplified = worklog.to_simplified_dict()
1943 | assert "id" in simplified
1944 | assert "time_spent" in simplified
1945 |
1946 | except Exception as e:
1947 | pytest.fail(f"Error testing real Jira worklog: {e}")
1948 |
```