"""Unit tests for RoyaltyAccountingClient repository.""" import json from pathlib import Path from unittest.mock import Mock, patch import pytest from src.enums import BatchErrorCode, BatchStatus from src.repositories.royalty_accounting import RoyaltyAccountingClient class TestRoyaltyAccountingClient: """Tests for RoyaltyAccountingClient repository.""" @pytest.fixture def mock_conn(self): """Mock MySQL connection.""" conn = Mock() cursor = Mock() cursor.__enter__ = Mock(return_value=cursor) cursor.__exit__ = Mock(return_value=False) conn.cursor.return_value = cursor return conn @pytest.fixture def client(self, mock_conn): """Create client instance.""" return RoyaltyAccountingClient(conn=mock_conn) def test_initialization(self, client, mock_conn): """Test client initialization.""" assert client.conn == mock_conn def test_delete_batch_from_staging_success(self, client, mock_conn): """Test successful deletion of batch data.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.rowcount = 150 result = client.delete_batch_from_staging(batch_id=123) assert result == 150 cursor.execute.assert_called_once() query, params = cursor.execute.call_args[0] assert 'DELETE FROM staging_adjustment_detail' in query assert 'worksheet_flowthrough_batch_id' in query assert params == [123] def test_delete_batch_from_staging_no_rows(self, client, mock_conn): """Test deletion when batch has no staging data.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.rowcount = 0 result = client.delete_batch_from_staging(batch_id=456) assert result == 0 cursor.execute.assert_called_once() def test_delete_batch_from_staging_large_batch(self, client, mock_conn): """Test deletion of large batch.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.rowcount = 50000 result = client.delete_batch_from_staging(batch_id=789) assert result == 50000 def test_delete_batch_from_staging_uses_cursor_context(self, client, mock_conn): """Test deletion uses cursor context manager.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.rowcount = 10 client.delete_batch_from_staging(batch_id=123) mock_conn.cursor.assert_called_once() mock_conn.cursor.return_value.__enter__.assert_called_once() mock_conn.cursor.return_value.__exit__.assert_called_once() @patch('src.repositories.royalty_accounting.load_from_file') def test_stage_batch_from_file_success( self, mock_load_from_file, client, mock_conn ): """Test successful staging from file.""" mock_load_from_file.return_value = 100 file_path = '/tmp/test.csv' client.stage_batch_from_file(file_path) mock_load_from_file.assert_called_once() cursor = mock_conn.cursor.return_value.__enter__.return_value call_args = mock_load_from_file.call_args assert call_args[0][0] == cursor assert str(call_args[0][1]) == file_path @patch('src.repositories.royalty_accounting.load_from_file') def test_stage_batch_from_file_with_pathlib( self, mock_load_from_file, client, mock_conn ): """Test staging from file with Path object.""" mock_load_from_file.return_value = 50 file_path = Path('/tmp/test.csv') client.stage_batch_from_file(file_path) mock_load_from_file.assert_called_once() @patch('src.repositories.royalty_accounting.load_from_file') @patch('src.repositories.royalty_accounting.logger') def test_stage_batch_from_file_logs_progress( self, mock_logger, mock_load_from_file, client, mock_conn ): """Test staging logs progress.""" mock_load_from_file.return_value = 75 file_path = '/tmp/test.csv' client.stage_batch_from_file(file_path) assert mock_logger.info.call_count == 2 # Check for staging message staging_call = mock_logger.info.call_args_list[0][0][0] assert file_path in staging_call # Check for loaded message loaded_call = mock_logger.info.call_args_list[1][0][0] assert '75' in loaded_call @patch('src.repositories.royalty_accounting.load_from_file') def test_stage_batch_from_file_uses_correct_table( self, mock_load_from_file, client, mock_conn ): """Test staging uses correct table name.""" from src.constants import AdjustmentStagingSchema mock_load_from_file.return_value = 10 file_path = '/tmp/test.csv' client.stage_batch_from_file(file_path) call_args = mock_load_from_file.call_args assert call_args[0][2] == AdjustmentStagingSchema.TABLE @patch('src.repositories.royalty_accounting.load_from_s3') def test_stage_batch_from_s3_success(self, mock_load_from_s3, client, mock_conn): """Test successful staging from S3.""" mock_load_from_s3.return_value = 200 client.stage_batch_from_s3('test-bucket', 'test/key.csv') mock_load_from_s3.assert_called_once() cursor = mock_conn.cursor.return_value.__enter__.return_value call_args = mock_load_from_s3.call_args assert call_args[0][0] == cursor assert call_args[0][1] == 'test-bucket' assert call_args[0][2] == 'test/key.csv' @patch('src.repositories.royalty_accounting.load_from_s3') @patch('src.repositories.royalty_accounting.logger') def test_stage_batch_from_s3_logs_progress( self, mock_logger, mock_load_from_s3, client, mock_conn ): """Test S3 staging logs progress.""" mock_load_from_s3.return_value = 150 client.stage_batch_from_s3('my-bucket', 'path/to/file.csv') assert mock_logger.info.call_count == 2 # Check for staging message staging_call = mock_logger.info.call_args_list[0][0][0] assert 's3://my-bucket/path/to/file.csv' in staging_call # Check for loaded message loaded_call = mock_logger.info.call_args_list[1][0][0] assert '150' in loaded_call def test_update_batch_status_basic(self, client, mock_conn): """Test basic status update.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 result = client.update_batch_status(batch_id=123, status=BatchStatus.VALIDATING) assert result == 1 cursor.execute.assert_called_once() query, params = cursor.execute.call_args[0] assert 'UPDATE worksheet_flowthrough_batch' in query assert 'SET batch_status = %s' in query assert 'WHERE worksheet_flowthrough_batch_id = %s' in query assert params == [BatchStatus.VALIDATING, 123] def test_update_batch_status_with_expected_status(self, client, mock_conn): """Test status update with optimistic locking.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 result = client.update_batch_status( batch_id=456, status=BatchStatus.ERROR, expected_status=BatchStatus.VALIDATING, ) assert result == 1 query, params = cursor.execute.call_args[0] assert 'AND batch_status = %s' in query assert params == [BatchStatus.ERROR, 456, BatchStatus.VALIDATING] def test_update_batch_status_with_errors(self, client, mock_conn): """Test status update with error codes.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 errors = [BatchErrorCode.EMPTY_FILE, BatchErrorCode.MISSING_HEADERS] result = client.update_batch_status( batch_id=789, status=BatchStatus.ERROR, errors=errors ) assert result == 1 query, params = cursor.execute.call_args[0] assert 'errors = %s' in query # Check that errors were JSON serialized error_json = params[1] parsed_errors = json.loads(error_json) assert BatchErrorCode.EMPTY_FILE.value in parsed_errors assert BatchErrorCode.MISSING_HEADERS.value in parsed_errors def test_update_batch_status_with_all_parameters(self, client, mock_conn): """Test status update with all parameters.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 errors = [BatchErrorCode.FILE_PARSING_ERROR] result = client.update_batch_status( batch_id=999, status=BatchStatus.ERROR, expected_status=BatchStatus.VALIDATING, errors=errors, ) assert result == 1 query, params = cursor.execute.call_args[0] assert 'UPDATE worksheet_flowthrough_batch' in query assert 'SET batch_status = %s' in query assert 'errors = %s' in query assert 'WHERE worksheet_flowthrough_batch_id = %s' in query assert 'AND batch_status = %s' in query assert len(params) == 4 def test_update_batch_status_no_rows_affected(self, client, mock_conn): """Test status update when no rows affected (optimistic lock fail).""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 0 result = client.update_batch_status( batch_id=123, status=BatchStatus.VALIDATING, expected_status=BatchStatus.PENDING, ) assert result == 0 def test_update_batch_status_multiple_errors(self, client, mock_conn): """Test status update with multiple error codes.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 errors = [ BatchErrorCode.EMPTY_FILE, BatchErrorCode.MISSING_HEADERS, BatchErrorCode.ROW_COUNT_EXCEEDED, BatchErrorCode.FILE_PARSING_ERROR, ] result = client.update_batch_status( batch_id=555, status=BatchStatus.ERROR, errors=errors ) assert result == 1 _, params = cursor.execute.call_args[0] error_json = params[1] parsed_errors = json.loads(error_json) assert len(parsed_errors) == 4 def test_update_batch_status_from_validating_to_error(self, client, mock_conn): """Test status update from validating to error.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 result = client.update_batch_status( batch_id=888, status=BatchStatus.ERROR, expected_status=BatchStatus.VALIDATING, ) assert result == 1 _, params = cursor.execute.call_args[0] assert BatchStatus.ERROR in params assert BatchStatus.VALIDATING in params def test_update_batch_status_uses_cursor_context(self, client, mock_conn): """Test status update uses cursor context manager.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 client.update_batch_status(batch_id=123, status=BatchStatus.VALIDATING) mock_conn.cursor.assert_called_once() mock_conn.cursor.return_value.__enter__.assert_called_once() mock_conn.cursor.return_value.__exit__.assert_called_once() @patch('src.repositories.royalty_accounting.handle_mysql_errors') def test_delete_batch_uses_error_handler(self, mock_handler, client, mock_conn): """Test delete_batch_from_staging uses MySQL error handler.""" # The decorator should be applied assert hasattr(client.delete_batch_from_staging, '__wrapped__') @patch('src.repositories.royalty_accounting.handle_mysql_errors') def test_stage_from_file_uses_error_handler(self, mock_handler, client, mock_conn): """Test stage_batch_from_file uses MySQL error handler.""" assert hasattr(client.stage_batch_from_file, '__wrapped__') @patch('src.repositories.royalty_accounting.handle_mysql_errors') def test_stage_from_s3_uses_error_handler(self, mock_handler, client, mock_conn): """Test stage_batch_from_s3 uses MySQL error handler.""" assert hasattr(client.stage_batch_from_s3, '__wrapped__') @patch('src.repositories.royalty_accounting.handle_mysql_errors') def test_update_status_uses_error_handler(self, mock_handler, client, mock_conn): """Test update_batch_status uses MySQL error handler.""" assert hasattr(client.update_batch_status, '__wrapped__') def test_update_batch_status_empty_errors_list(self, client, mock_conn): """Test status update with empty errors list.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 result = client.update_batch_status( batch_id=123, status=BatchStatus.ERROR, errors=[] ) assert result == 1 query, params = cursor.execute.call_args[0] # Empty list should not add errors clause assert 'errors = %s' not in query def test_update_batch_status_none_expected_status(self, client, mock_conn): """Test status update with None expected_status.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 result = client.update_batch_status( batch_id=123, status=BatchStatus.VALIDATING, expected_status=None ) assert result == 1 query, params = cursor.execute.call_args[0] # None expected_status should not add AND clause assert 'AND batch_status = %s' not in query assert len(params) == 2 # status and batch_id only def test_batch_status_transitions(self, client, mock_conn): """Test various batch status transitions.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.execute.return_value = 1 transitions = [ (BatchStatus.PENDING, BatchStatus.VALIDATING), (BatchStatus.VALIDATING, BatchStatus.ERROR), (BatchStatus.ERROR, BatchStatus.PENDING), (None, BatchStatus.VALIDATING), # No expected status ] for expected, new_status in transitions: result = client.update_batch_status( batch_id=123, status=new_status, expected_status=expected ) assert result == 1 def test_delete_batch_with_different_batch_ids(self, client, mock_conn): """Test deletion with various batch IDs.""" cursor = mock_conn.cursor.return_value.__enter__.return_value cursor.rowcount = 10 batch_ids = [1, 100, 999, 123456789] for batch_id in batch_ids: result = client.delete_batch_from_staging(batch_id) assert result == 10 _, params = cursor.execute.call_args[0] assert params == [batch_id]