"""Integration tests for the jira_cli Lambda handler.""" from typing import Any from unittest.mock import patch import pytest from jira_client.app import handler from jira_client.constants import JQLQueries from tests.integration.conftest import ( MockResponse, _hard_break, _paragraph, _text, make_jira_ticket, make_jira_ticket_single_paragraph, make_query_event, ) def _jira_env(monkeypatch: pytest.MonkeyPatch) -> None: """Set minimal Jira env vars required by JiraConfig.""" monkeypatch.setenv('JIRA_BASE_URL', 'https://jira.example.com') monkeypatch.setenv('JIRA_API_TOKEN', 'tok') monkeypatch.setenv('JIRA_USER_EMAIL', 'bot@example.com') @pytest.mark.integration class TestQueryOffboardingTickets: """Tests for the 'query_offboarding_tickets' action of the Jira CLI handler.""" def test_parses_well_formed_ticket(self, monkeypatch: pytest.MonkeyPatch) -> None: """A complete multi-paragraph ADF ticket is parsed into the expected structure.""" _jira_env(monkeypatch) tickets = [make_jira_ticket('SYS-4821')] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': tickets}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 1 t = result['tickets'][0] assert t['id'] == 'SYS-4821' assert t['email'] == 'jane@example.com' assert t['full_name'] == 'Jane Doe' assert t['last_working_day'] == '2026-04-11' def test_parses_ticket_with_custom_fields( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Custom name / email / date are all correctly extracted.""" _jira_env(monkeypatch) tickets = [ make_jira_ticket( 'SYS-99', full_name='JOHN DOE', email='JOHN.DOE.stage@sonymusic.com', last_working_day='2026-03-09', ) ] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': tickets}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 1 t = result['tickets'][0] assert t['email'] == 'JOHN.DOE.stage@sonymusic.com' assert t['full_name'] == 'John Doe' assert t['last_working_day'] == '2026-03-09' def test_parses_real_world_multi_paragraph_structure( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Ticket matching the exact ADF shape from the scratch file is parsed correctly. The description contains: an EXTERNAL SENDER paragraph, the submission sentence in its own paragraph, and all field values in a single paragraph separated by hardBreak nodes — exactly as the real Jira API now returns. """ _jira_env(monkeypatch) # Build the payload verbatim from the scratch file description_content = [ _paragraph(_text('EXTERNAL SENDER')), _paragraph( _text('An offboarding request has been submitted for JOHN DOE.') ), _paragraph( _text( 'Please ensure all application access is removed, and the account ' 'is fully deprovisioned on their last day of employment, at the ' 'specified date and time listed below.' ) ), _paragraph( _text( 'If the offboarding is scheduled for a future date, a reminder will be sent on their final day.' ), _hard_break(), _text('User ID: MNAT001'), _hard_break(), _text('Employee No: UPD164742'), _hard_break(), _text('Title: Juriste Junior'), _hard_break(), _text('Email: JOHN.DOE.stage@sonymusic.com'), _hard_break(), _text('Department: 50001611 - Legal & Bus Affair'), _hard_break(), _text('Location: France'), _hard_break(), _text('Last day of Employment: 2026-03-09'), _hard_break(), _text('Offboard Time:'), _hard_break(), _text('Litigation On Hold: False'), _hard_break(), _text('Grant Email Access: False'), _hard_break(), _text('Retain Mailbox: True'), _hard_break(), _text('Requestor Name: Manon THIEBAUT'), _hard_break(), _text('Reports to: Lucas Le Borgne'), _hard_break(), _text('Additional Requests / Comments:'), ), _paragraph( _text('Asset Details'), _hard_break(), _text('Device Model Asset Tag Number'), ), _paragraph( _text( 'Please call the Global Technology Service Desk at 212-833-6767 ' 'if you have any questions regarding this request.' ) ), _paragraph( _text( 'This email originated from outside of Sony Music. Do not click ' 'links or open attachments unless you recognize the sender and know ' 'the content is safe.' ), _hard_break(), _text( '[Created via e-mail received from: "Global Technology Service Desk, ' 'Sony Music" ]' ), ), ] ticket = { 'key': 'SYS-5000', 'fields': {'description': {'content': description_content}}, } with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': [ticket]}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 1 t = result['tickets'][0] assert t['id'] == 'SYS-5000' # email extracted is the employee's, not the service-desk address assert t['email'] == 'JOHN.DOE.stage@sonymusic.com' assert t['full_name'] == 'John Doe' assert t['last_working_day'] == '2026-03-09' def test_single_paragraph_old_style_still_works( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Old-style ticket with all text in a single paragraph node is still parsed.""" _jira_env(monkeypatch) tickets = [make_jira_ticket_single_paragraph('SYS-1')] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': tickets}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 1 t = result['tickets'][0] assert t['email'] == 'jane@example.com' assert t['full_name'] == 'Jane Doe' assert t['last_working_day'] == '2026-04-11' def test_multiple_tickets_all_parsed(self, monkeypatch: pytest.MonkeyPatch) -> None: """Multiple valid tickets are all returned.""" _jira_env(monkeypatch) tickets = [ make_jira_ticket('SYS-1'), make_jira_ticket( 'SYS-2', full_name='Bob Smith', email='bob@example.com', last_working_day='2026-05-01', ), ] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': tickets}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 2 ids = {t['id'] for t in result['tickets']} assert ids == {'SYS-1', 'SYS-2'} def test_ticket_with_no_description_skipped( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Ticket with description=None is soft-skipped.""" _jira_env(monkeypatch) bad_ticket: dict[str, Any] = {'key': 'SYS-99', 'fields': {'description': None}} with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': [bad_ticket]}), ): result = handler(make_query_event(), {}) assert result['tickets'] == [] def test_ticket_without_sentinel_text_skipped( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Ticket whose flattened text lacks the sentinel phrase is skipped.""" _jira_env(monkeypatch) ticket = { 'key': 'SYS-20', 'fields': { 'description': { 'content': [ _paragraph(_text('Some unrelated content.')), _paragraph( _text('Email: someone@example.com'), _hard_break(), _text('Last day of Employment: 2026-04-11'), ), ] } }, } with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': [ticket]}), ): result = handler(make_query_event(), {}) assert result['tickets'] == [] def test_ticket_missing_email_skipped( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Ticket whose description text has no email is skipped.""" _jira_env(monkeypatch) tickets = [ make_jira_ticket( 'SYS-10', full_name='Jane Doe', email='not-an-email', # won't match the email regex last_working_day='2026-04-11', ) ] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': tickets}), ): result = handler(make_query_event(), {}) assert result['tickets'] == [] def test_ticket_missing_last_working_day_skipped( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Ticket whose description has no last working day is skipped.""" _jira_env(monkeypatch) ticket = { 'key': 'SYS-11', 'fields': { 'description': { 'content': [ _paragraph( _text( 'An offboarding request has been submitted for Jane Doe.' ), ), _paragraph( _text('Email: jane@example.com'), # no Last day of Employment node ), ] } }, } with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': [ticket]}), ): result = handler(make_query_event(), {}) assert result['tickets'] == [] def test_hard_break_nodes_do_not_cause_errors( self, monkeypatch: pytest.MonkeyPatch ) -> None: """HardBreak nodes (no 'text' key) are silently skipped during flattening.""" _jira_env(monkeypatch) ticket = { 'key': 'SYS-30', 'fields': { 'description': { 'content': [ _paragraph( _text( 'An offboarding request has been submitted for Alice Brown.' ), ), _paragraph( _hard_break(), # leading hardBreak _text('Email: alice@example.com'), _hard_break(), _hard_break(), # consecutive hardBreaks _text('Last day of Employment: 2026-06-30'), _hard_break(), # trailing hardBreak ), ] } }, } with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': [ticket]}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 1 t = result['tickets'][0] assert t['email'] == 'alice@example.com' assert t['full_name'] == 'Alice Brown' assert t['last_working_day'] == '2026-06-30' def test_empty_jira_response(self, monkeypatch: pytest.MonkeyPatch) -> None: """Empty Jira result → tickets: [].""" _jira_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': []}), ): result = handler(make_query_event(), {}) assert result == {'tickets': []} def test_jira_401_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira 401 propagates as an exception (Step Functions task failure).""" _jira_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(401, {}), ): with pytest.raises(Exception): handler(make_query_event(), {}) def test_unknown_action_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """Unknown action raises ValueError.""" _jira_env(monkeypatch) with pytest.raises(ValueError, match='Unknown action'): handler({'action': 'bogus', 'dry_run': False}, {}) @pytest.mark.integration class TestQueryApprovedTickets: """Tests for the 'query-approved-tickets' action of the Jira CLI handler.""" def test_returns_parsed_approved_tickets( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Approved tickets are parsed into the expected structure.""" _jira_env(monkeypatch) tickets = [ make_jira_ticket( 'SYS-100', full_name='Alice Brown', email='alice@example.com', last_working_day='2026-01-15', ) ] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': tickets}), ): result = handler({'action': 'query-approved-tickets', 'dry_run': False}, {}) assert len(result['tickets']) == 1 t = result['tickets'][0] assert t['id'] == 'SYS-100' assert t['email'] == 'alice@example.com' assert t['full_name'] == 'Alice Brown' assert t['last_working_day'] == '2026-01-15' def test_empty_approved_response(self, monkeypatch: pytest.MonkeyPatch) -> None: """Empty Jira result for approved query → tickets: [].""" _jira_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': []}), ): result = handler({'action': 'query-approved-tickets', 'dry_run': False}, {}) assert result == {'tickets': []} def test_uses_approved_jql_query(self, monkeypatch: pytest.MonkeyPatch) -> None: """query-approved-tickets passes APPROVED_OFFBOARDING_TICKETS JQL to the client.""" _jira_env(monkeypatch) with patch( 'jira_client.jira_client.JiraClient.query_jira_tickets', return_value=[] ) as mock_query: handler({'action': 'query-approved-tickets', 'dry_run': False}, {}) mock_query.assert_called_once_with(JQLQueries.APPROVED_OFFBOARDING_TICKETS) def test_jira_401_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira 401 on approved query propagates as an exception.""" _jira_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(401, {}), ): with pytest.raises(Exception): handler({'action': 'query-approved-tickets', 'dry_run': False}, {}) @pytest.mark.integration class TestQueryCompletedTickets: """Tests for the 'query-completed-tickets' action of the Jira CLI handler.""" def test_returns_completed_ticket_ids( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Completed tickets are returned as key-only entries.""" _jira_env(monkeypatch) issues = [{'key': 'SYS-100'}, {'key': 'SYS-101'}] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': issues}), ): result = handler( {'action': 'query-completed-tickets', 'dry_run': False}, {} ) assert result == {'tickets': [{'id': 'SYS-100'}, {'id': 'SYS-101'}]} def test_ticket_without_description_still_returned( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A completed ticket with no parseable description is still returned. This is the regression guard proving the completed-tickets handler does NOT reuse the description-parsing path (which soft-skips such tickets). """ _jira_env(monkeypatch) issues = [{'key': 'SYS-200', 'fields': {}}] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': issues}), ): result = handler( {'action': 'query-completed-tickets', 'dry_run': False}, {} ) assert result == {'tickets': [{'id': 'SYS-200'}]} def test_empty_completed_response(self, monkeypatch: pytest.MonkeyPatch) -> None: """Empty Jira result for completed query → tickets: [].""" _jira_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': []}), ): result = handler( {'action': 'query-completed-tickets', 'dry_run': False}, {} ) assert result == {'tickets': []} def test_uses_completed_jql_query(self, monkeypatch: pytest.MonkeyPatch) -> None: """query-completed-tickets passes COMPLETED_TICKETS JQL to the client.""" _jira_env(monkeypatch) with patch( 'jira_client.jira_client.JiraClient.query_jira_tickets', return_value=[] ) as mock_query: handler({'action': 'query-completed-tickets', 'dry_run': False}, {}) mock_query.assert_called_once_with(JQLQueries.COMPLETED_TICKETS) def test_jira_401_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira 401 on completed query propagates as an exception.""" _jira_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(401, {}), ): with pytest.raises(Exception): handler({'action': 'query-completed-tickets', 'dry_run': False}, {}) def test_completed_jql_covers_all_terminal_labels(self) -> None: """COMPLETED_TICKETS targets SYS, all four terminal labels, and open status.""" jql = JQLQueries.COMPLETED_TICKETS assert 'project = SYS' in jql assert 'automation-complete' in jql assert 'suspension-complete' in jql assert 'no-actions-required' in jql assert 'suspend-no-actions-required' in jql assert 'status != "Closed"' in jql @pytest.mark.integration class TestAddComment: """Tests for the 'add-comment' action of the Jira CLI handler.""" def test_add_comment_posts_to_jira(self, monkeypatch: pytest.MonkeyPatch) -> None: """A successful add-comment call returns commented=True.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.add_comment') as mock_add: result = handler( { 'action': 'add-comment', 'ticket_id': 'SYS-1', 'comment': 'Offboarding complete.', 'dry_run': False, }, {}, ) mock_add.assert_called_once_with('SYS-1', 'Offboarding complete.') assert result == {'ticket_id': 'SYS-1', 'dry_run': False, 'commented': True} def test_add_comment_dry_run_skips_api( self, monkeypatch: pytest.MonkeyPatch ) -> None: """dry_run=True returns commented=False without calling the Jira API.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.add_comment') as mock_add: result = handler( { 'action': 'add-comment', 'ticket_id': 'SYS-1', 'comment': 'test', 'dry_run': True, }, {}, ) mock_add.assert_not_called() assert result == {'ticket_id': 'SYS-1', 'dry_run': True, 'commented': False} def test_add_comment_jira_error_propagates( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Jira API errors propagate as exceptions (Step Functions task failure).""" _jira_env(monkeypatch) with patch( 'jira_client.jira_client.JiraClient.add_comment', side_effect=Exception('Jira down'), ): with pytest.raises(Exception, match='Jira down'): handler( { 'action': 'add-comment', 'ticket_id': 'SYS-1', 'comment': 'test', 'dry_run': False, }, {}, ) @pytest.mark.integration class TestAddDueDate: """Tests for the 'add-due-date' action of the Jira CLI handler.""" def test_add_due_date_sets_field(self, monkeypatch: pytest.MonkeyPatch) -> None: """A successful add-due-date call returns due_date_set=True.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.add_due_date') as mock_add: result = handler( { 'action': 'add-due-date', 'ticket_id': 'SYS-1', 'due_date': '2026-04-11', 'dry_run': False, }, {}, ) mock_add.assert_called_once_with('SYS-1', '2026-04-11') assert result == { 'ticket_id': 'SYS-1', 'dry_run': False, 'due_date_set': True, } def test_add_due_date_dry_run_skips_api( self, monkeypatch: pytest.MonkeyPatch ) -> None: """dry_run=True returns due_date_set=False without calling the Jira API.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.add_due_date') as mock_add: result = handler( { 'action': 'add-due-date', 'ticket_id': 'SYS-1', 'due_date': '2026-04-11', 'dry_run': True, }, {}, ) mock_add.assert_not_called() assert result == { 'ticket_id': 'SYS-1', 'dry_run': True, 'due_date_set': False, } def test_add_due_date_jira_error_propagates( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Jira API errors propagate as exceptions.""" _jira_env(monkeypatch) with patch( 'jira_client.jira_client.JiraClient.add_due_date', side_effect=Exception('Jira down'), ): with pytest.raises(Exception, match='Jira down'): handler( { 'action': 'add-due-date', 'ticket_id': 'SYS-1', 'due_date': '2026-04-11', 'dry_run': False, }, {}, ) @pytest.mark.integration class TestAddLabel: """Tests for the 'add-label' action of the Jira CLI handler.""" def test_add_label_applies_label(self, monkeypatch: pytest.MonkeyPatch) -> None: """A successful add-label call returns label_added=True.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.add_label') as mock_add: result = handler( { 'action': 'add-label', 'ticket_id': 'SYS-1', 'label': 'automation-complete', 'dry_run': False, }, {}, ) mock_add.assert_called_once_with('SYS-1', 'automation-complete') assert result == { 'ticket_id': 'SYS-1', 'dry_run': False, 'label_added': True, } def test_add_label_dry_run_skips_api(self, monkeypatch: pytest.MonkeyPatch) -> None: """dry_run=True returns label_added=False without calling the Jira API.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.add_label') as mock_add: result = handler( { 'action': 'add-label', 'ticket_id': 'SYS-1', 'label': 'no-actions-required', 'dry_run': True, }, {}, ) mock_add.assert_not_called() assert result == { 'ticket_id': 'SYS-1', 'dry_run': True, 'label_added': False, } def test_add_label_jira_error_propagates( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Jira API errors propagate as exceptions.""" _jira_env(monkeypatch) with patch( 'jira_client.jira_client.JiraClient.add_label', side_effect=Exception('Jira down'), ): with pytest.raises(Exception, match='Jira down'): handler( { 'action': 'add-label', 'ticket_id': 'SYS-1', 'label': 'no-actions-required', 'dry_run': False, }, {}, ) @pytest.mark.integration class TestCloseTicket: """Tests for the 'close-ticket' action of the Jira CLI handler.""" def test_close_ticket_transitions_ticket( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A successful close-ticket call returns closed=True.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.close_ticket') as mock_close: result = handler( { 'action': 'close-ticket', 'ticket_id': 'SYS-1', 'target_status': 'Done', 'dry_run': False, }, {}, ) mock_close.assert_called_once_with('SYS-1', 'Done') assert result == {'ticket_id': 'SYS-1', 'dry_run': False, 'closed': True} def test_close_ticket_default_target_status( self, monkeypatch: pytest.MonkeyPatch ) -> None: """target_status defaults to 'Closed' when omitted from the event.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.close_ticket') as mock_close: handler( {'action': 'close-ticket', 'ticket_id': 'SYS-1', 'dry_run': False}, {} ) mock_close.assert_called_once_with('SYS-1', 'Closed') def test_close_ticket_dry_run_skips_api( self, monkeypatch: pytest.MonkeyPatch ) -> None: """dry_run=True returns closed=False without calling the Jira API.""" _jira_env(monkeypatch) with patch('jira_client.jira_client.JiraClient.close_ticket') as mock_close: result = handler( { 'action': 'close-ticket', 'ticket_id': 'SYS-1', 'target_status': 'Done', 'dry_run': True, }, {}, ) mock_close.assert_not_called() assert result == {'ticket_id': 'SYS-1', 'dry_run': True, 'closed': False} def test_close_ticket_jira_error_propagates( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Jira API errors propagate as exceptions.""" _jira_env(monkeypatch) with patch( 'jira_client.jira_client.JiraClient.close_ticket', side_effect=Exception('Jira down'), ): with pytest.raises(Exception, match='Jira down'): handler( { 'action': 'close-ticket', 'ticket_id': 'SYS-1', 'target_status': 'Done', 'dry_run': False, }, {}, ) @pytest.mark.integration class TestParsingFixes: """Tests for parsing improvements: full name regex and sentinel flexibility.""" def test_middle_initial_preserves_last_name( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Full name with a middle initial is parsed correctly — last name not dropped.""" _jira_env(monkeypatch) tickets = [make_jira_ticket('SYS-200', full_name='John A. Doe')] with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': tickets}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 1 assert result['tickets'][0]['full_name'] == 'John A. Doe' def test_sentinel_matched_with_past_tense( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Tickets using past-tense 'was submitted' are no longer skipped.""" _jira_env(monkeypatch) ticket = { 'key': 'SYS-201', 'fields': { 'description': { 'content': [ _paragraph( _text('An offboarding request was submitted for Bob Smith.') ), _paragraph( _text('Email: bob@example.com'), _hard_break(), _text('Last day of Employment: 2026-05-01'), ), ] } }, } with patch( 'requests.Session.get', return_value=MockResponse(200, {'issues': [ticket]}), ): result = handler(make_query_event(), {}) assert len(result['tickets']) == 1 t = result['tickets'][0] assert t['full_name'] == 'Bob Smith' assert t['email'] == 'bob@example.com' @pytest.mark.integration class TestDueDateValidation: """Tests for add-due-date input validation.""" def test_invalid_date_format_rejected( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A due_date not in YYYY-MM-DD format raises a validation error.""" from pydantic import ValidationError _jira_env(monkeypatch) with pytest.raises(ValidationError): handler( { 'action': 'add-due-date', 'ticket_id': 'SYS-1', 'due_date': '04-11-2026', # wrong format 'dry_run': False, }, {}, )