"""Lambda test module.""" import copy import json from unittest.mock import MagicMock from unittest.mock import patch import pytest from src import app from src.constants.constants import EMAIL_ATTACHMENT_ERROR_SUBJECT from src.constants.constants import Error from src.constants.constants import IGNORE_EMPTY_PAYLOAD from src.utils import functions from src.utils.custom_dataclasses import InternalAttachmentPayload @pytest.mark.parametrize( 'account_id, account_type, file_name, expected_result', [ (123, 'L', 'L123_123_test.file', 'per-label-test/test-L123/L123_123_test.file'), (4242, 'L', 'L4242_123_test.file', 'per-label-test/test-L4242/L4242_123_test.file'), ], ) @patch('src.app.S3_PER_LABEL_DIR', new_callable=lambda: 'per-label-test') @patch( 'src.app.S3_DESTINATION_KEY_TEMPLATE', new_callable=lambda: '{attachments_dir}/test-{account_dir}/{file_name}', ) # noqa E501 def test_get_attachment_destination_key( key_template, label_dir, account_id, account_type, file_name, expected_result ): """Test _get_attachment_destination_key utility function.""" result = app._get_attachment_destination_key(account_id, account_type, file_name) assert result == expected_result @patch('src.app.logger') def test_handler_empty_payload( mock_logger, ): """Test app.handler with no payload.""" app.handler({}, None) assert mock_logger.warning.called assert mock_logger.warning.call_args[0][0] == IGNORE_EMPTY_PAYLOAD @patch('src.app.is_feature_enabled') @patch('src.app.OwsMoneyhub') @patch('src.app._get_attachment_destination_key') @patch('src.app.functions') @patch('src.app.s3') @patch('src.app.IGNORE_ALL_EVENTS', new_callable=lambda: False) @patch('src.app.ENVIRONMENT', new_callable=lambda: 'prod') def test_handler( mock_environment, mock_ignore_events, mock_s3, mock_functions, mock_get_attachment_destination_key, mock_moneyhub, is_feature_enabled_mock, s3_event_fixture, attachment_obj_from_s3, attachment_month_dynamodb_item, attachment_quarter_dynamodb_item, ): """Test app.handler.""" is_feature_enabled_mock.return_value = True mock_functions.validate_file.return_value = '' mock_functions.parse_file_key.return_value = { 'filename': 'attac1.txt', 'extension': 'txt', 'account_type': 'L', 'label_id': '10303', 'period_id': '217', } mock_functions.s3_event_get_attachment_details.return_value = attachment_obj_from_s3 mock_functions.get_file_name_from_key.return_value = 'attac1.csv' mock_get_attachment_destination_key.return_value = attachment_obj_from_s3['file_key'] mock_functions.get_quarter_periods_by_period.return_value = [217, 218, 219] mock_functions.get_attachment_primary_key_value.side_effect = [ 'L10303_217', 'L10303_217_218_219', ] # noqa E501 mock_functions.format_quarter_file_name.return_value = 'attac1.csv(217)' app.handler(s3_event_fixture, None) mock_moneyhub.create_internal_statement_attachment.assert_called() @pytest.mark.parametrize( 'move_result, dest_key, expected_file_key', [ (True, 'another-path/another-key', 'another-path/another-key'), (False, 'another-path/another-key', 'testfolder/L10303_217_attac1.txt'), ], ) @patch('src.app.is_feature_enabled') @patch('src.app.OwsMoneyhub') @patch('src.app._get_attachment_destination_key') @patch('src.app.s3') @patch('src.app.functions') @patch('src.app.S3_BUCKET_NAME', new_callable=lambda: 'test_bucket_name') @patch('src.app.IGNORE_ALL_EVENTS', new_callable=lambda: False) @patch('src.app.ENVIRONMENT', new_callable=lambda: 'prod') def test_handler_successful_move_operation( mock_environment, mock_ignore_events, mock_s3_bucket, mock_functions, mock_s3, mock_get_attachment_destination_key, mock_moneyhub, is_feature_enabled_mock, attachment_obj_from_s3, attachment_month_dynamodb_item, attachment_quarter_dynamodb_item, move_result, dest_key, expected_file_key, s3_event_fixture, ): """Test handler when s3.move was successful.""" is_feature_enabled_mock.return_value = True mock_functions.validate_file.return_value = '' mock_functions.parse_file_key.return_value = { 'filename': 'attac1.txt', 'extension': 'txt', 'contract_id': None, 'account_type': 'L', 'label_id': '10303', 'period_id': '217', } mock_get_attachment_destination_key.return_value = dest_key mock_functions.get_quarter_periods_by_period.return_value = [217, 218, 219] mock_functions.s3_event_get_attachment_details.return_value = copy.deepcopy( attachment_obj_from_s3 ) mock_functions.get_attachment_primary_key_value.side_effect = [ 'L10303_217', 'L10303_217_218_219', ] mock_functions.format_quarter_file_name.return_value = 'attac1.txt(217)' mock_functions.get_file_name_from_key.return_value = 'L10303_217_attac1.txt' mock_s3.move_object.return_value = move_result expected_moth_item = copy.deepcopy(attachment_month_dynamodb_item) expected_quarter_item = copy.deepcopy(attachment_quarter_dynamodb_item) expected_moth_item['file_key'] = expected_file_key expected_quarter_item['file_key'] = expected_file_key payload = InternalAttachmentPayload( file_type='txt', file_location=f's3://test_bucket_name/{expected_file_key}', upload_date=attachment_month_dynamodb_item['upload_date'], ) app.handler(s3_event_fixture, None) mock_s3.move_object.assert_called_once_with( bucket='test_bucket_name', source_key=attachment_obj_from_s3['file_key'], dest_key=dest_key ) mock_moneyhub.create_internal_statement_attachment.assert_called_once_with( '10303', '217', payload ) @patch('src.app._get_attachment_destination_key') @patch('src.app.s3') @patch('src.app.functions') @patch('src.app.IGNORE_ALL_EVENTS', new_callable=lambda: True) def test_handler_ignores_all_events(mock_ignore_events, mock_functions, mock_s3, s3_event_fixture): """Test app.handler.""" app.handler(s3_event_fixture, None) mock_functions.s3_event_get_attachment_details.assert_not_called() @patch('src.app.is_feature_enabled') @patch('src.app.logger') @patch('src.app.OwsMoneyhub') @patch('src.app._get_attachment_destination_key') @patch('src.app.s3') @patch('src.app.functions') def test_handler_errors( mock_functions, mock_s3, mock_key, mock_moneyhub, mock_logger, is_feature_enabled_mock, s3_event_fixture, ): """Test app.handler.""" is_feature_enabled_mock.return_value = True mock_functions.validate_file.return_value = '' mock_functions.parse_file_key.return_value = { 'filename': 'attac1.txt', 'extension': 'txt', 'account_type': 'L', 'label_id': '10303', 'period_id': '217', } mock_error = Exception('fake error moving file.') mock_s3.move_object.side_effect = mock_error with pytest.raises(Exception) as exception_info: app.handler(s3_event_fixture, None) assert exception_info.value == mock_error assert not mock_moneyhub.called assert mock_logger.exception.called @pytest.mark.parametrize( 'period_ids, expected_result', [([217, 218, 219], '217,218,219'), ([220, 221, 222], '220,221,222'), ([1, 2, 3], '1,2,3')], ) def test_format_period_ids_string(period_ids, expected_result): """Test format_quarter_file_name.""" result = app._format_period_ids_string(period_ids) assert expected_result == result def test_parse_s3_event(s3_event_fixture): """Test _parse_s3_event extracts file name and upload timestamp.""" result = functions.parse_s3_event(s3_event_fixture) assert result == { 'full_file_name': 'L10303_217_attac1.txt', 'upload_timestamp': '1970-01-01T00:00:00.000Z', } def test_parse_s3_event_url_encoded(): """Test _parse_s3_event URL-decodes the file name.""" event = { 'Records': [ { 'eventTime': '2024-01-01T00:00:00.000Z', 's3': { 'object': {'key': 'folder/L25824_227_1mb-%28version%29.doc'}, 'bucket': {'name': 'bucket'}, }, } ] } result = functions.parse_s3_event(event) assert result['full_file_name'] == 'L25824_227_1mb-(version).doc' @patch('src.utils.functions.WORKFLOW_LINK', 'https://test.example.com/workflow') def test_prepare_error_email_body(s3_event_fixture): """Test _prepare_error_email_body formats the email template.""" body = functions.prepare_error_email_body(s3_event_fixture, Error.INVALID_EXTENSION_MSG) assert 'L10303_217_attac1.txt' in body assert '1970-01-01T00:00:00.000Z' in body assert Error.INVALID_EXTENSION_MSG.value in body assert 'https://test.example.com/workflow' in body @patch('src.utils.functions.boto3') @patch('src.utils.functions.ERROR_ATTACHMENT_EMAIL_RECIPIENT', 'test@example.com') @patch('src.utils.functions.EMAIL_SENDER', 'sender@example.com') @patch('src.utils.functions.WORKFLOW_LINK', 'https://test.example.com') def test_send_error_email(mock_boto3, s3_event_fixture): """Test _send_error_email calls SES with correct params.""" mock_ses = MagicMock() mock_boto3.client.return_value = mock_ses functions.send_error_email(s3_event_fixture, Error.INVALID_SIZE_MSG) mock_boto3.client.assert_called_once_with('ses') mock_ses.send_email.assert_called_once() call_kwargs = mock_ses.send_email.call_args[1] assert call_kwargs['Source'] == 'sender@example.com' assert call_kwargs['Destination'] == {'ToAddresses': ['test@example.com']} assert call_kwargs['Message']['Subject']['Data'] == EMAIL_ATTACHMENT_ERROR_SUBJECT assert Error.INVALID_SIZE_MSG.value in call_kwargs['Message']['Body']['Html']['Data'] assert call_kwargs['ReplyToAddresses'] == ['sender@example.com'] @patch('src.utils.functions.boto3') @patch('src.utils.functions.logger') @patch('src.utils.functions.ERROR_ATTACHMENT_EMAIL_RECIPIENT', None) def test_send_error_email_no_recipient(mock_logger, mock_boto3, s3_event_fixture): """Test _send_error_email skips when no recipient configured.""" functions.send_error_email(s3_event_fixture, Error.INVALID_SIZE_MSG) mock_logger.error.assert_called_once() mock_boto3.client.assert_not_called() @patch('src.app.is_feature_enabled') @patch('src.app.logger') @patch('src.app.functions') @patch('src.app.IGNORE_ALL_EVENTS', new_callable=lambda: False) def test_handler_validation_failure( mock_ignore_events, mock_functions, mock_logger, is_feature_enabled_mock, s3_event_fixture ): """Test handler sends error email when validation fails.""" is_feature_enabled_mock.return_value = True mock_functions.validate_file.return_value = Error.INVALID_EXTENSION_MSG app.handler(s3_event_fixture, None) mock_functions.send_error_email.assert_called_once_with( s3_event_fixture, Error.INVALID_EXTENSION_MSG ) mock_functions.s3_event_get_attachment_details.assert_not_called() @patch('src.app.is_feature_enabled') @patch('src.app.OwsMoneyhub') @patch('src.app._get_attachment_destination_key') @patch('src.app.functions') @patch('src.app.s3') @patch('src.app.IGNORE_ALL_EVENTS', new_callable=lambda: False) @patch('src.app.ENVIRONMENT', new_callable=lambda: 'prod') def test_handler_sns_path_when_feature_flag_disabled( mock_environment, mock_ignore_events, mock_s3, mock_functions, mock_get_attachment_destination_key, mock_moneyhub, is_feature_enabled_mock, attachment_obj_from_s3, ): """Test handler uses SNS message path when S3_EVENT feature flag is disabled.""" is_feature_enabled_mock.return_value = False sns_event = { 'Records': [ { 'Sns': { 'Timestamp': '1970-01-01T00:00:00.000Z', 'Message': json.dumps( { 's3_event': {'key': 'testfolder/L10303_217_attac1.txt'}, 'attachment_attrs': {'account_type': 'L', 'label_id': '10303'}, } ), } } ] } mock_functions.s3_event_get_attachment_details.return_value = attachment_obj_from_s3 mock_functions.get_file_name_from_key.return_value = 'attac1.txt' mock_get_attachment_destination_key.return_value = attachment_obj_from_s3['file_key'] mock_s3.move_object.return_value = True mock_functions.get_quarter_periods_by_period.return_value = [217, 218, 219] mock_functions.get_attachment_primary_key_value.side_effect = [ 'L10303_217', 'L10303_217_218_219', ] # noqa E501 mock_functions.format_quarter_file_name.return_value = 'attac1.txt(217)' app.handler(sns_event, None) # SNS path should NOT call validate_file or parse_file_key mock_functions.validate_file.assert_not_called() mock_functions.parse_file_key.assert_not_called() # Should still call s3_event_get_attachment_details with parsed SNS message fields mock_functions.s3_event_get_attachment_details.assert_called_once_with( {'key': 'testfolder/L10303_217_attac1.txt'}, {'account_type': 'L', 'label_id': '10303'}, ) mock_moneyhub.create_internal_statement_attachment.assert_called()