python-jira-plus 2.0.7__tar.gz → 2.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jira-plus
3
- Version: 2.0.7
3
+ Version: 2.0.8
4
4
  Summary: Enhanced Python client for JIRA with better error handling, pagination, and metadata validation.
5
5
  Author: Avi Zaguri
6
6
  License: MIT
@@ -17,14 +17,16 @@ Requires-Python: >=3.12
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: custom-python-logger>=2.0.10
20
- Requires-Dist: dotenv>=0.9.9
20
+ Requires-Dist: python-dotenv>=1.0.0
21
21
  Requires-Dist: jira>=3.10.5
22
- Requires-Dist: pre-commit>=4.5.0
23
- Requires-Dist: pytest>=9.0.1
22
+ Requires-Dist: packaging>=26.2
24
23
  Requires-Dist: requests>=2.32.5
25
24
  Requires-Dist: retrying>=1.4.2
26
- Requires-Dist: setuptools>=80.9.0
27
- Requires-Dist: wheel>=0.45.1
25
+ Provides-Extra: dev
26
+ Requires-Dist: pre-commit>=4.5.0; extra == "dev"
27
+ Requires-Dist: pytest>=9.0.1; extra == "dev"
28
+ Requires-Dist: setuptools>=80.9.0; extra == "dev"
29
+ Requires-Dist: wheel>=0.45.1; extra == "dev"
28
30
  Dynamic: license-file
29
31
 
