"""Unit tests for jira_client.py.""" from typing import Any import requests from config import JiraConfig from pytest import MonkeyPatch, raises from requests.auth import HTTPBasicAuth from jira_client import JiraClient class MockResponse: """Simple mock of requests.Response.""" def __init__(self, status_code: int = 200, json_data: Any = None) -> None: """Initialize the mock response.""" self.status_code = status_code self._json_data = json_data or {} def raise_for_status(self) -> None: """Raise HTTPError for non-2xx status codes.""" if not (200 <= self.status_code < 300): raise requests.HTTPError(f'{self.status_code} error') def json(self) -> dict: """Return the JSON data.""" return self._json_data def make_jira_config() -> JiraConfig: """Create a sample JiraConfig for testing.""" return JiraConfig( jira_base_url='https://jira.example.com', jira_user_email='testuser', jira_api_token='secret-token', ) def test_jira_client_initialization(monkeypatch: MonkeyPatch) -> None: """Session is configured with auth, headers and retry adapter.""" cfg = make_jira_config() client = JiraClient(cfg) # Auth assert isinstance(client._session.auth, HTTPBasicAuth) assert client._session.auth.username == cfg.jira_user_email assert client._session.auth.password == cfg.jira_api_token # Headers assert client._session.headers.get('Accept') == 'application/json' # Adapter for https:// adapter = client._session.adapters.get('https://') assert adapter is not None, 'HTTPAdapter for https:// was not mounted' def test_query_jira_tickets_success(monkeypatch: MonkeyPatch) -> None: """A successful query returns the list of issues.""" cfg = make_jira_config() client = JiraClient(cfg) expected_issues = [{'id': '1', 'key': 'ABC-123'}] mock_resp = MockResponse(json_data={'issues': expected_issues}) def mock_get(url: str, params: Any = None, timeout: Any = None) -> MockResponse: # noqa: E501 assert url == f'{cfg.jira_base_url}/rest/api/3/search/jql' assert params['jql'] == 'project=ABC' assert params['fields'] == 'id,summary,description,key' assert params['maxResults'] == '500' assert timeout == 30 return mock_resp monkeypatch.setattr(client._session, 'get', mock_get) result = client.query_jira_tickets('project=ABC') assert result == expected_issues def test_query_jira_tickets_no_issues(monkeypatch: MonkeyPatch) -> None: """If the JSON payload contains no 'issues' key, an empty list is returned.""" cfg = make_jira_config() client = JiraClient(cfg) mock_resp = MockResponse(json_data={'noissues': []}) def mock_get(url: str, params: Any = None, timeout: Any = None) -> MockResponse: # noqa: E501 return mock_resp monkeypatch.setattr(client._session, 'get', mock_get) result = client.query_jira_tickets('project=ABC') assert result == [] def test_query_jira_tickets_http_error(monkeypatch: MonkeyPatch) -> None: """HTTP errors are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) mock_resp = MockResponse(status_code=404, json_data={}) def mock_get(url: str, params: Any = None, timeout: Any = None) -> MockResponse: # noqa: E501 return mock_resp monkeypatch.setattr(client._session, 'get', mock_get) with raises(requests.HTTPError): client.query_jira_tickets('project=ABC') # --------------------------------------------------------------------------- # add_comment # --------------------------------------------------------------------------- def test_add_comment_success(monkeypatch: MonkeyPatch) -> None: """A 201 response from Jira is treated as success.""" cfg = make_jira_config() client = JiraClient(cfg) captured: dict[str, Any] = {} def mock_post(url: str, json: Any = None, timeout: Any = None) -> MockResponse: captured['url'] = url captured['json'] = json captured['timeout'] = timeout return MockResponse(status_code=201) monkeypatch.setattr(client._session, 'post', mock_post) client.add_comment('SYS-1', 'Automation complete.') assert captured['url'] == f'{cfg.jira_base_url}/rest/api/3/issue/SYS-1/comment' assert captured['timeout'] == 30 body = captured['json']['body'] assert body['type'] == 'doc' assert body['content'][0]['type'] == 'paragraph' assert body['content'][0]['content'][0]['text'] == 'Automation complete.' def test_add_comment_401_raises(monkeypatch: MonkeyPatch) -> None: """A 401 response raises HTTPError with an auth-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'post', lambda *a, **kw: MockResponse(status_code=401), ) with raises(requests.HTTPError, match='authentication failed'): client.add_comment('SYS-1', 'test') def test_add_comment_403_raises(monkeypatch: MonkeyPatch) -> None: """A 403 response raises HTTPError with a permissions-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'post', lambda *a, **kw: MockResponse(status_code=403), ) with raises(requests.HTTPError, match='access denied'): client.add_comment('SYS-1', 'test') def test_add_comment_network_error_raises(monkeypatch: MonkeyPatch) -> None: """Network errors from requests are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) def raise_network(*a: Any, **kw: Any) -> None: raise requests.exceptions.ConnectionError('timeout') monkeypatch.setattr(client._session, 'post', raise_network) with raises(requests.exceptions.ConnectionError): client.add_comment('SYS-1', 'test') # --------------------------------------------------------------------------- # add_due_date # --------------------------------------------------------------------------- def test_add_due_date_success(monkeypatch: MonkeyPatch) -> None: """A 204 response from Jira is treated as success.""" cfg = make_jira_config() client = JiraClient(cfg) captured: dict[str, Any] = {} def mock_put(url: str, json: Any = None, timeout: Any = None) -> MockResponse: captured['url'] = url captured['json'] = json captured['timeout'] = timeout return MockResponse(status_code=204) monkeypatch.setattr(client._session, 'put', mock_put) client.add_due_date('SYS-1', '2026-04-11') assert captured['url'] == f'{cfg.jira_base_url}/rest/api/3/issue/SYS-1' assert captured['timeout'] == 30 assert captured['json'] == {'fields': {'duedate': '2026-04-11'}} def test_add_due_date_401_raises(monkeypatch: MonkeyPatch) -> None: """A 401 response raises HTTPError with an auth-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'put', lambda *a, **kw: MockResponse(status_code=401), ) with raises(requests.HTTPError, match='authentication failed'): client.add_due_date('SYS-1', '2026-04-11') def test_add_due_date_403_raises(monkeypatch: MonkeyPatch) -> None: """A 403 response raises HTTPError with a permissions-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'put', lambda *a, **kw: MockResponse(status_code=403), ) with raises(requests.HTTPError, match='access denied'): client.add_due_date('SYS-1', '2026-04-11') def test_add_due_date_network_error_raises(monkeypatch: MonkeyPatch) -> None: """Network errors from requests are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) def raise_network(*a: Any, **kw: Any) -> None: raise requests.exceptions.ConnectionError('timeout') monkeypatch.setattr(client._session, 'put', raise_network) with raises(requests.exceptions.ConnectionError): client.add_due_date('SYS-1', '2026-04-11') # --------------------------------------------------------------------------- # add_label # --------------------------------------------------------------------------- def test_add_label_success(monkeypatch: MonkeyPatch) -> None: """A 204 response from Jira is treated as success and uses the update verb.""" cfg = make_jira_config() client = JiraClient(cfg) captured: dict[str, Any] = {} def mock_put(url: str, json: Any = None, timeout: Any = None) -> MockResponse: captured['url'] = url captured['json'] = json captured['timeout'] = timeout return MockResponse(status_code=204) monkeypatch.setattr(client._session, 'put', mock_put) client.add_label('SYS-1', 'automation-complete') assert captured['url'] == f'{cfg.jira_base_url}/rest/api/3/issue/SYS-1' assert captured['timeout'] == 30 assert captured['json'] == {'update': {'labels': [{'add': 'automation-complete'}]}} def test_add_label_401_raises(monkeypatch: MonkeyPatch) -> None: """A 401 response raises HTTPError with an auth-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'put', lambda *a, **kw: MockResponse(status_code=401), ) with raises(requests.HTTPError, match='authentication failed'): client.add_label('SYS-1', 'automation-complete') def test_add_label_403_raises(monkeypatch: MonkeyPatch) -> None: """A 403 response raises HTTPError with a permissions-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'put', lambda *a, **kw: MockResponse(status_code=403), ) with raises(requests.HTTPError, match='access denied'): client.add_label('SYS-1', 'automation-complete') def test_add_label_network_error_raises(monkeypatch: MonkeyPatch) -> None: """Network errors from requests are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) def raise_network(*a: Any, **kw: Any) -> None: raise requests.exceptions.ConnectionError('timeout') monkeypatch.setattr(client._session, 'put', raise_network) with raises(requests.exceptions.ConnectionError): client.add_label('SYS-1', 'automation-complete') # --------------------------------------------------------------------------- # get_transitions / close_ticket # --------------------------------------------------------------------------- def test_get_transitions_success(monkeypatch: MonkeyPatch) -> None: """A successful call returns the transitions list.""" cfg = make_jira_config() client = JiraClient(cfg) expected_transitions = [{'id': '31', 'name': 'Close', 'to': {'name': 'Done'}}] captured: dict[str, Any] = {} def mock_get(url: str, timeout: Any = None) -> MockResponse: captured['url'] = url captured['timeout'] = timeout return MockResponse(json_data={'transitions': expected_transitions}) monkeypatch.setattr(client._session, 'get', mock_get) result = client.get_transitions('SYS-1') assert captured['url'] == f'{cfg.jira_base_url}/rest/api/3/issue/SYS-1/transitions' assert captured['timeout'] == 30 assert result == expected_transitions def test_get_transitions_missing_key(monkeypatch: MonkeyPatch) -> None: """If the response has no 'transitions' key, an empty list is returned.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(json_data={}) ) assert client.get_transitions('SYS-1') == [] def test_get_transitions_401_raises(monkeypatch: MonkeyPatch) -> None: """A 401 response raises HTTPError with an auth-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(status_code=401), ) with raises(requests.HTTPError, match='authentication failed'): client.get_transitions('SYS-1') def test_get_transitions_network_error_raises(monkeypatch: MonkeyPatch) -> None: """Network errors from requests are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) def raise_network(*a: Any, **kw: Any) -> None: raise requests.exceptions.ConnectionError('timeout') monkeypatch.setattr(client._session, 'get', raise_network) with raises(requests.exceptions.ConnectionError): client.get_transitions('SYS-1') def test_close_ticket_success(monkeypatch: MonkeyPatch) -> None: """The transition matching the target status by name is executed.""" cfg = make_jira_config() client = JiraClient(cfg) transitions = [ {'id': '21', 'name': 'In Progress', 'to': {'name': 'In Progress'}}, {'id': '31', 'name': 'Close', 'to': {'name': 'Done'}}, ] monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(json_data={'transitions': transitions}), ) captured: dict[str, Any] = {} def mock_post(url: str, json: Any = None, timeout: Any = None) -> MockResponse: captured['url'] = url captured['json'] = json captured['timeout'] = timeout return MockResponse(status_code=204) monkeypatch.setattr(client._session, 'post', mock_post) client.close_ticket('SYS-1', 'Done') assert captured['url'] == f'{cfg.jira_base_url}/rest/api/3/issue/SYS-1/transitions' assert captured['timeout'] == 30 assert captured['json'] == {'transition': {'id': '31'}} def test_close_ticket_status_match_is_case_insensitive( monkeypatch: MonkeyPatch, ) -> None: """Target status matching ignores case.""" cfg = make_jira_config() client = JiraClient(cfg) transitions = [{'id': '31', 'name': 'Close', 'to': {'name': 'Done'}}] monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(json_data={'transitions': transitions}), ) captured: dict[str, Any] = {} def mock_post(url: str, json: Any = None, timeout: Any = None) -> MockResponse: captured['json'] = json return MockResponse(status_code=204) monkeypatch.setattr(client._session, 'post', mock_post) client.close_ticket('SYS-1', 'done') assert captured['json'] == {'transition': {'id': '31'}} def test_close_ticket_no_matching_transition_raises(monkeypatch: MonkeyPatch) -> None: """A ValueError is raised when no transition leads to the target status. Only raises when the ticket is not already at the target status. Here the ticket is at 'In Progress' while the target is 'Done', so it raises. """ cfg = make_jira_config() client = JiraClient(cfg) transitions = [{'id': '21', 'name': 'In Progress', 'to': {'name': 'In Progress'}}] def mock_get(url: str, params: Any = None, timeout: Any = None) -> MockResponse: if url.endswith('/transitions'): return MockResponse(json_data={'transitions': transitions}) return MockResponse(json_data={'fields': {'status': {'name': 'In Progress'}}}) monkeypatch.setattr(client._session, 'get', mock_get) with raises(ValueError, match='No transition to status'): client.close_ticket('SYS-1', 'Done') def test_close_ticket_already_at_target_is_noop(monkeypatch: MonkeyPatch) -> None: """No matching transition but already at target → no-op success (no POST).""" cfg = make_jira_config() client = JiraClient(cfg) # No transition leads to 'Closed', but the ticket is already Closed. transitions = [{'id': '21', 'name': 'Reopen', 'to': {'name': 'Backlog'}}] def mock_get(url: str, params: Any = None, timeout: Any = None) -> MockResponse: if url.endswith('/transitions'): return MockResponse(json_data={'transitions': transitions}) assert params == {'fields': 'status'} return MockResponse(json_data={'fields': {'status': {'name': 'Closed'}}}) monkeypatch.setattr(client._session, 'get', mock_get) def mock_post(*a: Any, **kw: Any) -> MockResponse: raise AssertionError('close_ticket must not POST when already at target') monkeypatch.setattr(client._session, 'post', mock_post) # Returns None without raising. assert client.close_ticket('SYS-1', 'Closed') is None def test_close_ticket_already_at_target_case_insensitive( monkeypatch: MonkeyPatch, ) -> None: """The already-at-target no-op check ignores case.""" cfg = make_jira_config() client = JiraClient(cfg) transitions = [{'id': '21', 'name': 'Reopen', 'to': {'name': 'Backlog'}}] def mock_get(url: str, params: Any = None, timeout: Any = None) -> MockResponse: if url.endswith('/transitions'): return MockResponse(json_data={'transitions': transitions}) return MockResponse(json_data={'fields': {'status': {'name': 'closed'}}}) monkeypatch.setattr(client._session, 'get', mock_get) monkeypatch.setattr( client._session, 'post', lambda *a, **kw: (_ for _ in ()).throw(AssertionError('unexpected POST')), ) assert client.close_ticket('SYS-1', 'Closed') is None def test_close_ticket_401_raises(monkeypatch: MonkeyPatch) -> None: """A 401 response raises HTTPError with an auth-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) transitions = [{'id': '31', 'name': 'Close', 'to': {'name': 'Done'}}] monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(json_data={'transitions': transitions}), ) monkeypatch.setattr( client._session, 'post', lambda *a, **kw: MockResponse(status_code=401), ) with raises(requests.HTTPError, match='authentication failed'): client.close_ticket('SYS-1', 'Done') def test_close_ticket_403_raises(monkeypatch: MonkeyPatch) -> None: """A 403 response raises HTTPError with a permissions-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) transitions = [{'id': '31', 'name': 'Close', 'to': {'name': 'Done'}}] monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(json_data={'transitions': transitions}), ) monkeypatch.setattr( client._session, 'post', lambda *a, **kw: MockResponse(status_code=403), ) with raises(requests.HTTPError, match='access denied'): client.close_ticket('SYS-1', 'Done') def test_close_ticket_network_error_raises(monkeypatch: MonkeyPatch) -> None: """Network errors from requests are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) transitions = [{'id': '31', 'name': 'Close', 'to': {'name': 'Done'}}] monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(json_data={'transitions': transitions}), ) def raise_network(*a: Any, **kw: Any) -> None: raise requests.exceptions.ConnectionError('timeout') monkeypatch.setattr(client._session, 'post', raise_network) with raises(requests.exceptions.ConnectionError): client.close_ticket('SYS-1', 'Done') # --------------------------------------------------------------------------- # get_ticket_fields # --------------------------------------------------------------------------- def test_get_ticket_fields_success(monkeypatch: MonkeyPatch) -> None: """A successful call returns the fields dict and uses the correct URL and params.""" cfg = make_jira_config() client = JiraClient(cfg) captured: dict[str, Any] = {} fields_data = {'reporter': {'emailAddress': 'user@example.com'}} def mock_get(url: str, params: Any = None, timeout: Any = None) -> MockResponse: captured['url'] = url captured['params'] = params captured['timeout'] = timeout return MockResponse(json_data={'fields': fields_data}) monkeypatch.setattr(client._session, 'get', mock_get) result = client.get_ticket_fields('SYS-1', 'reporter,description') assert captured['url'] == f'{cfg.jira_base_url}/rest/api/3/issue/SYS-1' assert captured['params'] == {'fields': 'reporter,description'} assert captured['timeout'] == 30 assert result == fields_data def test_get_ticket_fields_missing_fields_key(monkeypatch: MonkeyPatch) -> None: """If the response has no 'fields' key, an empty dict is returned.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(json_data={}), ) assert client.get_ticket_fields('SYS-1', 'reporter') == {} def test_get_ticket_fields_401_raises(monkeypatch: MonkeyPatch) -> None: """A 401 response raises HTTPError with an auth-specific message.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(status_code=401), ) with raises(requests.HTTPError, match='authentication failed'): client.get_ticket_fields('SYS-1', 'reporter') def test_get_ticket_fields_http_error_raises(monkeypatch: MonkeyPatch) -> None: """Non-auth HTTP errors are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) monkeypatch.setattr( client._session, 'get', lambda *a, **kw: MockResponse(status_code=404), ) with raises(requests.HTTPError): client.get_ticket_fields('SYS-1', 'reporter') def test_get_ticket_fields_network_error_raises(monkeypatch: MonkeyPatch) -> None: """Network errors from requests are propagated to the caller.""" cfg = make_jira_config() client = JiraClient(cfg) def raise_network(*a: Any, **kw: Any) -> None: raise requests.exceptions.ConnectionError('timeout') monkeypatch.setattr(client._session, 'get', raise_network) with raises(requests.exceptions.ConnectionError): client.get_ticket_fields('SYS-1', 'reporter')