"""Lambda test module.""" import json import uuid from contextlib import nullcontext as does_not_raise from unittest import mock from unittest.mock import call from unittest.mock import MagicMock from unittest.mock import patch from segment import analytics from botocore.exceptions import ClientError import pytest from owsrequest.test_utils import MockOwsResponse from freezegun import freeze_time from moto.core import patch_client import config from src import app from src.utils import message_utils from ..conftest import trending_track_message @pytest.mark.parametrize( ('identity_response', 'notification_settings', 'should_send'), [ ( MockOwsResponse(200, { 'id': '5d0a59de8a9f8c0d78129af0', 'email': 'foo@theorchard.com', 'default_brand': 'theorchard' }), [{ 'feed_type': 'social_spike', 'notification_type': 'push_notifications', 'followed_entity': 'participant' }], True ), ( MockOwsResponse(200, { 'id': '5d0a59de8a9f8c0d78129af0', 'email': 'foo@gmail.com', 'default_brand': 'theorchard' }), [], False ), ( MockOwsResponse(200, { 'id': '5d0a59de8a9f8c0d78129af0', 'email': 'foo@theorchard.com', 'default_brand': 'awal' }), [{ 'feed_type': 'social_spike', 'notification_type': 'push_notifications', 'followed_entity': 'participant' }], True ), ( MockOwsResponse(200, { 'id': '5d0a59de8a9f8c0d78129af0', 'email': 'foo@sonymusic-pde.com', 'default_brand': 'sme' }), [{ 'feed_type': 'social_spike', 'notification_type': 'push_notifications', 'followed_entity': 'participant' }], True ), ( MockOwsResponse(200, { 'id': '5d0a59de8a9f8c0d78129af0', 'email': 'foo@gmail.com', 'default_brand': 'awal' }), [], False ) ] ) @patch.object(app, 'add_event') @patch.object(app, 'sns_client') @patch('src.common.messages.create_message_string') @patch('src.models.ows_users.get_identity') @patch.object(analytics, 'identify') @patch.object(analytics, 'track') @patch('src.models.ows_notifications.get_notification_settings') def test_index( get_notification_settings, mock_identify, mock_track, get_identity, create_message, sns_client, add_event, identity_response, notification_settings, should_send, fixture_batch_events, monkeypatch): """Full function handler sending event immediately.""" monkeypatch.setattr( uuid, 'uuid4', MagicMock(return_value='510bfb79-a0b5-4727-9ebb-124c57e1e505')) sns_client.publish.return_value = {'MessageId': 'foo'} get_identity.return_value = identity_response create_message.return_value = 'message' get_notification_settings.return_value = notification_settings expected_brand = identity_response.json().get('default_brand', 'orchard') identity_id = identity_response.json()['id'] metadata = {'data': {'brand': identity_response.json()['default_brand']}} expected_sns_topic = app.get_topic_name(metadata, identity_id) app.handler(fixture_batch_events, None) metadata = { 'data': { 'profileId': '12134', 'profileType': 'InsightsProfile', 'identityId': '5d0a59de8a9f8c0d78129af0', 'brand': expected_brand, 'notificationId': '510bfb79-a0b5-4727-9ebb-124c57e1e505', 'type': 'social_spike', 'socialPlatform': 'twitter', 'payload': { 'participantId': '09ab9fa1-50e9-4229-bd4c-fecaf546de15' } } } if should_send: create_message.assert_called_with( 'The Ones and Zeros gained 100 Twitter Followers since yesterday!', # noqa:E501 metadata ) sns_client.publish.assert_called_with( Message='message', TopicArn=f'{config.SNS_ARN_PREFIX}:{expected_sns_topic}', MessageStructure='json' ) get_identity.assert_called_with('12134', 'InsightsProfile') @patch.object(app, 'add_event') @patch.object(app, '_aggregation_time', return_value=900) def test_index_buffer( _aggregation_time, add_event, fixture_batch_events): """Full function handler buffering event.""" num_events = 10 app.handler(fixture_batch_events, None) add_event_calls = [ call( { 'actor': '09ab9fa1-50e9-4229-bd4c-fecaf546de15', 'foreign_id': 'test-fk', 'group': 'stream-realtime', 'id': 'd1695272-688f-11ea-a2d5-1231d51167b4', 'object': { 'name': 'The Ones and Zeros', 'new_followers': 100, 'activity_sources': ['participant'] }, 'origin': 'label_spike_detector:InsightsProfile_19', 'target': '', 'time': '2020-03-17T20:42:29.117400', 'verb': 'social_spike_twitter' }, 'social_spike_twitter', '12134', 'InsightsProfile', 900 ) for _ in range(0, num_events) ] assert add_event.call_args_list == add_event_calls agg_calls = [call('social_spike_twitter') for _ in range(0, num_events)] assert _aggregation_time.call_args_list == agg_calls @patch('boto3.client') @patch('owsrequest.request.process') @patch('lambdacommon.common_config.logger.exception') @patch('segment.analytics.identify') def test_index_identity_exception( analytics_identify, exception_logger, request_process, boto_client, fixture_batch_events): """Test index handling of exception from get_identity call.""" request_process.return_value = MockOwsResponse(500, {}) with pytest.raises(Exception) as e: app.handler(fixture_batch_events, None) assert str(e.value) == 'unexpected HTTP response status 500' assert exception_logger.called assert request_process.called assert not analytics_identify.called @patch('boto3.client') @patch('src.models.ows_users.get_identity') @patch('lambdacommon.common_config.logger.exception') @patch('segment.analytics.identify') def test_index_identity_not_found( analytics_identify, exception_logger, get_identity, boto3_client, fixture_batch_events): """Test index handling of 404 from get_identity call.""" get_identity.return_value = MockOwsResponse(404, {}) app.handler(fixture_batch_events, None) assert get_identity.called assert not analytics_identify.called assert not boto3_client.publish.called @patch('boto3.client') @patch('src.models.ows_users.get_identity') @patch('src.common.messages.get_translations') @patch('src.utils.message_utils._notification_enabled') @patch('owsrequest.request.process') @patch('lambdacommon.common_config.logger.exception') @patch('segment.analytics.identify') def test_index_settings_exception( analytics_identify, exception_logger, request_process, notifications_enabled, get_translations, get_identity, boto_client, fixture_batch_events): """Test index handling of exception from _get_notification_settings.""" get_identity.return_value = MockOwsResponse(200, {'id': 'bar'}) get_translations.return_value = 'en' request_process.return_value = MockOwsResponse(500, {}) with pytest.raises(Exception) as e: app.handler(fixture_batch_events, None) assert str(e.value) == 'unexpected HTTP response status 500' assert exception_logger.called assert request_process.called assert get_identity.called assert analytics_identify.called assert get_translations.called assert not notifications_enabled.called @patch('boto3.client') @patch('owsrequest.request.process') @patch('src.app.ows_users.get_identity') def test_getstream_test_message( get_identity, ows_request, boto3_client, fixture_getstream_test_event): """Handle GetStream test message. Clicking "Test SQS" in GetStream dashboard sends a message to SQS and it is not properly base64 encoded. Ensure that message is skipped and does not blow up the lambda. Args: ows_request (MagicMock): owsrequest.request.process function call boto3_client (MagicMock): boto3.client function call Returns: None """ app.handler({'Records': [fixture_getstream_test_event]}, None) assert not get_identity.called assert not ows_request.called assert not boto3_client.publish.called @pytest.mark.parametrize(('exception_cls', 'expectation'), [ pytest.param( app.sns_client.exceptions.NotFoundException, does_not_raise(), id='NotFoundException' ), pytest.param( app.sns_client.exceptions.InvalidParameterException, pytest.raises(ClientError), id='InvalidParameterException' ), pytest.param( app.sns_client.exceptions.ValidationException, pytest.raises(ClientError), id='ValidationException' ), ]) @patch.object(app, 'add_event') @patch.object(app.sns_client, 'publish') @patch('src.models.ows_users.get_identity') @patch('src.models.ows_notifications.get_notification_settings') @patch.object(analytics, 'identify') @patch.object(analytics, 'track') def test_not_found_sns_topic( mock_identify, mock_track, get_notification_settings, get_identity, publish_mock, add_event, exception_cls, expectation, fixture_batch_events): """Test SNS topic not existing. A mobile user may star a GlobalParticipant but not turn on push notifications. Ensure this use case does now blow up the lambda and simply skips that case. """ publish_mock.side_effect = exception_cls( {'Error': {'Code': exception_cls.__name__.replace('Exception', '')}}, 'operation' ) get_identity.return_value = MockOwsResponse( 200, {'id': 'bar', 'email': 'foo@theorchard.com'}) get_notification_settings.return_value = [{ 'feed_type': 'social_spike', 'notification_type': 'push_notifications', 'followed_entity': 'participant' }] with expectation: app.handler(fixture_batch_events, None) assert publish_mock.called @patch('boto3.client') @patch('src.app.ows_users.get_identity') def test_sqs_event_empty_items( get_identity, boto3_client, fixture_event_empty_items): """Test events with empty new feed items are skipped.""" app.handler({'Records': [fixture_event_empty_items]}, None) assert not get_identity.called assert not boto3_client.publish.called @freeze_time('2019-06-15') @patch.object(app, 'dynamo_client') def test_add_first_event(dynamo_client): """Test dynamo add empty buffer.""" dynamo_client.update_item.return_value = {} result = app.add_event({'key': 'value'}, 'event_type', 123, 'Insights', 900) # noqa:E501 assert dynamo_client.update_item.call_args_list == [ call( AttributeUpdates={ 'events': { 'Value': {'L': [{'M': {'key': {'S': 'value'}}}]}, 'Action': 'ADD' } }, Key={'buffer_id': {'S': 'event_type:123:Insights'}}, ReturnValues='ALL_OLD', TableName=config.DYNAMO_TABLE ), call( AttributeUpdates={ 'process_after': { 'Value': {'N': '1560557700'}, 'Action': 'PUT' } }, Key={'buffer_id': {'S': 'event_type:123:Insights'}}, TableName=config.DYNAMO_TABLE) ] assert result == {'S': 'event_type:123:Insights'} @patch.object(app, 'dynamo_client') def test_add_second_event(dynamo_client): """Test dynamo add non-empty buffer.""" dynamo_client.update_item.return_value = {'Attributes': {}} result = app.add_event({'key': 'value'}, 'event_type', 123, 'Insights', 900) # noqa:E501 assert dynamo_client.update_item.call_args_list == [ call( AttributeUpdates={ 'events': { 'Value': {'L': [{'M': {'key': {'S': 'value'}}}]}, 'Action': 'ADD' } }, Key={'buffer_id': {'S': 'event_type:123:Insights'}}, ReturnValues='ALL_OLD', TableName=config.DYNAMO_TABLE ) ] assert result == {'S': 'event_type:123:Insights'} @patch.object(app, 'dynamo_client') @patch('config.DYNAMO_TABLE', f'{config.DEV_ENVIRONMENT}_notifications-buffer') def test_clear_buffer(dynamo_client): """Test dynamo delete with results.""" dynamo_client.delete_item.return_value = { 'Attributes': { 'events': { 'L': [ { 'M': { 'bar': {'S': 'abc'}, 'foo': {'S': 'def'} } } ] }, } } result = app.clear_buffer('trending_tracks', 1234, 'InsightsProfile') assert dynamo_client.delete_item.call_args_list == [ call( Key={'buffer_id': {'S': 'trending_tracks:1234:InsightsProfile'}}, ReturnValues='ALL_OLD', TableName='dev_notifications-buffer' ) ] assert result == [{'bar': 'abc', 'foo': 'def'}] @patch.object(app, 'dynamo_client') def test_clear_empty_buffer(dynamo_client): """Test dynamo delete without results.""" dynamo_client.delete_item.return_value = {} result = app.clear_buffer('trending_tracks', 1234, 'InsightsProfile') assert result == [] @patch.object(app, 'send_notifications') @patch.object( app, 'clear_buffer', side_effect=[['a', 'b', 'c'], ['d', 'e', 'f']]) def test_process_buffer(clear_buffer, send_notifications): """Test index handling of buffer processing.""" message = { 'Records': [ { 'body': 'playlist_placements:123:InsightsProfile', 'attributes': {'SenderId': 'buffer-poller-sender-id'} }, { 'body': 'trending_tracks:456:LabelProfile', 'attributes': {'SenderId': 'buffer-poller-sender-id:extra'} }, ] } app.handler(message, None) assert clear_buffer.call_args_list == [ call('playlist_placements', 123, 'InsightsProfile'), call('trending_tracks', 456, 'LabelProfile') ] assert send_notifications.call_args_list == [ call(123, 'InsightsProfile', ['a', 'b', 'c']), call(456, 'LabelProfile', ['d', 'e', 'f']), ] def test_unknown_sender(): """Test unexpected sender id.""" message = { 'Records': [ { 'body': 'trending_tracks:456:LabelProfile', 'attributes': {'SenderId': 'unknown-sender-id'} } ] } raised = False try: app.handler(message, None) except Exception as e: assert str(e) == 'Unexpected sender_id "unknown-sender-id"' raised = True assert raised @pytest.mark.parametrize( ('followed_entities', 'publish_called', 'brand'), [ (['participant'], True, 'orchard'), (['participant'], True, 'awal'), (['participant'], True, 'sme'), (['participant', 'sound_recording'], True, 'orchard'), (['participant', 'sound_recording', 'sub_account'], True, 'orchard'), (['participant', 'sound_recording', 'sub_account'], True, 'awal'), (['participant', 'sound_recording', 'sub_account'], True, 'sme'), (['sub_account'], False, 'orchard'), (['sub_account'], False, 'awal'), (['sub_account'], False, 'sme'), ([], False, 'orchard') ] ) @patch.object(app, 'process_identity_response') @patch.object(app, 'ows_notifications') @patch.object(app, 'publish_to_sns') @patch('uuid.uuid4') def test_send_notifications( mock_uuid_func, mock_publish, mock_ows_notifications, mock_identity, followed_entities, publish_called, brand): """Test send notification call structure and event filtering.""" # setup uuid mock mock_uuid = 'mock-uuid' mock_uuid_func.return_value = 'mock-uuid' # setup identity response mock identity_id = 'mock-identity-id' mock_identity.return_value = ( identity_id, message_utils.messages.get_translations('en'), brand ) # setup notification config mock notification_settings = [ { 'feed_type': 'trending_tracks', 'notification_type': 'push_notifications', 'followed_entity': x } for x in followed_entities ] # add a setting that should be ignored, different feed type notification_settings.append({ 'feed_type': 'playlist_placements', 'notification_type': 'push_notifications', 'followed_entity': ['participant'] }) # add a setting that should be ignored, different notification type notification_settings.append({ 'feed_type': 'trending_tracks', 'notification_type': 'email', 'followed_entity': ['participant'] }) mock_ows_notifications.get_notification_settings.return_value = notification_settings # noqa: E501 # setup mock events app_id = 'xyz' profile_id = 123 profile_type = 'InsightsProfile' events = [ trending_track_message( 'Canada', 'spotify', 123, 100, ['participant']), trending_track_message( 'Canada', 'spotify', 456, 100, ['participant', 'sound_recording']), trending_track_message( 'Canada', 'spotify', 789, 100, ['vendor']) ] # call the function app.send_notifications(profile_id, profile_type, events, app_id) # make sure calls were made assert mock_identity.call_args_list == [ call(profile_id, profile_type, app_id) ] assert mock_ows_notifications.get_notification_settings.call_args_list == [ call(profile_id, profile_type) ] # make sure filtering happened as expected and publish called if publish_called: assert mock_publish.call_count == 2 assert mock_publish.call_args_list[0][0][1] == { 'data': { 'profileId': profile_id, 'profileType': profile_type, 'identityId': identity_id, 'brand': brand, 'notificationId': mock_uuid, 'type': 'trending_tracks', 'payload': { 'isrc': 'ISRC-123' } } } print(mock_publish.call_args_list[0][0]) assert mock_publish.call_args_list[0][0][2] == identity_id assert mock_publish.call_args_list[1][0][1] == { 'data': { 'profileId': profile_id, 'profileType': profile_type, 'identityId': identity_id, 'brand': brand, 'notificationId': mock_uuid, 'type': 'trending_tracks', 'payload': { 'isrc': 'ISRC-456' } } } assert mock_publish.call_args_list[1][0][2] == identity_id else: assert not mock_publish.called def test_aggregation_time(): """Test reading aggregation per event type config.""" assert app._aggregation_time('social_spike_twitter') == 0 assert app._aggregation_time('social_spike_youtube') == 0 assert app._aggregation_time('social_spike_instagram') == 0 assert app._aggregation_time('trending_tracks') == 900 assert app._aggregation_time('playlist_placements') == 3600 @patch.object(app, 'ses_client') def test_send_email_to_ses(mock_ses_client): """Test send_email_to_ses.""" app.send_email_to_ses( 'test@example.com', 'subject', 'html body', 'text body') mock_ses_client.send_email.assert_called_with( Destination={'ToAddresses': 'test@example.com'}, Message={'Body': {'Html': {'Charset': 'UTF-8', 'Data': 'html body'}, 'Text': {'Charset': 'UTF-8', 'Data': 'text body'}}, 'Subject': {'Charset': 'UTF-8', 'Data': 'subject'}}, # noqa:E501 Source='notifications@theorchard.com') @pytest.mark.parametrize( ( 'metadata', 'identity_id', 'expected_topic_name' ), [ ( {'data': {'brand': 'theorchard'}}, 'identity_123', f'{config.DEV_ENVIRONMENT}-push-notifications-identity_123'), ( {'data': {'brand': 'awal'}}, 'identity_123', f'{config.DEV_ENVIRONMENT}-awal-push-notifications-identity_123' ), ( {'data': {'brand': 'sme'}}, 'identity_123', f'{config.DEV_ENVIRONMENT}-sme-push-notifications-identity_123' ) ] ) @patch('config.ENVIRONMENT', config.DEV_ENVIRONMENT) def test_get_topic_name(metadata, identity_id, expected_topic_name): """Ensure that get_topic_name works as expected.""" topic_name = app.get_topic_name(metadata, identity_id) assert topic_name == expected_topic_name @pytest.mark.parametrize( ('brand', 'topic_name'), [ pytest.param('orchard', 'test-push-notifications-{}', id='orchard'), pytest.param('awal', 'test-awal-push-notifications-{}', id='awal'), pytest.param('sme', 'test-sme-push-notifications-{}', id='sme'), pytest.param('orchard', None, id='orchard_no_topic'), pytest.param('awal', None, id='awal_no_topic'), pytest.param('sme', None, id='sme_no_topic'), ] ) def test_publish_to_sns(brand, topic_name, mocked_aws, mocker): """Test publish_to_sns behavior with different brands and topic existence.""" client = app.sns_client patch_client(client) track_mock = mocker.patch.object(app.analytics, 'track') message = json.dumps({ 'GCM': json.dumps({'android': 'android message'}), 'APNS': json.dumps({'ios': 'ios message'}), 'default': 'default message' }) metadata = { 'data': { 'type': 'social_spike', 'brand': brand, 'profileId': 123456, 'profileType': 'InsightsProfile', } } identity_id = '904f960e-0dad-11f1-b9e0-3aff7849549b' if topic_name: topic_name = topic_name.format(identity_id) client.create_topic(Name=topic_name) app.publish_to_sns(message, metadata, identity_id) track_mock.assert_called_once_with( identity_id, 'Push Notification Sent', { 'campaign': { 'medium': 'Push', 'name': metadata['data'].get('type', '').upper(), 'source': 'orchard-notifications', }, 'notification_data': metadata['data'], 'sns_publish_id': mock.ANY, 'sns_topic_name': topic_name, }, ) else: app.publish_to_sns(message, metadata, identity_id) track_mock.assert_not_called()