"""Unit tests for ScheduledProductUpdate logic.""" from datetime import timedelta from unittest.mock import ANY, MagicMock, call, patch import pytest from dateutil import parser from oto import response from oto import status as http_status import timed_release.config as config from tests.unit.testutils import db from timed_release.connectors import scheduler, sfn from timed_release.constants.timed_release import ( OA, UPSERT_SCHEDULE, WARNING_EMAIL, ) from timed_release.logic import scheduled_update as scheduled_update_logic from timed_release.models import scheduled_update as scheduled_update_model from timed_release.utils import feature_flag @db.test_schema @pytest.mark.parametrize( ( 'schedule_datetime', 'time_offset', 'process_at_datetime' ), [ ( '2023-02-11T00:00:00.000Z', '01:00', '2023-02-10T23:00:00Z' ), ( '2023-02-11T00:00:00Z', '00:30', '2023-02-10T23:30:00Z' ) ] ) @patch( 'timed_release.logic.scheduled_update.g', spec=['log', 'request_context'] ) def test_upsert_scheduled_update_V1_workflow( mock_g, schedule_datetime, time_offset, process_at_datetime, mocker): """Test that upsert scheduled update for V1 workflow.""" product_id = 2958087 warn_at = parser.isoparse(process_at_datetime) - timedelta(hours=2) warn_at_datetime = warn_at.strftime('%Y-%m-%dT%H:%M:%SZ') orchard_identity_id = '03e83149-3c7f-4587-940b-ce83dec34bc8' logic_data = { 'delivery_store_ids': [268], 'schedule_datetime': schedule_datetime, 'schedule_time_offset': time_offset, 'update': { 'sale_start_date': '2023-03-03' } } model_data = { 'delivery_store_ids': [268], 'schedule_datetime': schedule_datetime, 'process_at_datetime': process_at_datetime, 'schedule_time_offset': time_offset, 'update': { 'sale_start_date': '2023-03-03' } } expected_model_result = { 'product_id': product_id, 'schedule_datetime': schedule_datetime, 'schedule_time_offset': time_offset, 'process_at_datetime': process_at_datetime, 'delivery_store_ids': [268], 'scheduled_by': '03e83149-3c7f-4587-940b-ce83dec34bc8', 'update': { 'sale_start_date': '2023-03-03' }, } expected_scheduler_result = {'Arn': '11223344'} mocker.patch.object( scheduled_update_model, 'upsert', return_value=expected_model_result ) mocker.patch.object( scheduler, 'get_scheduler_client', return_value='scheduler') mock_upsert_event_schedule = mocker.patch.object( scheduler, 'upsert_event_schedule', return_value=expected_scheduler_result ) mocker.patch.object( scheduler, 'delete_event_schedule', return_value=MagicMock() ) mocker.patch.object( feature_flag, 'is_feature_enabled', return_value=False ) result = scheduled_update_logic.upsert( product_id, logic_data, orchard_identity_id) scheduled_update_model.upsert.assert_called_with( product_id, model_data, orchard_identity_id, session=ANY ) assert result == expected_model_result assert mock_upsert_event_schedule.call_count == 2 mock_upsert_event_schedule.assert_has_calls([ mocker.call( schedule_name=product_id, scheduler_group=config.SCHEDULER_GROUP, scheduler_role_arn=config.SCHEDULER_ROLE_ARN, description=f'Metadata update for Product: {product_id}', schedule_datetime=process_at_datetime, target_arn=config.TRIGGER_PRODUCT_UPDATE_LAMBDA_ARN, payload={'productId': product_id} ), mocker.call( schedule_name=f'{product_id}-warning', scheduler_group=config.SCHEDULER_GROUP, scheduler_role_arn=config.SCHEDULER_ROLE_ARN, description=( f'Pre-metadata update warning for Product: {product_id}'), schedule_datetime=warn_at_datetime, target_arn=config.NOTIFY_LABEL_MANAGER_LAMBDA_ARN, payload={ 'productId': product_id, 'scheduledByIdentityId': orchard_identity_id, 'processAt': process_at_datetime } ) ]) @db.test_schema @pytest.mark.parametrize( ( 'schedule_datetime', 'time_offset', 'process_at_datetime', 'is_upsert_schedule_successful', 'event_schedule_response', 'warning_schedule_response', 'delete_event_schedule_response', 'delete_warning_schedule_response', 'expected_status', 'expected_calls' ), [ # 1: Success case ( '2023-02-11T00:00:00Z', '00:30', '2023-02-10T23:30:00Z', True, MagicMock(), MagicMock(), MagicMock(), MagicMock(), http_status.OK, { 'upsert_event_schedule_called': True, 'upsert_warning_schedule_called': True } ), # 2: Error on event upsert schedule call ( '2023-02-11T00:00:00Z', '00:30', '2023-02-10T23:30:00Z', True, Exception('event schedule failed'), MagicMock(), MagicMock(), MagicMock(), http_status.INTERNAL_ERROR, { 'upsert_event_schedule_called': True, 'upsert_warning_schedule_called': False } ), # 3: Error on warning upsert schedule call ( '2023-02-11T00:00:00Z', '00:30', '2023-02-10T23:30:00Z', True, MagicMock(), Exception('warning schedule failed'), MagicMock(), MagicMock(), http_status.INTERNAL_ERROR, { 'upsert_event_schedule_called': True, 'upsert_warning_schedule_called': True } ), # 4: Error on delete schedule call after upsert event schedule ( '2023-02-11T00:00:00Z', '00:30', '2023-02-10T23:30:00Z', True, MagicMock(), MagicMock(), Exception('delete event schedule failed'), MagicMock(), http_status.INTERNAL_ERROR, { 'upsert_event_schedule_called': True, 'upsert_warning_schedule_called': False } ), # 5: Error on delete schedule call after upsert warning schedule ( '2023-02-11T00:00:00Z', '00:30', '2023-02-10T23:30:00Z', True, MagicMock(), MagicMock(), MagicMock(), Exception('delete warning schedule failed'), http_status.INTERNAL_ERROR, { 'upsert_event_schedule_called': True, 'upsert_warning_schedule_called': True } ), # 6: Upsert model call failure ( '2023-02-11T00:00:00Z', '00:30', '2023-02-10T23:30:00Z', False, MagicMock(), MagicMock(), MagicMock(), MagicMock(), http_status.INTERNAL_ERROR, { 'upsert_event_schedule_called': False, 'upsert_warning_schedule_called': False, 'delete_schedule_called': False } ) ] ) @patch( 'timed_release.logic.scheduled_update.g', spec=['log', 'request_context'] ) def test_upsert_scheduled_update_V2_workflow( mock_g, schedule_datetime, time_offset, process_at_datetime, is_upsert_schedule_successful, event_schedule_response, warning_schedule_response, delete_event_schedule_response, delete_warning_schedule_response, expected_status, expected_calls, mocker ): """Test upsert scheduled update for V2 workflow.""" product_id = 2958087 warn_at = parser.isoparse(process_at_datetime) - timedelta(hours=2) warn_at_datetime = warn_at.strftime('%Y-%m-%dT%H:%M:%SZ') orchard_identity_id = '03e83149-3c7f-4587-940b-ce83dec34bc8' logic_data = { 'delivery_store_ids': [268], 'schedule_datetime': schedule_datetime, 'schedule_time_offset': time_offset, 'update': { 'sale_start_date': '2023-03-03' } } model_data = { 'delivery_store_ids': [268], 'schedule_datetime': schedule_datetime, 'process_at_datetime': process_at_datetime, 'schedule_time_offset': time_offset, 'update': { 'sale_start_date': '2023-03-03' } } expected_message = None if is_upsert_schedule_successful: expected_message = { 'product_id': product_id, 'schedule_datetime': schedule_datetime, 'schedule_time_offset': time_offset, 'process_at_datetime': process_at_datetime, 'delivery_store_ids': [268], 'scheduled_by': '03e83149-3c7f-4587-940b-ce83dec34bc8', 'update': { 'sale_start_date': '2023-03-03' } } upsert_response = response.Response( status=http_status.OK, message=expected_message ) else: upsert_response = response.Response( status=http_status.INTERNAL_ERROR ) mocker.patch.object( scheduled_update_model, 'upsert', return_value=upsert_response) mocker.patch.object( scheduler, 'get_scheduler_client', return_value='scheduler') mocker.patch.object( feature_flag, 'is_feature_enabled', return_value=True) mock_upsert_event_schedule = MagicMock(side_effect=[ event_schedule_response, warning_schedule_response, MagicMock() ]) mocker.patch.object( scheduler, 'upsert_event_schedule', mock_upsert_event_schedule ) mock_delete_schedule = MagicMock(side_effect=[ delete_event_schedule_response, delete_warning_schedule_response, MagicMock(), MagicMock() ]) mocker.patch.object( scheduler, 'delete_event_schedule', mock_delete_schedule ) result = scheduled_update_logic.upsert( product_id, logic_data, orchard_identity_id) scheduled_update_model.upsert.assert_called_with( product_id, model_data, orchard_identity_id, session=ANY ) if is_upsert_schedule_successful: if expected_calls['upsert_event_schedule_called']: assert mock_upsert_event_schedule.call_args_list[0] == call( schedule_name=product_id, scheduler_group=config.WS_SCHEDULER_GROUP, scheduler_role_arn=config.WS_SCHEDULER_ROLE_ARN, schedule_datetime=process_at_datetime, description=f'Metadata update for Product: {product_id}', target_arn=config.TRIGGER_SFN_WS_UPDATE_METADATA_ARN, payload={ 'productId': product_id, 'eventType': UPSERT_SCHEDULE, 'salesDateTime': schedule_datetime, 'source': OA } ) if isinstance(event_schedule_response, Exception): assert not mock_delete_schedule.called else: assert mock_delete_schedule.call_args_list[0] == call( product_id, config.SCHEDULER_GROUP ) elif isinstance(delete_event_schedule_response, Exception): assert mock_upsert_event_schedule.call_count == 1 assert mock_delete_schedule.call_args_list[0] == call( product_id, config.SCHEDULER_GROUP ) assert mock_delete_schedule.call_args_list[1] == call( product_id, config.WS_SCHEDULER_GROUP ) else: assert not mock_upsert_event_schedule.called assert not mock_delete_schedule.called if expected_calls['upsert_warning_schedule_called']: assert mock_upsert_event_schedule.call_args_list[1] == call( schedule_name=f'{product_id}-warning', scheduler_group=config.WS_SCHEDULER_GROUP, scheduler_role_arn=config.WS_SCHEDULER_ROLE_ARN, schedule_datetime=warn_at_datetime, description=( f'Pre-metadata update warning for Product: {product_id}' ), target_arn=config.WS_SEND_NOTIFICATION_LAMBDA_ARN, payload={ 'productId': product_id, 'eventProcessOn': process_at_datetime, 'eventType': WARNING_EMAIL, 'salesDateTime': schedule_datetime, 'source': OA } ) if isinstance(warning_schedule_response, Exception): assert mock_delete_schedule.call_args_list[1] == call( product_id, config.WS_SCHEDULER_GROUP ) else: assert mock_delete_schedule.call_args_list[1] == call( f'{product_id}-warning', config.SCHEDULER_GROUP ) if isinstance(delete_warning_schedule_response, Exception): assert mock_delete_schedule.call_args_list[2] == call( f'{product_id}-warning', config.WS_SCHEDULER_GROUP ) assert mock_delete_schedule.call_args_list[3] == call( product_id, config.WS_SCHEDULER_GROUP ) assert mock_upsert_event_schedule.call_args_list[2] == call( schedule_name=product_id, scheduler_group=config.SCHEDULER_GROUP, scheduler_role_arn=config.SCHEDULER_ROLE_ARN, description=f'Metadata update for Product: {product_id}', schedule_datetime=process_at_datetime, target_arn=config.TRIGGER_PRODUCT_UPDATE_LAMBDA_ARN, payload={'productId': product_id} ) assert result.status == expected_status if result.status == http_status.OK: assert result.message == expected_message @db.test_schema @pytest.mark.parametrize( ( 'scheduler_group', 'feature_flag_value' ), [ ( config.SCHEDULER_GROUP, False ), ( config.WS_SCHEDULER_GROUP, True ) ] ) @patch( 'timed_release.logic.scheduled_update.g', spec=['log', 'request_context'] ) def test_delete_scheduled_update_success( mock_g, scheduler_group, feature_flag_value, mocker): """Test that delete calls model layer and delete schedule from aws.""" product_id = 2958087 mocker.patch.object( scheduled_update_model, 'delete' ) mocker.patch.object( feature_flag, 'is_feature_enabled', return_value=feature_flag_value) mocker.patch.object( scheduler, 'get_scheduler_client', return_value='scheduler') mock_scheduler = mocker.patch.object( scheduler, 'delete_event_schedule', return_value={}) scheduled_update_logic.delete(product_id) scheduled_update_model.delete.assert_called_with(product_id, session=ANY) assert mock_scheduler.call_count == 2 mock_scheduler.assert_has_calls([ mocker.call(product_id, scheduler_group), mocker.call(f'{product_id}-warning', scheduler_group) ]) @db.test_schema @pytest.mark.parametrize( ( 'scheduler_group', 'feature_flag_value', 'delete_event_schedule_response', 'delete_warning_schedule_response', 'expected_calls', ), [ # delete event schedule failed in OA workflow ( config.SCHEDULER_GROUP, False, Exception('delete schedule failed'), MagicMock(), { 'delete_event_schedule_called': True, 'delete_warning_schedule_called': False, 'upsert_event_schedule_called': False } ), # delete warning schedule failed in OA workflow ( config.SCHEDULER_GROUP, False, MagicMock(), Exception('delete warning schedule failed'), { 'delete_event_schedule_called': True, 'delete_warning_schedule_called': True, 'upsert_event_schedule_called': True } ), # delete event schedule failed in OA + WS merged workflow ( config.WS_SCHEDULER_GROUP, True, Exception('delete schedule failed'), MagicMock(), { 'delete_event_schedule_called': True, 'delete_warning_schedule_called': False, 'upsert_event_schedule_called': False } ), # delete warning schedule failed in OA + WS merged workflow ( config.WS_SCHEDULER_GROUP, True, MagicMock(), Exception('delete warning schedule failed'), { 'delete_event_schedule_called': True, 'delete_warning_schedule_called': True, 'upsert_event_schedule_called': True } ) ] ) @patch( 'timed_release.logic.scheduled_update.g', spec=['log', 'request_context'] ) def test_delete_scheduled_update_failures( mock_g, scheduler_group, feature_flag_value, delete_event_schedule_response, delete_warning_schedule_response, expected_calls, mocker ): """Test that delete calls model layer and delete schedule from aws.""" product_id = 2958087 mocker.patch.object( scheduled_update_model, 'delete' ) mocker.patch.object( feature_flag, 'is_feature_enabled', return_value=feature_flag_value) mocker.patch.object( scheduler, 'get_scheduler_client', return_value='scheduler') mock_delete_schedule = MagicMock(side_effect=[ delete_event_schedule_response, delete_warning_schedule_response ]) mocker.patch.object( scheduler, 'delete_event_schedule', mock_delete_schedule ) mock_upsert_scheduler = mocker.patch.object( scheduler, 'upsert_event_schedule', return_value={} ) schedule_data = { 'product_id': 2958087, 'schedule_datetime': '2023-02-11T01:00:00Z', 'process_at_datetime': '2023-02-11T00:00:00Z', 'schedule_time_offset': '01:00', 'delivery_store_ids': [268], 'scheduled_by': '03e83149-3c7f-4587-940b-ce83dec34bc8', 'update': {'sale_start_date': '2022-01-11'} } mock_get_schedule = mocker.patch.object( scheduled_update_model, 'get', return_value=schedule_data ) result = scheduled_update_logic.delete(product_id) scheduled_update_model.delete.assert_called_with(product_id, session=ANY) if isinstance(delete_event_schedule_response, Exception): mock_delete_schedule.assert_has_calls([ mocker.call(product_id, scheduler_group) ]) else: mock_delete_schedule.assert_has_calls([ mocker.call(product_id, scheduler_group), mocker.call(f'{product_id}-warning', scheduler_group) ]) if isinstance(delete_warning_schedule_response, Exception): mock_get_schedule.assert_called_with(product_id) if feature_flag_value: mock_upsert_scheduler.assert_called_with( schedule_name=product_id, scheduler_group=config.WS_SCHEDULER_GROUP, scheduler_role_arn=config.WS_SCHEDULER_ROLE_ARN, schedule_datetime=schedule_data['process_at_datetime'], description=f'Metadata update for Product: {product_id}', target_arn=config.TRIGGER_SFN_WS_UPDATE_METADATA_ARN, payload={ 'productId': product_id, 'eventType': UPSERT_SCHEDULE, 'salesDateTime': schedule_data['schedule_datetime'], 'source': OA } ) else: mock_upsert_scheduler.assert_called_with( schedule_name=product_id, scheduler_group=config.SCHEDULER_GROUP, scheduler_role_arn=config.SCHEDULER_ROLE_ARN, description=f'Metadata update for Product: {product_id}', schedule_datetime=schedule_data['process_at_datetime'], target_arn=config.TRIGGER_PRODUCT_UPDATE_LAMBDA_ARN, payload={'productId': product_id} ) assert result.status == http_status.INTERNAL_ERROR def test_get_scheduled_update(mocker): """Test that get calls the model layer and returns its response.""" product_id = 2958087 expected_result = { 'product_id': 2958087, 'schedule_datetime': '2023-02-11T00:00:00Z', 'schedule_time_offset': '00:00', 'delivery_store_ids': [268], 'scheduled_by': '03e83149-3c7f-4587-940b-ce83dec34bc8', 'update': {'sale_start_date': '2022-01-11'}} mocker.patch.object( scheduled_update_model, 'get', return_value=expected_result ) result = scheduled_update_logic.get_scheduled_update(product_id) scheduled_update_model.get.assert_called_with(product_id) assert result == expected_result def test_execute_scheduled_update(mocker): """Test executing scheduled update logic.""" product_id = 1234 expected_schedule_response = { 'product_id': 1234, 'schedule_datetime': '2023-02-11T00:00:00Z', 'schedule_time_offset': '00:00', 'delivery_store_ids': [268], 'scheduled_by': '03e83149-3c7f-4587-940b-ce83dec34bc8', 'update': {'sale_start_date': '2022-01-11'}} mocker.patch.object( scheduled_update_model, 'get', return_value=response.Response( message=expected_schedule_response, status=200 ) ) sfn_response = { 'ResponseMetadata': { 'RequestId': '4c779c7b-296d-4a0c-8bbc-0197cb5fb93c', 'HTTPStatusCode': 200, 'HTTPHeaders': { 'x-amzn-requestid': '4c779c7b-296d-4a0c-8bbc-0197cb5fb93c', 'date': 'Wed, 02 Aug 2023 14:07:40 GMT', 'content-type': 'application/x-amz-json-1.0', 'content-length': '168', 'connection': 'keep-alive' }, 'RetryAttempts': 0 } } mocker.patch.object( sfn, 'execute_step_function', return_value=sfn_response ) result = scheduled_update_logic.execute_scheduled_update(product_id) assert result.message == sfn_response['ResponseMetadata'] assert result.status == 200