"""Test processor.""" from typing import Any from unittest.mock import MagicMock, patch from sync_contract_sap.processor import SyncContractSAPProcessor from sync_contract_sap.schemas import BatchSyncResponse, ContractSyncRow @patch('sync_contract_sap.processor.get_contract_by_id') @patch('sync_contract_sap.processor.SyncContractSAPProcessor._get_action_state') @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_processor_process( mock_contract_processor: Any, mock_account_processor: Any, mock_update_abacus_state: Any, mock_get_action_state: Any, mock_get_contract_by_id: Any, mock_event: dict[str, Any], mock_action_state: dict[str, Any], mock_contract: dict[str, Any], ) -> None: """Test processor.process.""" mock_get_action_state.return_value = mock_action_state mock_get_contract_by_id.return_value = mock_contract mock_account_processor.return_value = MagicMock(process=MagicMock()) mock_contract_processor.return_value = MagicMock(process=MagicMock()) account_processor_instance = mock_account_processor() contract_processor_instance = mock_contract_processor() mock_update_body = {'action_status': 'complete'} processor = SyncContractSAPProcessor() processor.process(mock_event['target_id']) mock_get_action_state.assert_called_once() account_processor_instance.process.assert_called_once() contract_processor_instance.process.assert_called_once() mock_update_abacus_state.assert_called_once_with( mock_action_state['abacus_state_id'], mock_update_body ) @patch('sync_contract_sap.processor.get_contract_by_id') @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SyncContractSAPProcessor._get_action_state') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_processor_process_error( mock_contract_processor: Any, mock_account_processor: Any, mock_get_action_state: Any, mock_update_abacus_state: Any, mock_get_contract_by_id: Any, mock_event: dict[str, Any], mock_action_state: dict[str, Any], mock_contract: dict[str, Any], ) -> None: """Test processor.process. Failure case when a child processor fails. """ mock_get_action_state.return_value = mock_action_state mock_get_contract_by_id.return_value = mock_contract mock_account_processor.return_value = MagicMock( process=MagicMock(side_effect=Exception()) ) mock_contract_processor.return_value = MagicMock(process=MagicMock()) account_processor_instance = mock_account_processor() contract_processor_instance = mock_contract_processor() mock_update_body = {'action_status': 'error'} processor = SyncContractSAPProcessor() processor.process(mock_event['target_id']) mock_get_action_state.assert_called_once() account_processor_instance.process.assert_called_once() contract_processor_instance.process.assert_not_called() mock_update_abacus_state.assert_called_once_with( mock_action_state['abacus_state_id'], mock_update_body ) @patch('sync_contract_sap.processor.get_abacus_states') def test_get_action_state( mock_get_abacus_states: Any, mock_abacus_states: list[dict[str, Any]], ) -> None: """Test processor._get_action_state.""" contract_id = 1 mock_get_abacus_states.return_value = mock_abacus_states processor = SyncContractSAPProcessor() response = processor._get_action_state(contract_id) mock_get_abacus_states.assert_called_once() assert response['abacus_state_id'] == 1 # --- Batch mode tests --- @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_success( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_batch_row: ContractSyncRow, ) -> None: """Test process_batch processes a row cleanly and returns correct counts.""" processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row]) assert result == BatchSyncResponse(total=1, success=1, skipped=0, errors=[]) mock_update_state.assert_called_once_with(42, {'action_status': 'complete'}) @patch('sync_contract_sap.processor.create_abacus_states') @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_creates_missing_states( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_create_states: Any, mock_batch_row_no_state: ContractSyncRow, ) -> None: """Test process_batch calls create_abacus_states when abacus_state_id is None.""" mock_create_states.return_value = [ {'action_name': 'sap_sync', 'abacus_state_id': 99}, {'action_name': 'sync_contract', 'abacus_state_id': 100}, ] processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row_no_state]) mock_create_states.assert_called_once_with(2, 'publishing') assert result.success == 1 mock_update_state.assert_called_once_with(99, {'action_status': 'complete'}) @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_partial_errors( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_batch_row: ContractSyncRow, ) -> None: """Test process_batch records errors per contract and continues the batch.""" row_2 = mock_batch_row.model_copy(update={'contract_id': 2, 'abacus_state_id': 99}) mock_contract_proc.return_value.process.side_effect = [ None, Exception('SAP API error'), ] processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row, row_2]) assert result.total == 2 assert result.success == 1 assert result.skipped == 0 assert len(result.errors) == 1 assert result.errors[0].contract_id == 2 assert 'SAP API error' in result.errors[0].error @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_error_updates_state( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_batch_row: ContractSyncRow, ) -> None: """Test that a per-contract error sets the state to 'error'.""" mock_account_proc.return_value.process.side_effect = Exception('boom') processor = SyncContractSAPProcessor() processor.process_batch([mock_batch_row]) mock_update_state.assert_called_once_with(42, {'action_status': 'error'}) def test_process_batch_empty() -> None: """Test process_batch returns zero counts for an empty row list.""" processor = SyncContractSAPProcessor() result = processor.process_batch([]) assert result == BatchSyncResponse(total=0, success=0, skipped=0, errors=[]) @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_error_handler_ows_failure_preserves_original_exception( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_batch_row: ContractSyncRow, ) -> None: """Test that if the error-state update fails, the original SAP exception is still raised.""" mock_account_proc.return_value.process.side_effect = Exception('SAP down') mock_update_state.side_effect = Exception('OWS also down') processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row]) assert len(result.errors) == 1 assert result.errors[0].error == 'SAP down' @patch('sync_contract_sap.processor.create_abacus_states') @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_create_states_raises_records_error( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_create_states: Any, mock_batch_row_no_state: ContractSyncRow, ) -> None: """Test that create_abacus_states raising is caught and recorded as a per-contract error.""" mock_create_states.side_effect = Exception('OWS state creation failed') processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row_no_state]) assert result.total == 1 assert result.success == 0 assert len(result.errors) == 1 assert 'OWS state creation failed' in result.errors[0].error mock_update_state.assert_not_called() @patch('sync_contract_sap.processor.create_abacus_states') @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_create_states_returns_no_sap_sync_state( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_create_states: Any, mock_batch_row_no_state: ContractSyncRow, ) -> None: """Test that ValueError is raised and recorded when create_abacus_states returns no sap_sync entry.""" mock_create_states.return_value = [ {'action_name': 'sync_contract', 'abacus_state_id': 100}, ] processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row_no_state]) assert result.success == 0 assert len(result.errors) == 1 assert 'No sap_sync state' in result.errors[0].error mock_update_state.assert_not_called() @patch('sync_contract_sap.processor.time.monotonic') @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_time_budget_skips_remaining( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_monotonic: Any, mock_batch_row: ContractSyncRow, ) -> None: """Test the batch stops early and counts skipped rows past the deadline.""" row_2 = mock_batch_row.model_copy(update={'contract_id': 2, 'abacus_state_id': 99}) mock_monotonic.side_effect = [100.0, 200.0] processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row, row_2], deadline=150.0) assert result.total == 2 assert result.success == 1 assert result.skipped == 1 assert result.errors == [] @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_dedupes_shared_account( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_batch_row: ContractSyncRow, ) -> None: """Test an account shared by several contracts is only synced once per batch.""" row_2 = mock_batch_row.model_copy(update={'contract_id': 2, 'abacus_state_id': 99}) processor = SyncContractSAPProcessor() result = processor.process_batch([mock_batch_row, row_2]) assert result.success == 2 mock_account_proc.return_value.process.assert_called_once_with(11) assert mock_contract_proc.return_value.process.call_count == 2 @patch('sync_contract_sap.processor.update_abacus_state_by_id') @patch('sync_contract_sap.processor.SAPAccountProcessor') @patch('sync_contract_sap.processor.SAPContractProcessor') def test_process_batch_no_account_skips_account_sync( mock_contract_proc: Any, mock_account_proc: Any, mock_update_state: Any, mock_batch_row: ContractSyncRow, ) -> None: """Test a contract without an account still syncs, skipping the account push.""" row = mock_batch_row.model_copy(update={'account_id': None}) processor = SyncContractSAPProcessor() result = processor.process_batch([row]) assert result.success == 1 mock_account_proc.return_value.process.assert_not_called() mock_contract_proc.return_value.process.assert_called_once_with(1)