"""Unit tests for MySQL connector adapter.""" from unittest.mock import MagicMock, Mock, patch import pytest from pymysql.cursors import Cursor from pymysql.err import InternalError, OperationalError from src.connectors.mysql import ( MySQLConfig, MySQLConnection, MySQLConnectionFactory, handle_mysql_errors, mysql_connection, ) from src.errors import TransientError # Patch path for pymysql in the connection module PYMYSQL_PATCH = 'src.connectors.mysql.connection.pymysql' PD_PATCH = 'src.connectors.mysql.connection.pd' MYSQL_CONN_PATCH = 'src.connectors.mysql.connection.mysql_connection' class TestHandleMySQLErrors: """Tests for handle_mysql_errors decorator.""" def test_decorator_returns_function_result(self): """Test decorator returns function result on success.""" @handle_mysql_errors def test_func(a, b): return a + b result = test_func(2, 3) assert result == 5 @pytest.mark.parametrize( 'error_code,error_msg', [ (2003, "Can't connect to MySQL server"), (2006, 'MySQL server has gone away'), (2013, 'Lost connection to MySQL server during query'), (1205, 'Lock wait timeout exceeded'), (1213, 'Deadlock found when trying to get lock'), ], ) def test_decorator_converts_transient_errors(self, error_code, error_msg): """Test decorator converts transient OperationalErrors to TransientError.""" @handle_mysql_errors def test_func(): raise OperationalError(error_code, error_msg) with pytest.raises(TransientError) as exc_info: test_func() assert 'Database operation failed' in str(exc_info.value) def test_decorator_propagates_non_retriable_operational_errors(self): """Test decorator propagates non-retriable OperationalErrors.""" @handle_mysql_errors def test_func(): raise OperationalError(1234, 'Some other operational error') with pytest.raises(OperationalError) as exc_info: test_func() assert exc_info.value.args[0] == 1234 def test_decorator_propagates_non_operational_errors(self): """Test decorator propagates non-OperationalError exceptions.""" @handle_mysql_errors def test_func(): raise InternalError(1000, 'Internal error') with pytest.raises(InternalError): test_func() def test_decorator_propagates_generic_exceptions(self): """Test decorator propagates generic exceptions.""" @handle_mysql_errors def test_func(): raise ValueError('Invalid value') with pytest.raises(ValueError): test_func() def test_decorator_handles_kwargs(self): """Test decorator works with keyword arguments.""" @handle_mysql_errors def test_func(a, b=10, c=20): return a + b + c result = test_func(5, b=15, c=25) assert result == 45 class TestMySQLConnection: """Tests for mysql_connection context manager.""" @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_yields_connection(self, mock_connect): """Test mysql_connection yields a connection object.""" mock_conn = MagicMock() mock_connect.return_value = mock_conn config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with mysql_connection(config) as conn: assert conn is mock_conn @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_closes_connection(self, mock_connect): """Test mysql_connection closes connection after use.""" mock_conn = MagicMock() mock_connect.return_value = mock_conn config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with mysql_connection(config): pass mock_conn.close.assert_called_once() @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_closes_on_exception(self, mock_connect): """Test mysql_connection closes connection even if exception occurs.""" mock_conn = MagicMock() mock_connect.return_value = mock_conn config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with pytest.raises(ValueError): with mysql_connection(config): raise ValueError('Test error') mock_conn.close.assert_called_once() @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_uses_default_parameters(self, mock_connect): """Test mysql_connection uses correct default parameters.""" mock_conn = MagicMock() mock_connect.return_value = mock_conn config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with mysql_connection(config): pass mock_connect.assert_called_once_with( host='localhost', user='testuser', password='testpass', database='testdb', connect_timeout=10, port=3306, cursorclass=Cursor, autocommit=False, local_infile=False, ) @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_uses_custom_parameters(self, mock_connect): """Test mysql_connection uses custom parameters when provided.""" mock_conn = MagicMock() mock_connect.return_value = mock_conn config = MySQLConfig( host='remote-host', user='admin', password='secret', database='production', connect_timeout=10, port=3307, autocommit=True, ) with mysql_connection(config): pass mock_connect.assert_called_once_with( host='remote-host', user='admin', password='secret', database='production', connect_timeout=10, port=3307, cursorclass=Cursor, autocommit=True, local_infile=False, ) @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_does_not_close_none_connection(self, mock_connect): """Test mysql_connection handles case where connection is None.""" mock_connect.side_effect = Exception('Connection failed') config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with pytest.raises(Exception): with mysql_connection(config): pass @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_handles_connect_failure(self, mock_connect): """Test mysql_connection handles transient connection failures.""" mock_connect.side_effect = OperationalError( 2003, "Can't connect to MySQL server" ) config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with pytest.raises(TransientError): with mysql_connection(config): pass @patch(f'{PYMYSQL_PATCH}.connect') def test_mysql_connection_handles_non_transient_connect_failure(self, mock_connect): """Test mysql_connection re-raises non-transient OperationalErrors.""" mock_connect.side_effect = OperationalError(1045, 'Access denied for user') config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with pytest.raises(OperationalError) as exc_info: with mysql_connection(config): pass assert exc_info.value.args[0] == 1045 class TestMySQLConfig: """Tests for MySQLConfig Pydantic model.""" def test_config_with_required_fields(self): """Test config creation with only required fields.""" config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) assert config.host == 'localhost' assert config.user == 'testuser' assert config.password == 'testpass' assert config.database == 'testdb' assert config.port == 3306 assert config.connect_timeout == 10 assert config.autocommit is False assert config.local_infile is False def test_config_with_all_fields(self): """Test config creation with all fields.""" config = MySQLConfig( host='remote-host', user='admin', password='secret', database='production', port=3307, connect_timeout=10, autocommit=True, local_infile=True, ) assert config.host == 'remote-host' assert config.user == 'admin' assert config.password == 'secret' assert config.database == 'production' assert config.port == 3307 assert config.connect_timeout == 10 assert config.autocommit is True assert config.local_infile is True def test_config_is_frozen(self): """Test that config is immutable (frozen).""" config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) with pytest.raises(Exception): config.host = 'newhost' class TestMySQLConnectionAdapter: """Tests for MySQLConnection adapter.""" def test_adapter_commit(self): """Test adapter commit method.""" mock_conn = MagicMock() adapter = MySQLConnection(mock_conn) adapter.commit() mock_conn.commit.assert_called_once() def test_adapter_rollback(self): """Test adapter rollback method.""" mock_conn = MagicMock() adapter = MySQLConnection(mock_conn) adapter.rollback() mock_conn.rollback.assert_called_once() def test_adapter_cursor(self): """Test adapter cursor method.""" mock_conn = MagicMock() mock_cursor = MagicMock() mock_conn.cursor.return_value = mock_cursor adapter = MySQLConnection(mock_conn) cursor = adapter.cursor() # The cursor method returns a closing context manager # We verify that entering the context manager yields our mock cursor with cursor as c: assert c is mock_cursor mock_conn.cursor.assert_called_once() class TestMySQLConnectionFactory: """Tests for MySQLConnectionFactory.""" @patch(MYSQL_CONN_PATCH) def test_connection_factory_yields_adapter(self, mock_mysql_connection): """Test that connection factory yields a MySQLConnection adapter.""" mock_raw_conn = Mock() mock_mysql_connection.return_value.__enter__.return_value = mock_raw_conn config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) factory = MySQLConnectionFactory(config) with factory.connection() as conn: assert isinstance(conn, MySQLConnection) assert conn._conn is mock_raw_conn mock_mysql_connection.assert_called_once_with(config) @patch(MYSQL_CONN_PATCH) def test_connection_factory_excludes_db_type(self, mock_mysql_connection): """Test that connection factory excludes db_type from config.""" mock_raw_conn = Mock() mock_mysql_connection.return_value.__enter__.return_value = mock_raw_conn config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) factory = MySQLConnectionFactory(config) with factory.connection(): pass # Verify db_type is not passed to mysql_connection call_kwargs = mock_mysql_connection.call_args[1] assert 'db_type' not in call_kwargs @patch(MYSQL_CONN_PATCH) def test_connection_factory_handles_errors(self, mock_mysql_connection): """Test that connection factory propagates errors.""" mock_mysql_connection.return_value.__enter__.side_effect = TransientError( 'Connection failed' ) config = MySQLConfig( host='localhost', user='testuser', password='testpass', database='testdb', ) factory = MySQLConnectionFactory(config) with pytest.raises(TransientError): with factory.connection(): pass