"""Unit tests for MySQL DB connector.""" 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, handle_mysql_errors, mysql_connection from src.errors import TransientError 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('src.connectors.mysql.connection.pymysql.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('src.connectors.mysql.connection.pymysql.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('src.connectors.mysql.connection.pymysql.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('src.connectors.mysql.connection.pymysql.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('src.connectors.mysql.connection.pymysql.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('src.connectors.mysql.connection.pymysql.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 # Should not raise AttributeError trying to close None @patch('src.connectors.mysql.connection.pymysql.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('src.connectors.mysql.connection.pymysql.connect') def test_mysql_connection_handles_non_transient_connect_failure(self, mock_connect): """Test mysql_connection re-raises non-transient OperationalErrors.""" # Error code 1045 is "Access denied" - not transient 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 TestMySQLConnectionFactory: """Tests for MySQLConnectionFactory.""" @patch('src.connectors.mysql.connection.mysql_connection') def test_connection_factory_yields_connection(self, mock_mysql_connection): """Test that connection factory yields a connection.""" from src.connectors.mysql import MySQLConnection, MySQLConnectionFactory mock_conn = Mock() mock_mysql_connection.return_value.__enter__.return_value = mock_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_conn mock_mysql_connection.assert_called_once_with(config) @patch('src.connectors.mysql.connection.mysql_connection') def test_connection_factory_handles_errors(self, mock_mysql_connection): """Test that connection factory propagates errors.""" from src.connectors.mysql import MySQLConnectionFactory 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