"""Unit tests for DuckDBValidator.""" from unittest.mock import Mock, patch import pytest from src.services.validator import ( DuckDBValidator, ValidationResult, ) class TestValidationResult: """Tests for ValidationResult model.""" def test_validation_result_creation(self): """Test ValidationResult model initialization.""" result = ValidationResult( invalid_rows=5, valid_rows=95, total_errors=10, total_amount=1500.50, total_amount_raw=1500.75, ) assert result.invalid_rows == 5 assert result.valid_rows == 95 assert result.total_errors == 10 assert result.total_amount == 1500.50 assert result.total_amount_raw == 1500.75 def test_validation_result_ignores_extra_fields(self): """Test ValidationResult ignores extra fields.""" result = ValidationResult( invalid_rows=5, valid_rows=95, total_errors=10, total_amount=1500.50, total_amount_raw=1500.75, extra_field='ignored', ) assert not hasattr(result, 'extra_field') def test_validation_result_all_valid(self): """Test ValidationResult with all valid rows.""" result = ValidationResult( invalid_rows=0, valid_rows=100, total_errors=0, total_amount=10000.0, total_amount_raw=10000.0, ) assert result.invalid_rows == 0 assert result.valid_rows == 100 assert result.total_errors == 0 def test_validation_result_all_invalid(self): """Test ValidationResult with all invalid rows.""" result = ValidationResult( invalid_rows=100, valid_rows=0, total_errors=150, total_amount=0.0, total_amount_raw=10000.0, ) assert result.invalid_rows == 100 assert result.valid_rows == 0 assert result.total_errors == 150 def test_validation_result_with_zero_amounts(self): """Test ValidationResult with zero amounts.""" result = ValidationResult( invalid_rows=0, valid_rows=10, total_errors=0, total_amount=0.0, total_amount_raw=0.0, ) assert result.total_amount == 0.0 assert result.total_amount_raw == 0.0 def test_validation_result_with_negative_amounts(self): """Test ValidationResult with negative amounts (adjustments can be negative).""" result = ValidationResult( invalid_rows=0, valid_rows=5, total_errors=0, total_amount=-500.0, total_amount_raw=-500.0, ) assert result.total_amount == -500.0 assert result.total_amount_raw == -500.0 def test_validation_result_rounding_differences(self): """Test ValidationResult with rounding differences between raw and rounded.""" result = ValidationResult( invalid_rows=0, valid_rows=10, total_errors=0, total_amount=1000.00, total_amount_raw=1000.49, ) assert result.total_amount == 1000.00 assert result.total_amount_raw == 1000.49 assert abs(result.total_amount - result.total_amount_raw) < 1.0 class TestDuckDBValidator: """Tests for DuckDBValidator class.""" @pytest.fixture def mock_duck_conn(self): """Mock DuckDB connection.""" # The validator accesses duck_conn._conn, so we need to mock that inner_conn = Mock() cursor = Mock() cursor.__enter__ = Mock(return_value=cursor) cursor.__exit__ = Mock(return_value=False) inner_conn.cursor.return_value = cursor # Create the wrapper mock that has _conn attribute wrapper = Mock() wrapper._conn = inner_conn wrapper.cursor.return_value = cursor return wrapper @pytest.fixture def validator(self, mock_duck_conn): """Create validator instance with mocked dependencies.""" return DuckDBValidator(duck_conn=mock_duck_conn) def test_initialization(self, validator): """Test validator initialization.""" assert validator._duck_conn is not None @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_success( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test successful validation.""" # Setup mocks mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 2, 'valid_rows': 98, 'total_errors': 3, 'total_amount': 5000.0, 'total_amount_raw': 5000.5, } # Execute result = validator.validate(batch_id=123) # Verify assert isinstance(result, ValidationResult) assert result.valid_rows == 98 assert result.invalid_rows == 2 assert result.total_errors == 3 assert result.total_amount == 5000.0 assert result.total_amount_raw == 5000.5 @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_with_batch_id( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation passes correct batch_id to SQL.""" mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 0, 'valid_rows': 100, 'total_errors': 0, 'total_amount': 10000.0, 'total_amount_raw': 10000.0, } validator.validate(batch_id=456) # Verify cursor.execute was called with batch_id in params cursor = mock_duck_conn.cursor.return_value.__enter__.return_value assert cursor.execute.call_count == 2 first_call_params = cursor.execute.call_args_list[0][0][1] assert first_call_params['batch_id'] == 456 @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') @patch('src.services.validator.config') def test_validate_uses_config_values( self, mock_config, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation uses config values for validation rules.""" mock_config.policy.MAX_COMMENT_LEN = 500 mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 0, 'valid_rows': 10, 'total_errors': 0, 'total_amount': 1000.0, 'total_amount_raw': 1000.0, } validator.validate(batch_id=123) # Verify config value was passed to SQL cursor = mock_duck_conn.cursor.return_value.__enter__.return_value first_call_params = cursor.execute.call_args_list[0][0][1] assert first_call_params['comment_max_length'] == 500 @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_all_rows_valid( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation with all rows valid.""" mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 0, 'valid_rows': 100, 'total_errors': 0, 'total_amount': 50000.0, 'total_amount_raw': 50000.0, } result = validator.validate(batch_id=123) assert result.valid_rows == 100 assert result.invalid_rows == 0 assert result.total_errors == 0 @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_all_rows_invalid( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation with all rows invalid.""" mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 100, 'valid_rows': 0, 'total_errors': 250, 'total_amount': 0.0, 'total_amount_raw': 50000.0, } result = validator.validate(batch_id=123) assert result.valid_rows == 0 assert result.invalid_rows == 100 assert result.total_errors > 0 @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_multiple_errors_per_row( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation where rows have multiple errors.""" mock_load_sql.return_value = 'SELECT * FROM validation' # 10 invalid rows with 30 total errors = avg 3 errors per invalid row mock_get_result.return_value = { 'invalid_rows': 10, 'valid_rows': 90, 'total_errors': 30, 'total_amount': 45000.0, 'total_amount_raw': 45000.0, } result = validator.validate(batch_id=123) assert result.invalid_rows == 10 assert result.total_errors == 30 # More errors than invalid rows indicates multiple errors per row assert result.total_errors > result.invalid_rows @patch('src.services.validator.load_sql') def test_validate_calls_correct_sql_queries( self, mock_load_sql, validator, mock_duck_conn, ): """Test validation calls correct SQL queries in order.""" from src.sql import DuckDBQuery mock_load_sql.return_value = 'SELECT 1' with patch('src.services.validator.get_result_dict') as mock_get_result: mock_get_result.return_value = { 'invalid_rows': 0, 'valid_rows': 10, 'total_errors': 0, 'total_amount': 1000.0, 'total_amount_raw': 1000.0, } validator.validate(batch_id=123) # Should call load_sql twice: ValidateAdjustments and GetValidationResults assert mock_load_sql.call_count == 2 mock_load_sql.assert_any_call(DuckDBQuery.ValidateAdjustments) mock_load_sql.assert_any_call(DuckDBQuery.GetValidationResults) @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_with_zero_rows( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation with zero rows in file.""" mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 0, 'valid_rows': 0, 'total_errors': 0, 'total_amount': 0.0, 'total_amount_raw': 0.0, } result = validator.validate(batch_id=123) assert result.valid_rows == 0 assert result.invalid_rows == 0 assert result.total_errors == 0 @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_with_large_amounts( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation with large financial amounts.""" mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 0, 'valid_rows': 1000, 'total_errors': 0, 'total_amount': 999999999.99, 'total_amount_raw': 999999999.99, } result = validator.validate(batch_id=123) assert result.total_amount == 999999999.99 assert result.total_amount_raw == 999999999.99 @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_logs_results( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation logs are called.""" mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 5, 'valid_rows': 95, 'total_errors': 10, 'total_amount': 5000.0, 'total_amount_raw': 5000.0, } with patch('src.services.validator.logger') as mock_logger: validator.validate(batch_id=123) # Verify logging calls assert mock_logger.info.call_count >= 2 mock_logger.info.assert_any_call('Validating adjustments') mock_logger.info.assert_any_call('Adjustments validated') @patch('src.services.validator.load_sql') @patch('src.services.validator.get_result_dict') def test_validate_cursor_context_manager( self, mock_get_result, mock_load_sql, validator, mock_duck_conn, ): """Test validation uses cursor context manager correctly.""" mock_load_sql.return_value = 'SELECT * FROM validation' mock_get_result.return_value = { 'invalid_rows': 0, 'valid_rows': 10, 'total_errors': 0, 'total_amount': 1000.0, 'total_amount_raw': 1000.0, } validator.validate(batch_id=123) # Verify cursor context manager was used mock_duck_conn.cursor.assert_called_once() cursor_cm = mock_duck_conn.cursor.return_value cursor_cm.__enter__.assert_called_once() cursor_cm.__exit__.assert_called_once()