"""Tests for Lambda handler.""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from src.errors import InputValidationError, TransientError from src.schemas.responses import ProcessResult class TestHandler: """Test app.handler.""" @patch('app.build_snowflake_config') @patch('app.get_ows_client') @patch('app.parse_earnings_transfers') @patch('app.SnowflakeConnectionFactory') @patch('app.EarningsTransferProcessor') def test_validation_error_returns_error_status( self, mock_processor_cls: MagicMock, mock_sf: MagicMock, mock_parse: MagicMock, mock_ows: MagicMock, mock_build_config: MagicMock, ) -> None: """InputValidationError is caught and returns status=ERROR (no retry).""" from app import handler mock_parse.side_effect = InputValidationError('invalid record') mock_processor_cls.return_value = MagicMock() context = MagicMock() context.invoked_function_arn = 'arn:test' result = handler({}, context) assert result['status'] == 'ERROR' assert 'invalid record' in result['error'] @patch('app.build_snowflake_config') @patch('app.get_ows_client') @patch('app.parse_earnings_transfers') @patch('app.SnowflakeConnectionFactory') @patch('app.EarningsTransferProcessor') def test_transient_error_propagates( self, mock_processor_cls: MagicMock, mock_sf: MagicMock, mock_parse: MagicMock, mock_ows: MagicMock, mock_build_config: MagicMock, ) -> None: """TransientError propagates out of handler for Lambda retry.""" from app import handler mock_records = [MagicMock()] mock_parse.return_value = mock_records mock_processor = MagicMock() mock_processor.process_records.side_effect = TransientError('Snowflake timeout') mock_processor_cls.return_value = mock_processor context = MagicMock() context.invoked_function_arn = 'arn:test' with pytest.raises(TransientError, match='Snowflake timeout'): handler({}, context) @patch('app.build_snowflake_config') @patch('app.get_ows_client') @patch('app.parse_earnings_transfers') @patch('app.SnowflakeConnectionFactory') @patch('app.EarningsTransferProcessor') def test_context_without_arn_attribute( self, mock_processor_cls: MagicMock, mock_sf: MagicMock, mock_parse: MagicMock, mock_ows: MagicMock, mock_build_config: MagicMock, ) -> None: """Handler uses getattr for context.invoked_function_arn (safe for None).""" from app import handler mock_parse.return_value = [MagicMock()] mock_processor = MagicMock() mock_processor.process_records.return_value = ProcessResult( input_key='ows-royalties', output_key='output/ows-royalties_output.xlsx', format='ows-royalties', record_count=1, match_count=1, mismatch_count=0, ) mock_processor_cls.return_value = mock_processor # Use a plain object without invoked_function_arn context = object() result = handler({}, context) assert result['status'] == 'OK' class TestManualTrigger: """Test manual trigger (non-S3) handler paths.""" @patch('app.build_snowflake_config') @patch('app.get_ows_client') @patch('app.parse_earnings_transfers') @patch('app.SnowflakeConnectionFactory') @patch('app.EarningsTransferProcessor') def test_empty_event_loads_from_ows( self, mock_processor_cls: MagicMock, mock_sf: MagicMock, mock_parse: MagicMock, mock_ows: MagicMock, mock_build_config: MagicMock, ) -> None: """Empty event triggers manual path, loads from OWS client.""" from app import handler mock_records = [MagicMock()] mock_parse.return_value = mock_records mock_processor = MagicMock() mock_processor.process_records.return_value = ProcessResult( input_key='ows-royalties', output_key='output/ows-royalties_output.xlsx', format='ows-royalties', record_count=3, match_count=3, mismatch_count=0, ) mock_processor_cls.return_value = mock_processor context = MagicMock() context.invoked_function_arn = 'arn:test' result = handler({}, context) assert result['status'] == 'OK' assert result['record_count'] == 3 mock_parse.assert_called_once() mock_processor.process_records.assert_called_once_with(mock_records) @patch('app.build_snowflake_config') @patch('app.get_ows_client') @patch('app.parse_earnings_transfers') @patch('app.SnowflakeConnectionFactory') @patch('app.EarningsTransferProcessor') def test_none_event_loads_from_ows( self, mock_processor_cls: MagicMock, mock_sf: MagicMock, mock_parse: MagicMock, mock_ows: MagicMock, mock_build_config: MagicMock, ) -> None: """None event triggers manual path.""" from app import handler mock_parse.return_value = [MagicMock()] mock_processor = MagicMock() mock_processor.process_records.return_value = ProcessResult( input_key='ows-royalties', output_key='output/ows-royalties_output.xlsx', format='ows-royalties', record_count=1, match_count=1, mismatch_count=0, ) mock_processor_cls.return_value = mock_processor context = MagicMock() context.invoked_function_arn = 'arn:test' result = handler(None, context) assert result['status'] == 'OK' mock_processor.process_records.assert_called_once() @patch('app.build_snowflake_config') @patch('app.get_ows_client') @patch('app.parse_earnings_transfers') @patch('app.SnowflakeConnectionFactory') @patch('app.EarningsTransferProcessor') def test_non_s3_event_loads_from_ows( self, mock_processor_cls: MagicMock, mock_sf: MagicMock, mock_parse: MagicMock, mock_ows: MagicMock, mock_build_config: MagicMock, ) -> None: """Non-S3 event with partial detail loads from OWS.""" from app import handler mock_parse.return_value = [MagicMock()] mock_processor = MagicMock() mock_processor.process_records.return_value = ProcessResult( input_key='ows-royalties', output_key='output/ows-royalties_output.xlsx', format='ows-royalties', record_count=1, match_count=1, mismatch_count=0, ) mock_processor_cls.return_value = mock_processor context = MagicMock() context.invoked_function_arn = 'arn:test' event = {'source': 'custom.trigger', 'user': 'admin'} result = handler(event, context) assert result['status'] == 'OK' mock_processor.process_records.assert_called_once() @patch('app.build_snowflake_config') @patch('app.get_ows_client') @patch('app.parse_earnings_transfers') @patch('app.SnowflakeConnectionFactory') @patch('app.EarningsTransferProcessor') def test_manual_uses_config_bucket( self, mock_processor_cls: MagicMock, mock_sf: MagicMock, mock_parse: MagicMock, mock_ows: MagicMock, mock_build_config: MagicMock, ) -> None: """Manual trigger uses config.S3_BUCKET_NAME as fallback.""" from app import handler mock_parse.return_value = [MagicMock()] mock_processor = MagicMock() mock_processor.process_records.return_value = ProcessResult( input_key='ows-royalties', output_key='output/ows-royalties_output.xlsx', format='ows-royalties', record_count=1, match_count=1, mismatch_count=0, ) mock_processor_cls.return_value = mock_processor context = MagicMock() result = handler({}, context) assert result['status'] == 'OK' # Verify processor was constructed (bucket is config fallback) mock_processor_cls.assert_called_once()