30
32
  ![PyPI version](https://img.shields.io/pypi/v/python-jira-plus)
@@ -59,7 +61,7 @@ pip install python-jira-plus
59
61
  ---
60
62
 
61
63
  ## Requirements
62
- - Python 3.9+
64
+ - Python 3.12+
63
65
  - `jira` package
64
66
  - `retrying` package
65
67
  - `custom-python-logger` package
@@ -30,7 +30,7 @@ pip install python-jira-plus
30
30
  ---
31
31
 
32
32
  ## Requirements
33
- - Python 3.9+
33
+ - Python 3.12+
34
34
  - `jira` package
35
35
  - `retrying` package
36
36
  - `custom-python-logger` package
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-jira-plus"
7
- version = "2.0.7"
7
+ version = "2.0.8"
8
8
  description = "Enhanced Python client for JIRA with better error handling, pagination, and metadata validation."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -22,12 +22,17 @@ classifiers = [
22
22
 
23
23
  dependencies = [
24
24
  "custom-python-logger>=2.0.10",
25
- "dotenv>=0.9.9",
25
+ "python-dotenv>=1.0.0",
26
26
  "jira>=3.10.5",
27
- "pre-commit>=4.5.0",
28
- "pytest>=9.0.1",
27
+ "packaging>=26.2",
29
28
  "requests>=2.32.5",
30
29
  "retrying>=1.4.2",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pre-commit>=4.5.0",
35
+ "pytest>=9.0.1",
31
36
  "setuptools>=80.9.0",
32
37
  "wheel>=0.45.1",
33
38
  ]
@@ -8,7 +8,7 @@ pagination, metadata validation, and more.
8
8
  from dotenv import load_dotenv
9
9
 
10
10
  from python_jira_plus.describe_allowed_value import describe_allowed_value
11
- from python_jira_plus.jira_plus import JiraPlus, ServerType
11
+ from python_jira_plus.jira_plus import JiraPlus, ServerType, UrlScheme
12
12
 
13
13
  load_dotenv()
14
14
 
@@ -27,4 +27,4 @@ BASIC_FIELDS = [
27
27
  "resolution",
28
28
  "parent",
29
29
  ]
30
- __all__ = ["JiraPlus", "ServerType", "BASIC_FIELDS"]
30
+ __all__ = ["JiraPlus", "ServerType", "UrlScheme", "BASIC_FIELDS"]
@@ -11,17 +11,17 @@ def describe_allowed_value(value: Any, allowed_values: set = None) -> bool:
11
11
 
12
12
  @describe_allowed_value.register
13
13
  def _(value: int, allowed_values: list = None) -> bool:
14
- return value == allowed_values
14
+ return value in allowed_values
15
15
 
16
16
 
17
17
  @describe_allowed_value.register
18
18
  def _(value: float, allowed_values: list = None) -> bool:
19
- return value == allowed_values
19
+ return value in allowed_values
20
20
 
21
21
 
22
22
  @describe_allowed_value.register
23
23
  def _(value: str, allowed_values: list = None) -> bool:
24
- return value == allowed_values
24
+ return value in allowed_values
25
25
 
26
26
 
27
27
  @describe_allowed_value.register
@@ -53,10 +53,12 @@ class JiraPlus:
53
53
 
54
54
  self.jira_client = self.create_connection()
55
55
  self.check_client_connection()
56
+ self.server_version = self.get_server_version()
57
+ self.use_legacy_api = self._resolve_use_legacy_api()
56
58
 
57
59
  @retry(stop_max_attempt_number=3, wait_fixed=180000)
58
- def create_connection(self, timeout: int = 580) -> JIRA | None:
59
- if self.server_type == ServerType.ON_PREMISE and not self.sso: # pylint: disable=W0160:
60
+ def create_connection(self, timeout: int = 580) -> JIRA:
61
+ if self.server_type == ServerType.ON_PREMISE and not self.sso:
60
62
  jira_client = JIRA(
61
63
  token_auth=self.jira_token, options={"server": self.server, "verify": self.verify_ssl}, timeout=timeout
62
64
  )
@@ -142,12 +144,16 @@ class JiraPlus:
142
144
  all_issues = []
143
145
  retries = 0
144
146
  page = None
145
- while True: # pylint: disable=W0149
147
+ while True:
146
148
  try:
149
+ next_token = None
150
+ if page is not None:
151
+ next_token = page.get('nextPageToken') if json_result else page.nextPageToken
152
+
147
153
  self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
148
154
  page = self.jira_client.enhanced_search_issues(
149
155
  jql_str=query,
150
- nextPageToken=page['nextPageToken'] if page else None,
156
+ nextPageToken=next_token,
151
157
  maxResults=max_results,
152
158
  fields=specific_fields,
153
159
  json_result=json_result,
@@ -156,7 +162,8 @@ class JiraPlus:
156
162
  break
157
163
 
158
164
  all_issues += page["issues"] if json_result else page
159
- if page['isLast']:
165
+ is_last = page['isLast'] if json_result else page.isLast
166
+ if is_last:
160
167
  break
161
168
  retries = 0 # reset on success
162
169
  except JIRAError as err:
@@ -268,21 +275,16 @@ class JiraPlus:
268
275
  def get_server_version(self) -> tuple[int, ...]:
269
276
  return tuple(self.jira_client.server_info()["versionNumbers"])
270
277
 
271
- def check_server_createmeta_compatibility(self) -> bool:
272
- server_version = self.get_server_version()
273
- if (
274
- self.server_type == ServerType.ON_PREMISE and
275
- Version(".".join(str(x) for x in server_version)) > Version("9.0.0")
276
- ):
277
- self.logger.warning(
278
- f"JIRA server version {server_version} is not fully compatible with createmeta-based field validation. "
279
- "Some features may not work as expected."
280
- )
281
- return False
278
+ def _resolve_use_legacy_api(self) -> bool:
279
+ if self.server_type == ServerType.ON_PREMISE:
280
+ use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
281
+ self.logger.debug(f"Using {'legacy createmeta' if use_legacy else 'project_issue_types'} API (ON_PREMISE {self.server_version})")
282
+ return use_legacy
283
+ self.logger.debug("Using legacy createmeta API (Cloud)")
282
284
  return True
283
285
 
284
286
  def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
285
- if self.check_server_createmeta_compatibility():
287
+ if self.use_legacy_api:
286
288
  _, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
287
289
  return issue_meta["fields"]
288
290
  _project_issue_types = self.jira_client.project_issue_types(project_key)
@@ -291,7 +293,7 @@ class JiraPlus:
291
293
  return {f.fieldId: f.raw for f in field_list}
292
294
 
293
295
  def validate_fields( # pylint: disable=R1260
294
- self, project_key: str, issue_type: str, fields: dict | None = None
296
+ self, project_key: str, issue_type: str, fields: dict
295
297
  ) -> None:
296
298
  fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
297
299
  for custom_field, value in fields.items():
@@ -370,7 +372,7 @@ class JiraPlus:
370
372
 
371
373
  def delete_issue(self, issue_key: str) -> bool:
372
374
  try:
373
- issue = self.get_issue_by_key(key=issue_key)
375
+ issue = self.get_issue_by_key(key=issue_key, json_result=False)
374
376
  issue.delete()
375
377
  self.logger.info(f"Issue {issue_key} deleted successfully.")
376
378
  return True
@@ -455,10 +457,13 @@ class JiraPlus:
455
457
  return False
456
458
 
457
459
  def get_account_data(self, user_mail: str) -> list[dict]:
458
- jira_rst_url = (
459
- f"https://{self.jira_username}:{self.jira_token}@{self.base_url}/rest/api/{3}/user/search?query={user_mail}"
460
+ response = requests.get(
461
+ f"{self.server}/rest/api/3/user/search",
462
+ params={"query": user_mail},
463
+ auth=(self.jira_username, self.jira_token),
464
+ timeout=30,
460
465
  )
461
- response = requests.get(jira_rst_url)
466
+ response.raise_for_status()
462
467
  return response.json()
463
468
 
464
469
  def get_account_id(self, user_mail: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jira-plus
3
- Version: 2.0.7
3
+ Version: 2.0.8
4
4
  Summary: Enhanced Python client for JIRA with better error handling, pagination, and metadata validation.
5
5
  Author: Avi Zaguri
6
6
  License: MIT
@@ -17,14 +17,16 @@ Requires-Python: >=3.12
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: custom-python-logger>=2.0.10
20
- Requires-Dist: dotenv>=0.9.9
20
+ Requires-Dist: python-dotenv>=1.0.0
21
21
  Requires-Dist: jira>=3.10.5
22
- Requires-Dist: pre-commit>=4.5.0
23
- Requires-Dist: pytest>=9.0.1
22
+ Requires-Dist: packaging>=26.2
24
23
  Requires-Dist: requests>=2.32.5
25
24
  Requires-Dist: retrying>=1.4.2
26
- Requires-Dist: setuptools>=80.9.0
27
- Requires-Dist: wheel>=0.45.1
25
+ Provides-Extra: dev
26
+ Requires-Dist: pre-commit>=4.5.0; extra == "dev"
27
+ Requires-Dist: pytest>=9.0.1; extra == "dev"
28
+ Requires-Dist: setuptools>=80.9.0; extra == "dev"
29
+ Requires-Dist: wheel>=0.45.1; extra == "dev"
28
30
  Dynamic: license-file
29
31
 
30
32
  ![PyPI version](https://img.shields.io/pypi/v/python-jira-plus)
@@ -59,7 +61,7 @@ pip install python-jira-plus
59
61
  ---
60
62
 
61
63
  ## Requirements
62
- - Python 3.9+
64
+ - Python 3.12+
63
65
  - `jira` package
64
66
  - `retrying` package
65
67
  - `custom-python-logger` package
@@ -9,4 +9,7 @@ python_jira_plus.egg-info/PKG-INFO
9
9
  python_jira_plus.egg-info/SOURCES.txt
10
10
  python_jira_plus.egg-info/dependency_links.txt
11
11
  python_jira_plus.egg-info/requires.txt
12
- python_jira_plus.egg-info/top_level.txt
12
+ python_jira_plus.egg-info/top_level.txt
13
+ tests/test_describe_allowed_value.py
14
+ tests/test_jira_cloud_integration.py
15
+ tests/test_jira_plus.py
@@ -1,9 +1,12 @@
1
1
  custom-python-logger>=2.0.10
2
- dotenv>=0.9.9
2
+ python-dotenv>=1.0.0
3
3
  jira>=3.10.5
4
- pre-commit>=4.5.0
5
- pytest>=9.0.1
4
+ packaging>=26.2
6
5
  requests>=2.32.5
7
6
  retrying>=1.4.2
7
+
8
+ [dev]
9
+ pre-commit>=4.5.0
10
+ pytest>=9.0.1
8
11
  setuptools>=80.9.0
9
12
  wheel>=0.45.1
@@ -0,0 +1,77 @@
1
+ import pytest
2
+
3
+ from python_jira_plus.describe_allowed_value import describe_allowed_value
4
+
5
+
6
+ class TestDescribeAllowedValueStr:
7
+ def test_str_in_allowed_set_returns_true(self) -> None:
8
+ assert describe_allowed_value("High", allowed_values={"High", "Medium", "Low"}), \
9
+ "Expected True for 'High' in allowed set"
10
+
11
+ def test_str_not_in_allowed_set_returns_false(self) -> None:
12
+ assert not describe_allowed_value("Critical", allowed_values={"High", "Medium", "Low"}), \
13
+ "Expected False for 'Critical' not in allowed set"
14
+
15
+
16
+ class TestDescribeAllowedValueInt:
17
+ def test_int_in_allowed_set_returns_true(self) -> None:
18
+ assert describe_allowed_value(1, allowed_values={1, 2, 3}), \
19
+ "Expected True for 1 in allowed set"
20
+
21
+ def test_int_not_in_allowed_set_returns_false(self) -> None:
22
+ assert not describe_allowed_value(9, allowed_values={1, 2, 3}), \
23
+ "Expected False for 9 not in allowed set"
24
+
25
+
26
+ class TestDescribeAllowedValueFloat:
27
+ def test_float_in_allowed_set_returns_true(self) -> None:
28
+ assert describe_allowed_value(1.5, allowed_values={1.5, 2.5}), \
29
+ "Expected True for 1.5 in allowed set"
30
+
31
+ def test_float_not_in_allowed_set_returns_false(self) -> None:
32
+ assert not describe_allowed_value(9.9, allowed_values={1.5, 2.5}), \
33
+ "Expected False for 9.9 not in allowed set"
34
+
35
+
36
+ class TestDescribeAllowedValueList:
37
+ def test_list_of_strings_with_match_returns_true(self) -> None:
38
+ assert describe_allowed_value(["High", "Unknown"], allowed_values={"High", "Medium"}), \
39
+ "Expected True when at least one item matches"
40
+
41
+ def test_list_of_strings_no_match_returns_false(self) -> None:
42
+ assert not describe_allowed_value(["Critical", "Unknown"], allowed_values={"High", "Medium"}), \
43
+ "Expected False when no items match"
44
+
45
+ def test_list_of_dicts_with_match_returns_true(self) -> None:
46
+ assert describe_allowed_value([{"name": "High"}], allowed_values={"High", "Medium"}), \
47
+ "Expected True when dict value matches"
48
+
49
+ def test_list_of_dicts_no_match_returns_false(self) -> None:
50
+ assert not describe_allowed_value([{"name": "Critical"}], allowed_values={"High", "Medium"}), \
51
+ "Expected False when dict value doesn't match"
52
+
53
+
54
+ class TestDescribeAllowedValueSet:
55
+ def test_set_with_match_returns_true(self) -> None:
56
+ assert describe_allowed_value({"High", "Unknown"}, allowed_values={"High", "Medium"}), \
57
+ "Expected True when set contains a match"
58
+
59
+ def test_set_no_match_returns_false(self) -> None:
60
+ assert not describe_allowed_value({"Critical"}, allowed_values={"High", "Medium"}), \
61
+ "Expected False when set has no match"
62
+
63
+
64
+ class TestDescribeAllowedValueDict:
65
+ def test_dict_value_in_allowed_set_returns_true(self) -> None:
66
+ assert describe_allowed_value({"name": "High"}, allowed_values={"High", "Medium"}), \
67
+ "Expected True when dict value matches"
68
+
69
+ def test_dict_value_not_in_allowed_set_returns_false(self) -> None:
70
+ assert not describe_allowed_value({"name": "Critical"}, allowed_values={"High", "Medium"}), \
71
+ "Expected False when dict value doesn't match"
72
+
73
+
74
+ class TestDescribeAllowedValueUnknownType:
75
+ def test_unknown_type_raises_not_implemented_error(self) -> None:
76
+ with pytest.raises(NotImplementedError):
77
+ describe_allowed_value(object(), allowed_values={"High"})
@@ -0,0 +1,150 @@
1
+ """
2
+ Integration tests against a real Jira Cloud instance.
3
+
4
+ Required env vars:
5
+ JIRA_USER_NAME your Jira email
6
+ JIRA_TOKEN your Jira API token
7
+ JIRA_BASE_URL e.g. your-instance.atlassian.net
8
+
9
+ Optional env vars (fall back to defaults if not set):
10
+ JIRA_TEST_PROJECT project key to query (default: SUP)
11
+ JIRA_TEST_ISSUE_TYPE issue type to query (default: [System] Service request)
12
+ JIRA_TEST_ISSUE_KEY a known issue key (default: SUP-1)
13
+ JIRA_TEST_FIELD field for allowed values check (default: Priority)
14
+
15
+ Run only integration tests:
16
+ uv run pytest -m integration -v
17
+ """
18
+
19
+ import os
20
+
21
+ import pytest
22
+
23
+ from python_jira_plus import BASIC_FIELDS
24
+ from python_jira_plus.jira_plus import JiraPlus
25
+
26
+ pytestmark = pytest.mark.integration
27
+
28
+ _REQUIRED_ENV = ("JIRA_USER_NAME", "JIRA_TOKEN", "JIRA_BASE_URL")
29
+
30
+
31
+ def _missing_env() -> list[str]:
32
+ return [var for var in _REQUIRED_ENV if not os.getenv(var)]
33
+
34
+
35
+ @pytest.fixture(scope="module")
36
+ def jira() -> JiraPlus:
37
+ missing = _missing_env()
38
+ if missing:
39
+ pytest.skip(f"Missing required env vars: {', '.join(missing)}")
40
+ return JiraPlus()
41
+
42
+
43
+ @pytest.fixture(scope="module")
44
+ def project_key() -> str:
45
+ return os.getenv("JIRA_TEST_PROJECT", "SUP")
46
+
47
+
48
+ @pytest.fixture(scope="module")
49
+ def issue_type() -> str:
50
+ return os.getenv("JIRA_TEST_ISSUE_TYPE", "[System] Service request")
51
+
52
+
53
+ @pytest.fixture(scope="module")
54
+ def issue_key() -> str:
55
+ return os.getenv("JIRA_TEST_ISSUE_KEY", "SUP-1")
56
+
57
+
58
+ @pytest.fixture(scope="module")
59
+ def test_field() -> str:
60
+ return os.getenv("JIRA_TEST_FIELD", "Priority")
61
+
62
+
63
+ class TestConnection:
64
+ def test_client_is_connected(self, jira: JiraPlus) -> None:
65
+ result = jira.check_client_connection()
66
+ assert result is True, "Expected active connection to Jira Cloud"
67
+
68
+ def test_server_version_is_tuple(self, jira: JiraPlus) -> None:
69
+ assert isinstance(jira.server_version, tuple), \
70
+ f"Expected tuple, got {type(jira.server_version)}"
71
+ assert len(jira.server_version) > 0, "Expected non-empty version tuple"
72
+
73
+ def test_cloud_uses_legacy_api(self, jira: JiraPlus) -> None:
74
+ assert jira.use_legacy_api is True, \
75
+ "Cloud uses legacy createmeta API — project_issue_types is not supported for Cloud by the jira library"
76
+
77
+
78
+ class TestGetObjectsByQuery:
79
+ def test_returns_list_of_issues(self, jira: JiraPlus, project_key: str, issue_type: str) -> None:
80
+ query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
81
+ result = jira.get_objects_by_query(
82
+ query=query,
83
+ specific_fields=BASIC_FIELDS,
84
+ max_results=10,
85
+ json_result=True,
86
+ )
87
+ assert isinstance(result, list), f"Expected list, got {type(result)}"
88
+
89
+ def test_returned_issues_have_basic_fields(self, jira: JiraPlus, project_key: str, issue_type: str) -> None:
90
+ query = f'project = "{project_key}" AND issuetype = "{issue_type}"'
91
+ result = jira.get_objects_by_query(
92
+ query=query,
93
+ specific_fields=BASIC_FIELDS,
94
+ max_results=5,
95
+ json_result=True,
96
+ )
97
+ if not result:
98
+ pytest.skip(f"No issues found in project '{project_key}' with issue type '{issue_type}'")
99
+
100
+ issue = result[0]
101
+ assert "key" in issue, f"Expected 'key' in issue, got keys: {list(issue.keys())}"
102
+ assert "fields" in issue, f"Expected 'fields' in issue, got keys: {list(issue.keys())}"
103
+
104
+ def test_invalid_jql_raises(self, jira: JiraPlus) -> None:
105
+ from jira import JIRAError
106
+ with pytest.raises(JIRAError):
107
+ jira.get_objects_by_query(query="INVALID JQL !!!", max_results=1)
108
+
109
+
110
+ class TestGetIssueByKey:
111
+ def test_json_result_true_returns_dict(self, jira: JiraPlus, issue_key: str) -> None:
112
+ result = jira.get_issue_by_key(key=issue_key, json_result=True)
113
+ if result is None:
114
+ pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
115
+ assert isinstance(result, dict), f"Expected dict, got {type(result)}"
116
+ assert "key" in result, f"Expected 'key' in result"
117
+
118
+ def test_json_result_false_returns_issue_object(self, jira: JiraPlus, issue_key: str) -> None:
119
+ from jira import Issue
120
+ result = jira.get_issue_by_key(key=issue_key, json_result=False)
121
+ if result is None:
122
+ pytest.skip(f"Issue '{issue_key}' not found — set JIRA_TEST_ISSUE_KEY to a valid key")
123
+ assert isinstance(result, Issue), f"Expected Issue object, got {type(result)}"
124
+
125
+ def test_nonexistent_issue_returns_none(self, jira: JiraPlus) -> None:
126
+ result = jira.get_issue_by_key(key="NONEXISTENT-99999999")
127
+ assert result is None, "Expected None for a nonexistent issue key"
128
+
129
+
130
+ class TestGetAllowedValues:
131
+ def test_returns_list_for_valid_field(
132
+ self, jira: JiraPlus, project_key: str, issue_type: str, test_field: str
133
+ ) -> None:
134
+ result = jira.get_allowed_values(
135
+ project_key=project_key,
136
+ issue_type=issue_type,
137
+ field_id_or_name=test_field,
138
+ )
139
+ assert result is None or isinstance(result, list), \
140
+ f"Expected list or None, got {type(result)}"
141
+
142
+ def test_invalid_field_raises_value_error(
143
+ self, jira: JiraPlus, project_key: str, issue_type: str
144
+ ) -> None:
145
+ with pytest.raises(ValueError, match="not found"):
146
+ jira.get_allowed_values(
147
+ project_key=project_key,
148
+ issue_type=issue_type,
149
+ field_id_or_name="nonexistent_field_xyz_999",
150
+ )
@@ -0,0 +1,227 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+ from jira import JIRAError
5
+
6
+ from python_jira_plus.jira_plus import JiraPlus
7
+
8
+
9
+ def _make_issue_type_mock(name: str, issue_id: str) -> MagicMock:
10
+ mock = MagicMock()
11
+ mock.name = name
12
+ mock.id = issue_id
13
+ return mock
14
+
15
+
16
+ def _make_field_mock(field_id: str, raw: dict) -> MagicMock:
17
+ mock = MagicMock()
18
+ mock.fieldId = field_id
19
+ mock.raw = raw
20
+ return mock
21
+
22
+
23
+ class TestResolveLegacyApi:
24
+ def test_cloud_uses_legacy_api(self, jira_plus_cloud: JiraPlus) -> None:
25
+ assert jira_plus_cloud.use_legacy_api is True, \
26
+ "Cloud uses legacy createmeta API — project_issue_types is not supported for Cloud by the jira library"
27
+
28
+ def test_on_premise_below_9_uses_legacy_api(self, jira_plus_on_premise_legacy: JiraPlus) -> None:
29
+ assert jira_plus_on_premise_legacy.use_legacy_api is True, \
30
+ "ON_PREMISE < 9.0 should use legacy createmeta API"
31
+
32
+ def test_on_premise_9_and_above_uses_new_api(self, jira_plus_on_premise_new: JiraPlus) -> None:
33
+ assert jira_plus_on_premise_new.use_legacy_api is False, \
34
+ "ON_PREMISE >= 9.0 should use project_issue_types API"
35
+
36
+
37
+ class TestGetIssueByKey:
38
+ def test_json_result_true_returns_raw_dict(self, jira_plus_cloud: JiraPlus) -> None:
39
+ mock_issue = MagicMock()
40
+ mock_issue.raw = {"key": "PROJ-1", "fields": {}}
41
+ jira_plus_cloud.jira_client.issue.return_value = mock_issue
42
+
43
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-1", json_result=True)
44
+
45
+ assert result == {"key": "PROJ-1", "fields": {}}, f"Expected raw dict, got {result}"
46
+
47
+ def test_json_result_false_returns_issue_object(self, jira_plus_cloud: JiraPlus) -> None:
48
+ mock_issue = MagicMock()
49
+ jira_plus_cloud.jira_client.issue.return_value = mock_issue
50
+
51
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-1", json_result=False)
52
+
53
+ assert result is mock_issue, "Expected Issue object, not raw dict"
54
+
55
+ def test_jira_error_returns_none(self, jira_plus_cloud: JiraPlus) -> None:
56
+ jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Not found", status_code=404)
57
+
58
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-999")
59
+
60
+ assert result is None, "Expected None on JIRAError"
61
+
62
+ def test_unexpected_error_returns_none(self, jira_plus_cloud: JiraPlus) -> None:
63
+ jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
64
+
65
+ result = jira_plus_cloud.get_issue_by_key(key="PROJ-1")
66
+
67
+ assert result is None, "Expected None on unexpected error"
68
+
69
+
70
+ class TestDeleteIssue:
71
+ def test_success_returns_true_and_calls_delete(self, jira_plus_cloud: JiraPlus) -> None:
72
+ mock_issue = MagicMock()
73
+ jira_plus_cloud.jira_client.issue.return_value = mock_issue
74
+
75
+ result = jira_plus_cloud.delete_issue("PROJ-1")
76
+
77
+ assert result is True, "Expected True on successful delete"
78
+ mock_issue.delete.assert_called_once()
79
+
80
+ def test_jira_error_returns_false(self, jira_plus_cloud: JiraPlus) -> None:
81
+ jira_plus_cloud.jira_client.issue.side_effect = JIRAError("Forbidden", status_code=403)
82
+
83
+ result = jira_plus_cloud.delete_issue("PROJ-1")
84
+
85
+ assert result is False, "Expected False on JIRAError"
86
+
87
+ def test_unexpected_error_returns_false(self, jira_plus_cloud: JiraPlus) -> None:
88
+ jira_plus_cloud.jira_client.issue.side_effect = RuntimeError("network failure")
89
+
90
+ result = jira_plus_cloud.delete_issue("PROJ-1")
91
+
92
+ assert result is False, "Expected False on unexpected error"
93
+
94
+
95
+ class TestValidateFields:
96
+ def _setup_createmeta(self, jira_plus: JiraPlus, fields: dict) -> None:
97
+ jira_plus.jira_client.createmeta.return_value = {
98
+ "projects": [{
99
+ "key": "PROJ",
100
+ "issuetypes": [{"name": "Story", "fields": fields}],
101
+ }]
102
+ }
103
+
104
+ def test_valid_string_field_raises_no_error(self, jira_plus_cloud: JiraPlus) -> None:
105
+ self._setup_createmeta(jira_plus_cloud, {
106
+ "summary": {"schema": {"type": "string"}, "name": "Summary"},
107
+ })
108
+
109
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": "Hello"})
110
+
111
+ def test_field_not_in_metadata_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
112
+ self._setup_createmeta(jira_plus_cloud, {
113
+ "summary": {"schema": {"type": "string"}, "name": "Summary"},
114
+ })
115
+
116
+ with pytest.raises(ValueError, match="Invalid field"):
117
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"nonexistent_field": "value"})
118
+
119
+ def test_wrong_type_for_string_field_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
120
+ self._setup_createmeta(jira_plus_cloud, {
121
+ "summary": {"schema": {"type": "string"}, "name": "Summary"},
122
+ })
123
+
124
+ with pytest.raises(ValueError, match="must be a string"):
125
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"summary": 123})
126
+
127
+ def test_wrong_type_for_number_field_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
128
+ self._setup_createmeta(jira_plus_cloud, {
129
+ "story_points": {"schema": {"type": "number"}, "name": "Story Points"},
130
+ })
131
+
132
+ with pytest.raises(ValueError, match="must be a number"):
133
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"story_points": "five"})
134
+
135
+ def test_invalid_allowed_value_raises_value_error(self, jira_plus_cloud: JiraPlus) -> None:
136
+ self._setup_createmeta(jira_plus_cloud, {
137
+ "priority": {
138
+ "schema": {"type": "string"},
139
+ "name": "Priority",
140
+ "allowedValues": [{"name": "High"}, {"name": "Low"}],
141
+ },
142
+ })
143
+
144
+ with pytest.raises(ValueError, match="Invalid value"):
145
+ jira_plus_cloud.validate_fields("PROJ", "Story", {"priority": "Critical"})
146
+
147
+
148
+ class TestPaginateQueryNew:
149
+ def test_single_page_returns_issues(self, jira_plus_cloud: JiraPlus) -> None:
150
+ issues = [{"key": "PROJ-1"}, {"key": "PROJ-2"}]
151
+ jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {
152
+ "issues": issues,
153
+ "isLast": True,
154
+ }
155
+
156
+ result = jira_plus_cloud._paginate_query_new(
157
+ query="project = PROJ",
158
+ max_results=50,
159
+ specific_fields="*all",
160
+ jira_err_limit=3,
161
+ json_result=True,
162
+ )
163
+
164
+ assert result == issues, f"Expected {issues}, got {result}"
165
+
166
+ def test_multiple_pages_accumulates_all_issues(self, jira_plus_cloud: JiraPlus) -> None:
167
+ page1 = {"issues": [{"key": "PROJ-1"}], "isLast": False, "nextPageToken": "token-2"}
168
+ page2 = {"issues": [{"key": "PROJ-2"}], "isLast": True}
169
+ jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = [page1, page2]
170
+
171
+ result = jira_plus_cloud._paginate_query_new(
172
+ query="project = PROJ",
173
+ max_results=50,
174
+ specific_fields="*all",
175
+ jira_err_limit=3,
176
+ json_result=True,
177
+ )
178
+
179
+ assert result == [{"key": "PROJ-1"}, {"key": "PROJ-2"}], \
180
+ f"Expected both pages merged, got {result}"
181
+
182
+ def test_empty_page_returns_empty_list(self, jira_plus_cloud: JiraPlus) -> None:
183
+ jira_plus_cloud.jira_client.enhanced_search_issues.return_value = {}
184
+
185
+ result = jira_plus_cloud._paginate_query_new(
186
+ query="project = PROJ",
187
+ max_results=50,
188
+ specific_fields="*all",
189
+ jira_err_limit=3,
190
+ json_result=True,
191
+ )
192
+
193
+ assert result == [], f"Expected empty list, got {result}"
194
+
195
+ def test_bad_request_raises_jira_error(self, jira_plus_cloud: JiraPlus) -> None:
196
+ jira_plus_cloud.jira_client.enhanced_search_issues.side_effect = JIRAError(
197
+ "Bad JQL", status_code=400
198
+ )
199
+
200
+ with pytest.raises(JIRAError):
201
+ jira_plus_cloud._paginate_query_new(
202
+ query="invalid JQL !!!",
203
+ max_results=50,
204
+ specific_fields="*all",
205
+ jira_err_limit=3,
206
+ json_result=True,
207
+ )
208
+
209
+
210
+ class TestGetAccountData:
211
+ def test_success_returns_user_list(self, jira_plus_cloud: JiraPlus) -> None:
212
+ mock_response = MagicMock()
213
+ mock_response.json.return_value = [{"accountId": "abc123", "displayName": "Test User"}]
214
+
215
+ with patch("python_jira_plus.jira_plus.requests.get", return_value=mock_response):
216
+ result = jira_plus_cloud.get_account_data("test@test.com")
217
+
218
+ assert result[0]["accountId"] == "abc123", f"Expected 'abc123', got {result[0]['accountId']}"
219
+ mock_response.raise_for_status.assert_called_once()
220
+
221
+ def test_http_error_raises(self, jira_plus_cloud: JiraPlus) -> None:
222
+ mock_response = MagicMock()
223
+ mock_response.raise_for_status.side_effect = Exception("401 Unauthorized")
224
+
225
+ with patch("python_jira_plus.jira_plus.requests.get", return_value=mock_response):
226
+ with pytest.raises(Exception, match="401"):
227
+ jira_plus_cloud.get_account_data("bad@test.com")