"""Tests for S3 connector.""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from botocore.exceptions import ClientError from src.connectors.s3 import S3Connector, get_s3_client, get_s3_connector from src.errors import S3FileNotFoundError, TransientError def _client_error(code: str, http_status: int) -> ClientError: return ClientError( error_response={ 'Error': {'Code': code, 'Message': 'test error'}, 'ResponseMetadata': {'HTTPStatusCode': http_status}, }, operation_name='GetObject', ) class TestS3ConnectorDownload: """Test S3Connector.download_object.""" def test_download_success(self) -> None: mock_client = MagicMock() mock_client.get_object.return_value = { 'Body': MagicMock(read=MagicMock(return_value=b'xlsx-bytes')), } connector = S3Connector(mock_client) result = connector.download_object('bucket', 'key') assert result == b'xlsx-bytes' mock_client.get_object.assert_called_once_with(Bucket='bucket', Key='key') def test_download_404_raises_s3_not_found(self) -> None: mock_client = MagicMock() mock_client.get_object.side_effect = _client_error('NoSuchKey', 404) connector = S3Connector(mock_client) with pytest.raises(S3FileNotFoundError): connector.download_object('bucket', 'missing-key') def test_download_403_raises_s3_not_found(self) -> None: mock_client = MagicMock() mock_client.get_object.side_effect = _client_error('AccessDenied', 403) connector = S3Connector(mock_client) with pytest.raises(S3FileNotFoundError): connector.download_object('bucket', 'forbidden-key') def test_download_503_raises_transient(self) -> None: mock_client = MagicMock() mock_client.get_object.side_effect = _client_error('SlowDown', 503) connector = S3Connector(mock_client) with pytest.raises(TransientError): connector.download_object('bucket', 'key') class TestS3ConnectorUpload: """Test S3Connector.upload_buffer.""" def test_upload_success(self) -> None: mock_client = MagicMock() connector = S3Connector(mock_client) connector.upload_buffer('bucket', 'key', b'data') mock_client.put_object.assert_called_once_with( Bucket='bucket', Key='key', Body=b'data', ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) def test_upload_500_raises_transient(self) -> None: mock_client = MagicMock() mock_client.put_object.side_effect = _client_error('InternalError', 500) connector = S3Connector(mock_client) with pytest.raises(TransientError): connector.upload_buffer('bucket', 'key', b'data') class TestS3Factory: """Test factory functions.""" @patch('src.connectors.s3.boto3') def test_get_s3_client(self, mock_boto3: MagicMock) -> None: client = get_s3_client() mock_boto3.client.assert_called_once() assert client is mock_boto3.client.return_value @patch('src.connectors.s3.get_s3_client') def test_get_s3_connector(self, mock_get_client: MagicMock) -> None: connector = get_s3_connector() assert isinstance(connector, S3Connector) class TestS3UnclassifiedError: """Test that unclassified errors are re-raised.""" def test_download_400_reraises_client_error(self) -> None: """Mock 400 status -> original ClientError propagates.""" mock_client = MagicMock() mock_client.get_object.side_effect = _client_error('BadRequest', 400) connector = S3Connector(mock_client) with pytest.raises(ClientError): connector.download_object('bucket', 'bad-key')