"""Unit tests for app module.""" import json from unittest.mock import Mock from unittest.mock import patch from content_utils.exceptions import IneligibleEventError from content_utils.exceptions import NoProductDataException import pytest from src import app @patch('src.app.lambda_logger') @patch('src.app.store_product_initiate_auto_approval') def test_handler_success_single_record(mock_initiate_workflow, mock_logger): """Test successful handler execution with single record.""" # Arrange event = { 'Records': [ { 'messageId': 'msg-123', 'body': json.dumps({ 'product_id': '12345', 'review_queue_id': '67890', 'operation_type': 'add_to_index' }) } ], 'messageId': 'event-123' } context = Mock() # Act result = app.handler(event, context) # Assert assert result == {'batchItemFailures': []} mock_initiate_workflow.assert_called_once() call_args = mock_initiate_workflow.call_args[0][0] assert call_args['product_id'] == '12345' assert call_args['review_queue_id'] == '67890' assert call_args['operation_type'] == 'add_to_index' # Verify logger calls mock_logger.start.assert_called_once() mock_logger.end.assert_called_once() mock_logger.set_data.assert_called() @patch('src.app.lambda_logger') @patch('src.app.store_product_initiate_auto_approval') def test_handler_success_multiple_records(mock_initiate_workflow, mock_logger): """Test successful handler execution with multiple records.""" # Arrange event = { 'Records': [ { 'messageId': 'msg-1', 'body': json.dumps({ 'product_id': '111', 'review_queue_id': '222', 'operation_type': 'add' }) }, { 'messageId': 'msg-2', 'body': json.dumps({ 'product_id': '333', 'review_queue_id': '444', 'operation_type': 'update' }) }, { 'messageId': 'msg-3', 'body': json.dumps({ 'product_id': '555', 'review_queue_id': '666', 'operation_type': 'add' }) } ] } context = Mock() # Act result = app.handler(event, context) # Assert assert result == {'batchItemFailures': []} assert mock_initiate_workflow.call_count == 3 assert mock_logger.start.call_count == 3 assert mock_logger.end.call_count == 3 @patch('src.app.lambda_logger') def test_handler_missing_records_key(mock_logger): """Test handler when Records key is missing.""" # Arrange event = {} context = Mock() # Act result = app.handler(event, context) # Assert assert result == {'batchItemFailures': []} mock_logger.logger.info.assert_called_with('SQS has no records') @patch('src.app.lambda_logger') @patch('src.app.store_product_initiate_auto_approval') def test_handler_processes_all_records_even_with_skipped_messages( mock_initiate_workflow, mock_logger ): """Test handler processes all records with skipped messages.""" # Arrange event = { 'Records': [ { 'messageId': 'msg-1', 'body': json.dumps({'product_id': '111', 'review_queue_id': '222'}) }, { 'messageId': 'msg-2', # Missing body - triggers InvalidMessageException }, { 'messageId': 'msg-3', 'body': json.dumps({'product_id': '333', 'review_queue_id': '444'}) }, { 'messageId': 'msg-4', 'body': json.dumps({'product_id': '555', 'review_queue_id': '666'}) }, { 'messageId': 'msg-6', 'body': json.dumps({'product_id': '999', 'review_queue_id': '000'}) } ] } context = Mock() # Each record triggers different type of error mock_initiate_workflow.side_effect = [ None, # First succeeds # Second skipped due to missing body IneligibleEventError('Not eligible'), # Third raises IneligibleEventError NoProductDataException('No product data'), # Fourth raises NoProductDataException None # Fifth succeeds ] # Act result = app.handler(event, context) # Assert - handler completes successfully even with skipped messages assert result == {'batchItemFailures': []} # Verify all records were processed assert mock_initiate_workflow.call_count == 4 # All except msg-2 (missing body) assert mock_logger.start.call_count == 5 # All records assert mock_logger.end.call_count == 5 # All records # Verify different error statuses were logged set_data_calls = mock_logger.set_data.call_args_list statuses = [ call[1].get('status') for call in set_data_calls if 'status' in call[1] ] assert 'success' in statuses # From successful records # From InvalidMessageException, IneligibleEventError, # NoProductDataException assert 'skip' in statuses @patch('src.app.lambda_logger') @patch('src.app.store_product_initiate_auto_approval') def test_handler_returns_batch_failures_when_general_exception_occurs( mock_initiate_workflow, mock_logger ): """Test handler returns batch item failures when exception occurs.""" # Arrange event = { 'Records': [ { 'messageId': 'msg-1', 'body': json.dumps({'product_id': '111', 'review_queue_id': '222'}) }, { 'messageId': 'msg-2', 'body': json.dumps({'product_id': '333', 'review_queue_id': '444'}) }, { 'messageId': 'msg-3', 'body': json.dumps({'product_id': '555', 'review_queue_id': '666'}) } ] } context = Mock() # First succeeds, second fails with general exception, third succeeds mock_initiate_workflow.side_effect = [ None, # First succeeds Exception('Unexpected error'), # Second raises general Exception None # Third succeeds ] # Act result = app.handler(event, context) # Assert - Only failed message should be in batch failures assert result == { 'batchItemFailures': [{'itemIdentifier': 'msg-2'}] } # Verify all records were processed assert mock_initiate_workflow.call_count == 3 assert mock_logger.start.call_count == 3 assert mock_logger.end.call_count == 3 # Verify different statuses were logged set_data_calls = mock_logger.set_data.call_args_list statuses = [call[1].get('status') for call in set_data_calls if 'status' in call[1]] assert 'success' in statuses # From successful records assert 'error' in statuses # From general Exception # Tests for store_product_initiate_auto_approval function @patch('src.app.step_function.invoke_auto_approval_sfn') @patch('src.app.ff.is_eligible_for_auto_approval') @patch('src.app.s3.wait_until_exists') @patch('src.app.s3.upload_snapshot') @patch('src.app.s3.get_object_key') @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') @patch('src.app.config.AWS_BUCKET_NAME', 'test-bucket') def test_store_product_initiate_auto_approval_success( mock_logger, mock_get_product, mock_get_key, mock_upload, mock_wait_until_exists, mock_is_eligible_for_auto_approval, mock_invoke_sfn, mock_msg_body, mock_product_data ): """Test successful execution of store_product_initiate_auto_approval.""" # Arrange mock_get_product.return_value = mock_product_data mock_get_key.return_value = 'product/12345/67890.json' mock_upload.return_value = 200 mock_is_eligible_for_auto_approval.return_value = True mock_invoke_sfn.return_value = 'arn:aws:states:us-east-1:123456789012:execution:test' # Act app.store_product_initiate_auto_approval(mock_msg_body) # Assert mock_get_product.assert_called_once_with(json.dumps(mock_msg_body)) mock_get_key.assert_called_once_with( mock_msg_body['product_id'], mock_msg_body['review_queue_id'] ) mock_upload.assert_called_once_with( mock_product_data, 'product/12345/67890.json' ) mock_is_eligible_for_auto_approval.assert_called_once_with(mock_product_data) mock_invoke_sfn.assert_called_once() # Verify the payload structure call_args = mock_invoke_sfn.call_args[0][0] assert call_args['data']['product_id'] == mock_msg_body['product_id'] assert call_args['data']['review_queue_id'] == mock_msg_body['review_queue_id'] assert call_args['data']['use_auto_approval'] is True assert call_args['data']['product_metadata_s3_url'] == ( 's3://test-bucket/product/12345/67890.json' ) assert 'product_metadata' not in call_args['data'] # Verify lambda_logger.set_data calls set_data_calls = mock_logger.set_data.call_args_list assert len(set_data_calls) == 3 # First call: product details assert set_data_calls[0][1] == { 'queue_id': mock_msg_body['review_queue_id'], 'product_id': mock_msg_body['product_id'], 'operation_type': mock_msg_body['operation_type'] } # Second call: S3 upload details assert set_data_calls[1][1] == { 's3_upload_status': 200, 'snapshot_object_key': 'product/12345/67890.json', 'snapshot_bucket': 'test-bucket' } # Third call: execution ARN assert set_data_calls[2][1] == { 'execution_arn': 'arn:aws:states:us-east-1:123456789012:execution:test' } @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') def test_store_product_initiate_auto_approval_raises_no_product_data_exception( mock_logger, mock_get_product, mock_msg_body ): """Test that NoProductDataException is raised when product data is None.""" # Arrange mock_get_product.return_value = None # Act & Assert with pytest.raises(NoProductDataException) as exc_info: app.store_product_initiate_auto_approval(mock_msg_body) assert 'Failed to query complete product' in str(exc_info.value) mock_get_product.assert_called_once_with(json.dumps(mock_msg_body)) @patch('src.app.s3.upload_snapshot') @patch('src.app.s3.get_object_key') @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') @patch('src.app.config.AWS_BUCKET_NAME', 'test-bucket') def test_store_product_initiate_auto_approval_raises_error_with_s3_upload( mock_logger, mock_get_product, mock_get_key, mock_upload, mock_msg_body, mock_product_data ): """Test that error is raised when s3 upload fails.""" # Arrange mock_get_product.return_value = mock_product_data mock_get_key.return_value = 'product/12345/67890.json' mock_upload.side_effect = Exception('S3 upload failed') # Act & Assert with pytest.raises(Exception) as exc_info: app.store_product_initiate_auto_approval(mock_msg_body) assert 'S3 upload failed' in str(exc_info.value) mock_upload.assert_called_once_with( mock_product_data, 'product/12345/67890.json' ) @patch('src.app.step_function.invoke_auto_approval_sfn') @patch('src.app.ff.is_eligible_for_auto_approval') @patch('src.app.s3.wait_until_exists') @patch('src.app.s3.upload_snapshot') @patch('src.app.s3.get_object_key') @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') @patch('src.app.config.AWS_BUCKET_NAME', 'test-bucket') def test_store_product_initiate_auto_approval_with_auto_approval_disabled( mock_logger, mock_get_product, mock_get_key, mock_upload, mock_wait_until_exists, mock_is_eligible_for_auto_approval, mock_invoke_sfn, mock_msg_body, mock_product_data ): """Test successful execution when auto-approval is not eligible.""" # Arrange mock_get_product.return_value = mock_product_data mock_get_key.return_value = 'product/12345/67890.json' mock_upload.return_value = 200 mock_is_eligible_for_auto_approval.return_value = False mock_invoke_sfn.return_value = 'arn:aws:states:us-east-1:123456789012:execution:test' # Act app.store_product_initiate_auto_approval(mock_msg_body) # Assert mock_is_eligible_for_auto_approval.assert_called_once_with(mock_product_data) mock_invoke_sfn.assert_called_once() # Verify the payload has use_auto_approval set to False call_args = mock_invoke_sfn.call_args[0][0] assert call_args['data']['use_auto_approval'] is False @patch('src.app.step_function.invoke_auto_approval_sfn') @patch('src.app.ff.is_eligible_for_auto_approval') @patch('src.app.s3.wait_until_exists') @patch('src.app.s3.upload_snapshot') @patch('src.app.s3.get_object_key') @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') @patch('src.app.config.AWS_BUCKET_NAME', 'test-bucket') def test_store_product_initiate_auto_approval_raises_error_with_step_function_invoke( mock_logger, mock_get_product, mock_get_key, mock_upload, mock_wait_until_exists, mock_is_eligible_for_auto_approval, mock_invoke_sfn, mock_msg_body, mock_product_data ): """Test that error is raised when step function invocation fails.""" # Arrange mock_get_product.return_value = mock_product_data mock_get_key.return_value = 'product/12345/67890.json' mock_upload.return_value = 'success' mock_is_eligible_for_auto_approval.return_value = True mock_invoke_sfn.side_effect = Exception('Step function invocation failed') # Act & Assert with pytest.raises(Exception) as exc_info: app.store_product_initiate_auto_approval(mock_msg_body) assert 'Step function invocation failed' in str(exc_info.value) mock_invoke_sfn.assert_called_once() @patch('src.logic.step_function.time.time') @patch('src.logic.step_function.get_sfn_client') @patch('src.app.ff.is_eligible_for_auto_approval') @patch('src.app.s3.wait_until_exists') @patch('src.app.s3.upload_snapshot') @patch('src.app.s3.get_object_key') @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') @patch('src.app.config.AWS_BUCKET_NAME', 'test-bucket') def test_store_product_initiate_auto_approval_invokes_step_function( mock_logger, mock_get_product, mock_get_key, mock_upload, mock_wait_until_exists, mock_is_eligible_for_auto_approval, mock_get_sfn_client, mock_time, mock_msg_body, mock_product_data, mock_step_function_payload ): """Test that step function is invoked with correct payload structure.""" # Arrange mock_time.return_value = 1739232000.0 mock_get_product.return_value = mock_product_data mock_get_key.return_value = 'product/12345/67890.json' mock_upload.return_value = 200 mock_is_eligible_for_auto_approval.return_value = True # Mock the boto3 Step Functions client mock_sfn_client = Mock() mock_sfn_client.start_execution.return_value = { 'executionArn': 'arn:aws:states:us-east-1:123456789012:execution:test-exec', 'startDate': '2026-02-09T12:00:00.000Z' } mock_get_sfn_client.return_value = mock_sfn_client # Act app.store_product_initiate_auto_approval(mock_msg_body) # Assert - verify the boto3 client was called correctly mock_sfn_client.start_execution.assert_called_once() call_kwargs = mock_sfn_client.start_execution.call_args.kwargs # Verify the state machine ARN assert 'stateMachineArn' in call_kwargs # Verify the execution name format assert call_kwargs['name'] == 'auto-approve-1739232000-review-queue-67890' # Verify the input payload structure payload = json.loads(call_kwargs['input']) assert payload == mock_step_function_payload # Verify logger was called with execution ARN logger_calls = mock_logger.set_data.call_args_list execution_arn_logged = any( 'execution_arn' in call[1] and call[1]['execution_arn'] == 'arn:aws:states:us-east-1:123456789012:execution:test-exec' for call in logger_calls ) assert execution_arn_logged @patch('src.app.step_function.invoke_auto_approval_sfn') @patch('src.app.ff.is_eligible_for_auto_approval') @patch('src.app.s3.wait_until_exists') @patch('src.app.s3.upload_snapshot') @patch('src.app.s3.get_object_key') @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') @patch('src.app.config.AWS_BUCKET_NAME', 'test-bucket') def test_store_product_initiate_auto_approval_waits_for_s3_object( mock_logger, mock_get_product, mock_get_key, mock_upload, mock_wait_until_exists, mock_is_eligible_for_auto_approval, mock_invoke_sfn, mock_msg_body, mock_product_data ): """Test that wait_until_exists is called after upload and before step function.""" mock_get_product.return_value = mock_product_data mock_get_key.return_value = 'snapshots/product-12345-review-queue-67890.json' mock_upload.return_value = 200 mock_is_eligible_for_auto_approval.return_value = True mock_invoke_sfn.return_value = 'arn:aws:states:us-east-1:123456789012:execution:test' call_order = [] mock_upload.side_effect = lambda *a, **kw: call_order.append('upload') or 200 mock_wait_until_exists.side_effect = lambda *a, **kw: call_order.append('wait') mock_invoke_sfn.side_effect = lambda *a, **kw: ( call_order.append('sfn') or 'arn:aws:states:us-east-1:123456789012:execution:test' ) app.store_product_initiate_auto_approval(mock_msg_body) assert call_order == ['upload', 'wait', 'sfn'] mock_wait_until_exists.assert_called_once_with( 'snapshots/product-12345-review-queue-67890.json' ) @patch('src.app.s3.wait_until_exists') @patch('src.app.s3.upload_snapshot') @patch('src.app.s3.get_object_key') @patch('src.app.get_complete_product') @patch('src.app.lambda_logger') def test_store_product_initiate_auto_approval_raises_when_wait_times_out( mock_logger, mock_get_product, mock_get_key, mock_upload, mock_wait_until_exists, mock_msg_body, mock_product_data ): """Test that a WaiterError is wrapped with a descriptive message.""" from botocore.exceptions import WaiterError mock_get_product.return_value = mock_product_data mock_get_key.return_value = 'snapshots/product-12345-review-queue-67890.json' mock_upload.return_value = 200 mock_wait_until_exists.side_effect = WaiterError( 'object_exists', 'Max attempts exceeded', None ) with pytest.raises(Exception) as exc_info: app.store_product_initiate_auto_approval(mock_msg_body) assert 'Timed out waiting for S3 object to exist' in str(exc_info.value) assert 'snapshots/product-12345-review-queue-67890.json' in str(exc_info.value)