"""Functional tests for AbacusEvent.""" import base64 import json from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from abacus_common_logic.connectors.database import db from abacus_common_logic.utils.dates import current_timestamp, safe_format_datetime from sqlalchemy import select from werkzeug.exceptions import BadRequest from abacus_event.constants.constants import ( ALLOWED_EVENTS_FOR_RETRY, DATALOADER_BATCH_LIMIT, EVENT_NAMES, TARGET_TYPES, ) from abacus_event.models.abacus_event import AbacusEvent from tests.utils.factories import AbacusEventFactory @pytest.mark.db('mysql') @patch('abacus_event.logic.abacus_event.handle_event_actions') def test_create_abacus_event( handle_event_mock, fixture_client, ): """Test successful creation of abacus event.""" event_name = EVENT_NAMES.ACCOUNTING_RUN_COMMIT post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': event_name, 'target_id': 1337, 'target_type': 'accounting_run', } res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 201 assert res.json['abacus_event_id'] assert to_datetime(res.json['event_date']) == to_datetime(post_body['event_date']) assert res.json['event_name'] == post_body['event_name'] assert res.json['statement_period_id'] assert res.json['target_id'] == post_body['target_id'] assert res.json['target_type'] == post_body['target_type'] assert res.json['created_by'] == 'default_user_id' handle_event_mock.assert_called_once() @patch('abacus_event.logic.abacus_event.handle_event_actions') def test_create_abacus_event_with_statement_period(handle_event_mock, fixture_client): """Test successful creation of abacus event with a statement_period_id.""" event_name = EVENT_NAMES.ACCOUNTING_RUN_COMMIT post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': event_name, 'statement_period_id': 282, 'target_id': 1337, 'target_type': 'accounting_run', } res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 201 assert res.json['abacus_event_id'] assert to_datetime(res.json['event_date']) == to_datetime(post_body['event_date']) assert res.json['event_name'] == post_body['event_name'] assert res.json['statement_period_id'] == post_body['statement_period_id'] assert res.json['target_id'] == post_body['target_id'] assert res.json['target_type'] == post_body['target_type'] handle_event_mock.assert_called_once() @patch('abacus_event.logic.abacus_event.handle_event_actions') @patch('abacus_event.connectors.aws.boto3') def test_create_abacus_event_invokes_lambda_by_target_type( mock_boto, mock_handle_airflow_event, fixture_client ): """Test successful creation of abacus event triggers lambdas by target type.""" event_name = EVENT_NAMES.SEND_PAYMENTS target_type = TARGET_TYPES.WORKSHEET_PAYMENT_CONTRACT_ADVANCE post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': event_name, 'statement_period_id': 282, 'target_id': 1, 'target_type': target_type, } mock_lambda_client = MagicMock() mock_boto.client.return_value = mock_lambda_client mock_lambda_client.invoke.return_value = 'SUCCESS' res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 201 mock_boto.client.assert_called_once() mock_lambda_client.invoke.assert_called_once() mock_handle_airflow_event.assert_not_called() @patch('abacus_event.logic.abacus_event.handle_event_actions') @patch('abacus_event.connectors.aws.boto3') def test_create_abacus_event_invokes_lambda( mock_boto, mock_handle_airflow_event, fixture_client ): """Test successful creation of abacus event triggers lambdas when specified.""" event_name = EVENT_NAMES.LEGACY_SYNC_CONTRACT post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': event_name, 'statement_period_id': 282, 'target_id': 1, 'target_type': 'contract', } mock_lambda_client = MagicMock() mock_boto.client.return_value = mock_lambda_client mock_lambda_client.invoke.return_value = 'SUCCESS' res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 201 mock_boto.client.assert_called_once() mock_lambda_client.invoke.assert_called_once() mock_handle_airflow_event.assert_not_called() def test_unknown_event_returns_400(request_engine, fixture_client): """Test failing when the event is unknown.""" event_name = 'foo' post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': event_name, 'target_id': 1337, 'target_type': 'accounting_run', } res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 400 print(res, res.data) @patch('abacus_event.connectors.airflow.ensure_dag_not_running_in_aws') def test_create_event_fails_when_dag_is_in_progress(ensure_mock, fixture_client): """Test failing when dag is in progress.""" post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': EVENT_NAMES.ACCOUNTING_RUN_CALCULATE, 'target_id': 1337, 'target_type': 'accounting_run', } ensure_mock.side_effect = BadRequest('Dag is already running') res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 400 assert res.json['message'] == 'Dag is already running' stmt = select(AbacusEvent) assert db.session.execute(stmt).scalars().first().rolled_back_at @pytest.mark.db('mysql') @patch('abacus_event.connectors.airflow.requests') @patch('abacus_event.connectors.airflow.boto3') @patch('abacus_event.connectors.airflow.ensure_dag_not_running_in_aws') def test_create_event_for_aws_airflow_dag( ensure_mock, boto_mock, requests_mock, fixture_client ): """Test creating an event and triggering the dag in AWS Airflow.""" client_mock = MagicMock() client_mock.create_cli_token.return_value = { 'WebServerHostname': 'mock-host-name', 'CliToken': 'mock-token', } boto_mock.client.return_value = client_mock result_mock = MagicMock(status_code=200) result_mock.json.return_value = { 'stderr': '', 'stdout': base64.b64encode(b'success'), } requests_mock.post.return_value = result_mock post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': 'accounting_run_calculate', 'target_id': 1, 'target_type': 'accounting_run', } res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 201 assert res.json['abacus_event_id'] == 1 assert to_datetime(res.json['event_date']) == to_datetime(post_body['event_date']) assert res.json['event_name'] == post_body['event_name'] assert res.json['statement_period_id'] assert res.json['target_id'] == post_body['target_id'] assert res.json['target_type'] == post_body['target_type'] assert res.json['created_by'] == 'default_user_id' event_config = json.dumps(res.json) cli_command = f"""dags trigger -c '{event_config}' accounting_run_calculate""" ensure_mock.assert_called_once_with('accounting_run_calculate') requests_mock.post.assert_called_once_with( 'https://mock-host-name/aws_mwaa/cli', data=cli_command, headers={'Authorization': 'Bearer mock-token', 'Content-Type': 'text/plain'}, ) @patch('abacus_event.connectors.airflow.requests') @patch('abacus_event.connectors.airflow.boto3') @patch('abacus_event.connectors.airflow.ensure_dag_not_running_in_aws') def test_create_duplicate_apply_pending_adjustments_event_succeeds( ensure_mock, boto_mock, requests_mock, fixture_client ): """Test duplicate apply_pending_adjustments event for retry scenarios.""" client_mock = MagicMock() client_mock.create_cli_token.return_value = { 'WebServerHostname': 'mock-host-name', 'CliToken': 'mock-token', } boto_mock.client.return_value = client_mock result_mock = MagicMock(status_code=200) result_mock.json.return_value = { 'stderr': '', 'stdout': base64.b64encode(b'success'), } requests_mock.post.return_value = result_mock event_name = EVENT_NAMES.APPLY_PENDING_ADJUSTMENTS target_id = 123 target_type = TARGET_TYPES.STATEMENT_PERIOD_ADJUSTMENT_FILE # Create first event first_event = AbacusEventFactory.create( event_name=event_name, target_id=target_id, target_type=target_type ) post_body = { 'event_date': '2020-05-04T11:36:10.000001', 'event_name': event_name, 'target_id': target_id, 'target_type': target_type, } # Creating duplicate should succeed to allow retry functionality res = fixture_client.post('/abacus-event', json=post_body) assert res.status_code == 201 assert res.json['event_name'] == event_name assert res.json['target_id'] == target_id assert res.json['target_type'] == target_type assert res.json['abacus_event_id'] != first_event.abacus_event_id ensure_mock.assert_called_once() def test_get_payment_groups_approval_count(fixture_client): """Test endpoint to get payment group's approval count.""" payment_group_payment_id = 3 rolled_back_payment_group_payment_id = 4 endpoint = '/abacus-event/payment-group-payment/{}/approval-count' AbacusEventFactory.create( event_name='payment_approval', target_id=rolled_back_payment_group_payment_id, target_type='payment_group_payment', ) AbacusEventFactory.create( event_name='payment_approval', target_id=rolled_back_payment_group_payment_id, target_type='payment_group_payment', rolled_back_at=current_timestamp(), ) AbacusEventFactory.create( event_name='payment_approval', target_id=payment_group_payment_id, target_type='payment_group_payment', ) response_1 = fixture_client.get(endpoint.format(payment_group_payment_id)) assert response_1.status_code == 200 assert response_1.json['approval_count'] == 1 assert response_1.json['payment_group_payment_id'] == payment_group_payment_id response_2 = fixture_client.get( endpoint.format(rolled_back_payment_group_payment_id) ) assert response_2.status_code == 200 assert response_2.json['approval_count'] == 1 assert ( response_2.json['payment_group_payment_id'] == rolled_back_payment_group_payment_id ) def test_rollback_events(fixture_client): """Test endpoint to rollback specified events.""" event_name = 'payment_approval' target_id = 1 target_type = 'payment_group_payment' AbacusEventFactory.create( event_name=event_name, target_id=target_id, target_type=target_type ) put_body = { 'event_name': event_name, 'target_id': target_id, 'target_type': target_type, } response = fixture_client.put('/abacus-event/rollback/', json=put_body) data = response.json assert response.status_code == 200 assert data[0]['abacus_event_id'] assert data[0]['event_date'] is not None assert data[0]['rolled_back_at'] is not None assert data[0]['event_name'] == event_name assert data[0]['target_id'] == target_id assert data[0]['target_type'] == target_type def test_get_events_by_target_type(fixture_client): """Test endpoint to get events by target type and id.""" target_type_1 = 'payment-group-payments' target_id_1 = 1 target_type_2 = 'payment-group' target_id_2 = 2 endpoint = '/abacus-event/{}/{}' response_1_test_item = AbacusEventFactory.create( event_name='payment_approval', target_id=target_id_1, target_type=target_type_1.replace('-', '_'), ) AbacusEventFactory.create( event_name='send_payments', target_id=target_id_1, target_type=target_type_1.replace('-', '_'), ) AbacusEventFactory.create( event_name='send_payments', target_id=3, target_type=target_type_1.replace('-', '_'), ) response_2_test_item = AbacusEventFactory.create( event_name='send_payments', target_id=target_id_2, target_type=target_type_2.replace('-', '_'), ) response_1 = fixture_client.get(endpoint.format(target_type_1, target_id_1)) assert response_1.status_code == 200 assert len(response_1.json) == 2 assert response_1.json[0] == { 'abacus_event_id': response_1_test_item.abacus_event_id, 'event_name': response_1_test_item.event_name, 'statement_period_id': response_1_test_item.statement_period_id, 'event_date': safe_format_datetime(response_1_test_item.event_date), 'rolled_back_at': response_1_test_item.rolled_back_at, 'created_by': response_1_test_item.created_by, 'target_id': response_1_test_item.target_id, } response_2 = fixture_client.get(endpoint.format(target_type_2, target_id_2)) assert response_2.status_code == 200 assert len(response_2.json) == 1 assert response_2.json[0] == { 'abacus_event_id': response_2_test_item.abacus_event_id, 'event_name': response_2_test_item.event_name, 'statement_period_id': response_2_test_item.statement_period_id, 'event_date': safe_format_datetime(response_1_test_item.event_date), 'rolled_back_at': response_1_test_item.rolled_back_at, 'created_by': response_1_test_item.created_by, 'target_id': response_2_test_item.target_id, } def test_get_event_by_object_id(fixture_client): """Test endpoint to get event by object_id.""" test_item1 = AbacusEventFactory.create( event_name='payment_approval', ) test_item2 = AbacusEventFactory.create( event_name='send_payments', rolled_back_at=current_timestamp() ) resp = fixture_client.get(f'/abacus-event/{test_item1.abacus_event_id}') assert resp.status_code == 200 assert resp.json == { 'abacus_event_id': test_item1.abacus_event_id, 'event_name': test_item1.event_name, 'statement_period_id': test_item1.statement_period_id, 'event_date': safe_format_datetime(test_item1.event_date), 'rolled_back_at': test_item1.rolled_back_at, 'created_by': test_item1.created_by, 'target_id': test_item1.target_id, 'previous_abacus_event_id': test_item1.previous_abacus_event_id, 'target_type': test_item1.target_type, } resp = fixture_client.get(f'/abacus-event/{test_item2.abacus_event_id}') assert resp.status_code == 404 def test_dataloader_events_by_ids(fixture_client): """Test abacus event dataloader endpoint.""" event = AbacusEventFactory.create() event_ids = [event.abacus_event_id, 999] res = fixture_client.post('/abacus-event/dataloader', json=event_ids) assert res.status_code == 200 assert res.json == { 'items': [ { 'data': { 'abacus_event_id': event.abacus_event_id, 'created_by': event.created_by, 'event_date': safe_format_datetime(event.event_date), 'event_name': event.event_name, 'previous_abacus_event_id': event.previous_abacus_event_id, 'rolled_back_at': event.rolled_back_at, 'statement_period_id': event.statement_period_id, 'target_id': event.target_id, 'target_type': event.target_type, } }, {'data': None}, ] } def to_datetime(raw: str) -> datetime: """Convert a string to naive (no tz) datetime.""" try: # tz-aware -> normalize to UTC and drop tzinfo dt = datetime.strptime(raw, '%Y-%m-%dT%H:%M:%S.%f%z') return dt.astimezone(timezone.utc).replace(tzinfo=None) except ValueError: return datetime.strptime(raw, '%Y-%m-%dT%H:%M:%S.%f') @pytest.mark.db('mysql') @patch('abacus_event.connectors.aws.boto3') def test_retry_events_success(mock_boto, fixture_client): """Test successful retry of allowed event.""" event_name = ALLOWED_EVENTS_FOR_RETRY[0] target_type = 'some_target_type' target_id = 123 mock_lambda_client = MagicMock() mock_boto.client.return_value = mock_lambda_client mock_lambda_client.invoke.return_value = 'SUCCESS' event = AbacusEventFactory.create( event_name=event_name, target_type=target_type, target_id=target_id ) put_body = { 'event_name': event_name, 'target_type': target_type, 'target_id': target_id, } response = fixture_client.put('/abacus-event/retry/', json=put_body) assert response.status_code == 200 assert isinstance(response.json, list) assert response.json[0]['abacus_event_id'] == event.abacus_event_id assert response.json[0]['event_name'] == event_name assert response.json[0]['target_type'] == target_type assert response.json[0]['target_id'] == target_id assert mock_lambda_client.invoke.called def test_retry_events_not_allowed(fixture_client): """Test retry of not-allowed event returns 400.""" event_name = 'not_allowed_event' target_type = 'some_type' target_id = 456 AbacusEventFactory.create( event_name=event_name, target_type=target_type, target_id=target_id ) put_body = { 'event_name': event_name, 'target_type': target_type, 'target_id': target_id, } response = fixture_client.put('/abacus-event/retry/', json=put_body) assert response.status_code == 400 assert 'message' in response.json def test_retry_events_no_events(fixture_client): """Test retry when no events found returns 400.""" put_body = { 'event_name': EVENT_NAMES.APPLY_PENDING_ADJUSTMENTS, 'target_type': TARGET_TYPES.STATEMENT_PERIOD_ADJUSTMENT_FILE, 'target_id': 99999, } response = fixture_client.put('/abacus-event/retry/', json=put_body) assert response.status_code == 404 assert 'message' in response.json def test_dataload_events_by_target_returns_ordered_active_events(fixture_client): """Dataloader returns one ordered entry per requested id, active events only.""" target_type = TARGET_TYPES.STATEMENT_PERIOD_ADJUSTMENT_FILE event_name = EVENT_NAMES.APPLY_PENDING_ADJUSTMENTS AbacusEventFactory.create( event_name=event_name, target_id=1, target_type=target_type ) AbacusEventFactory.create( event_name=event_name, target_id=2, target_type=target_type ) # Rolled-back event on id 2 must be excluded (active_events filter). AbacusEventFactory.create( event_name=event_name, target_id=2, target_type=target_type, rolled_back_at=current_timestamp(), ) # Event for a target id not requested must not be returned. AbacusEventFactory.create( event_name=event_name, target_id=99, target_type=target_type ) res = fixture_client.post(f'/abacus-event/{target_type}/dataloader', json=[1, 2, 3]) assert res.status_code == 200 items = res.json['items'] # One entry per requested id, in order: id 1 one event, id 2 one active event # (rolled-back excluded), id 3 none. assert len(items) == 3 assert [event['target_id'] for event in items[0]['data']] == [1] assert len(items[1]['data']) == 1 assert items[1]['data'][0]['target_id'] == 2 assert items[2]['data'] is None def test_dataload_events_by_target_empty_body_returns_400(fixture_client): """An empty id list is rejected.""" res = fixture_client.post( f'/abacus-event/{TARGET_TYPES.STATEMENT_PERIOD_ADJUSTMENT_FILE}/dataloader', json=[], ) assert res.status_code == 400 def test_dataload_events_by_target_over_limit_returns_400(fixture_client): """More than the batch limit of ids is rejected with 400.""" res = fixture_client.post( f'/abacus-event/{TARGET_TYPES.STATEMENT_PERIOD_ADJUSTMENT_FILE}/dataloader', json=list(range(DATALOADER_BATCH_LIMIT + 1)), ) assert res.status_code == 400