"""Unit tests for S3Connection.""" from unittest.mock import Mock, patch import pytest from botocore.exceptions import ClientError from src.connectors.s3 import S3Connection, get_s3_client, get_s3_connection from src.errors import S3FileNotFoundError, TransientError class TestS3Connection: """Tests for S3Connection methods.""" @pytest.fixture def mock_s3_client(self): """Mock S3 client.""" return Mock() @pytest.fixture def s3_connector(self, mock_s3_client): """S3Connection instance.""" return S3Connection(mock_s3_client) def test_download_file_success(self, s3_connector, mock_s3_client): """Test successful file download.""" bucket = 'test-bucket' key = 'path/to/file.xlsx' local_path = '/tmp/file.xlsx' s3_connector.download_file(bucket, key, local_path) mock_s3_client.download_file.assert_called_once_with( Bucket=bucket, Key=key, Filename=local_path ) def test_download_file_handles_transient_errors(self, s3_connector, mock_s3_client): """Test download_file converts S3 transient errors to TransientError.""" client_error = ClientError( { 'Error': {'Code': 'SlowDown', 'Message': 'Reduce your request rate'}, 'ResponseMetadata': {'HTTPStatusCode': 503}, }, 'DownloadFile', ) mock_s3_client.download_file.side_effect = client_error bucket = 'test-bucket' key = 'path/to/file.xlsx' local_path = '/tmp/file.xlsx' with pytest.raises(TransientError) as exc_info: s3_connector.download_file(bucket, key, local_path) expected_message = ( f'S3 service error ({client_error.response["Error"]["Code"]}): ' f'{client_error.response["Error"]["Message"]}' ) assert expected_message in str(exc_info.value) def test_download_file_handles_not_found_error(self, s3_connector, mock_s3_client): """Test download_file raises S3ObjectNotFoundError for 404.""" mock_s3_client.download_file.side_effect = ClientError( { 'Error': {'Code': 'NoSuchKey', 'Message': 'File not found'}, 'ResponseMetadata': {'HTTPStatusCode': 404}, }, 'DownloadFile', ) bucket = 'test-bucket' key = 'path/to/file.xlsx' local_path = '/tmp/file.xlsx' with pytest.raises(S3FileNotFoundError) as exc_info: s3_connector.download_file(bucket, key, local_path) assert 'NoSuchKey' in str(exc_info.value) def test_download_file_propagates_other_errors(self, s3_connector, mock_s3_client): """Test download_file propagates other S3 errors (e.g. 403).""" mock_s3_client.download_file.side_effect = ClientError( { 'Error': {'Code': 'AccessDenied', 'Message': 'Access denied'}, 'ResponseMetadata': {'HTTPStatusCode': 403}, }, 'DownloadFile', ) bucket = 'test-bucket' key = 'path/to/file.xlsx' local_path = '/tmp/file.xlsx' with pytest.raises(ClientError) as exc_info: s3_connector.download_file(bucket, key, local_path) assert 'AccessDenied' in str(exc_info.value) def test_upload_file_success(self, s3_connector, mock_s3_client): """Test successful file upload.""" bucket = 'test-bucket' key = 'staging/file.csv' local_path = '/tmp/prepared.csv' s3_connector.upload_file(local_path, bucket, key) mock_s3_client.upload_file.assert_called_once_with( Filename=local_path, Bucket=bucket, Key=key ) def test_upload_file_handles_transient_errors(self, s3_connector, mock_s3_client): """Test upload_file converts S3 transient errors to TransientError.""" client_error = ClientError( { 'Error': {'Code': 'TooManyRequests', 'Message': 'Slow down'}, 'ResponseMetadata': {'HTTPStatusCode': 429}, }, 'UploadFile', ) mock_s3_client.upload_file.side_effect = client_error bucket = 'test-bucket' key = 'staging/file.csv' local_path = '/tmp/prepared.csv' with pytest.raises(TransientError) as exc_info: s3_connector.upload_file(local_path, bucket, key) expected_message = ( f'S3 service error ({client_error.response["Error"]["Code"]}): ' f'{client_error.response["Error"]["Message"]}' ) assert expected_message in str(exc_info.value) def test_upload_file_propagates_non_transient_errors( self, s3_connector, mock_s3_client ): """Test upload_file propagates non-transient S3 errors.""" mock_s3_client.upload_file.side_effect = ClientError( { 'Error': {'Code': 'AccessDenied', 'Message': 'Access denied'}, 'ResponseMetadata': {'HTTPStatusCode': 403}, }, 'UploadFile', ) bucket = 'test-bucket' key = 'staging/file.csv' local_path = '/tmp/prepared.csv' with pytest.raises(ClientError) as exc_info: s3_connector.upload_file(local_path, bucket, key) assert 'AccessDenied' in str(exc_info.value) def test_get_file_metadata_success(self, s3_connector, mock_s3_client): """Test successful get_file_metadata.""" bucket = 'test-bucket' key = 'path/to/file.xlsx' mock_s3_client.head_object.return_value = { 'ContentLength': 12345, 'ETag': '"abc123"', } result = s3_connector.get_file_metadata(bucket, key) assert result is not None assert result.bucket == bucket assert result.key == key assert result.size == 12345 assert result.etag == 'abc123' mock_s3_client.head_object.assert_called_once_with(Bucket=bucket, Key=key) def test_get_file_metadata_not_found(self, s3_connector, mock_s3_client): """Test get_file_metadata returns None for 404.""" mock_s3_client.head_object.side_effect = ClientError( { 'Error': {'Code': 'NoSuchKey', 'Message': 'File not found'}, 'ResponseMetadata': {'HTTPStatusCode': 404}, }, 'HeadObject', ) bucket = 'test-bucket' key = 'path/to/file.xlsx' result = s3_connector.get_file_metadata(bucket, key) assert result is None def test_get_file_metadata_transient_error(self, s3_connector, mock_s3_client): """Test get_file_metadata raises TransientError for throttling.""" client_error = ClientError( { 'Error': {'Code': 'SlowDown', 'Message': 'Reduce your request rate'}, 'ResponseMetadata': {'HTTPStatusCode': 503}, }, 'HeadObject', ) mock_s3_client.head_object.side_effect = client_error bucket = 'test-bucket' key = 'path/to/file.xlsx' with pytest.raises(TransientError) as exc_info: s3_connector.get_file_metadata(bucket, key) expected_message = ( f'S3 service error ({client_error.response["Error"]["Code"]}): ' f'{client_error.response["Error"]["Message"]}' ) assert expected_message in str(exc_info.value) def test_get_file_metadata_other_error(self, s3_connector, mock_s3_client): """Test get_file_metadata propagates other ClientErrors.""" mock_s3_client.head_object.side_effect = ClientError( { 'Error': {'Code': 'AccessDenied', 'Message': 'Access denied'}, 'ResponseMetadata': {'HTTPStatusCode': 403}, }, 'HeadObject', ) bucket = 'test-bucket' key = 'path/to/file.xlsx' with pytest.raises(ClientError) as exc_info: s3_connector.get_file_metadata(bucket, key) assert 'AccessDenied' in str(exc_info.value) class TestS3ClientFactory: """Tests for S3 client factory functions.""" @patch('src.connectors.s3.boto3.client') def test_get_s3_client_with_default_config(self, mock_boto_client): """Test get_s3_client creates client with default config.""" mock_client = Mock() mock_boto_client.return_value = mock_client result = get_s3_client() assert result == mock_client mock_boto_client.assert_called_once() call_args = mock_boto_client.call_args assert call_args[0][0] == 's3' assert call_args[1]['config'].signature_version == 's3v4' @patch('src.connectors.s3.boto3.client') def test_get_s3_client_with_custom_config(self, mock_boto_client): """Test get_s3_client uses provided config.""" from botocore.client import Config custom_config = Config(signature_version='s3v2') mock_client = Mock() mock_boto_client.return_value = mock_client result = get_s3_client(custom_config) assert result == mock_client mock_boto_client.assert_called_once_with('s3', config=custom_config) @patch('src.connectors.s3.get_s3_client') def test_get_s3_connection_with_default_client(self, mock_get_client): """Test get_s3_connection creates connector with default client.""" mock_client = Mock() mock_get_client.return_value = mock_client result = get_s3_connection() assert isinstance(result, S3Connection) assert result.s3_client == mock_client mock_get_client.assert_called_once() def test_get_s3_connection_with_provided_client(self): """Test get_s3_connection uses provided client.""" mock_client = Mock() result = get_s3_connection(mock_client) assert isinstance(result, S3Connection) assert result.s3_client == mock_client