""" Tests for S3StorageBackend. TDD Phase 1: RED - Write tests first, then implement to pass. """ import shutil import tempfile from pathlib import Path from unittest.mock import MagicMock, patch, call import pytest @pytest.fixture def temp_dir() -> Path: """Create a temporary directory for tests.""" temp_dir = Path(tempfile.mkdtemp()) yield temp_dir shutil.rmtree(temp_dir, ignore_errors=True) @pytest.fixture def sample_file(temp_dir: Path) -> Path: """Create a sample file for testing.""" file_path = temp_dir / "sample.txt" file_path.write_text("Hello, World!") return file_path @pytest.fixture def mock_boto3_client(): """Create a mock boto3 S3 client.""" with patch("boto3.client") as mock_client_func: mock_client = MagicMock() mock_client_func.return_value = mock_client yield mock_client class TestS3StorageBackendCreation: """Tests for S3StorageBackend instantiation.""" def test_create_with_bucket_name(self, mock_boto3_client: MagicMock) -> None: """Test creating backend with bucket name.""" from shared.storage.s3 import S3StorageBackend backend = S3StorageBackend(bucket_name="test-bucket") assert backend.bucket_name == "test-bucket" def test_create_with_region(self, mock_boto3_client: MagicMock) -> None: """Test creating backend with region.""" from shared.storage.s3 import S3StorageBackend with patch("boto3.client") as mock_client: S3StorageBackend( bucket_name="test-bucket", region_name="us-west-2", ) mock_client.assert_called_once() call_kwargs = mock_client.call_args[1] assert call_kwargs.get("region_name") == "us-west-2" def test_create_with_credentials(self, mock_boto3_client: MagicMock) -> None: """Test creating backend with explicit credentials.""" from shared.storage.s3 import S3StorageBackend with patch("boto3.client") as mock_client: S3StorageBackend( bucket_name="test-bucket", access_key_id="AKIATEST", secret_access_key="secret123", ) mock_client.assert_called_once() call_kwargs = mock_client.call_args[1] assert call_kwargs.get("aws_access_key_id") == "AKIATEST" assert call_kwargs.get("aws_secret_access_key") == "secret123" def test_create_with_endpoint_url(self, mock_boto3_client: MagicMock) -> None: """Test creating backend with custom endpoint (for S3-compatible services).""" from shared.storage.s3 import S3StorageBackend with patch("boto3.client") as mock_client: S3StorageBackend( bucket_name="test-bucket", endpoint_url="http://localhost:9000", ) mock_client.assert_called_once() call_kwargs = mock_client.call_args[1] assert call_kwargs.get("endpoint_url") == "http://localhost:9000" def test_create_bucket_when_requested(self, mock_boto3_client: MagicMock) -> None: """Test that bucket is created when create_bucket=True.""" from botocore.exceptions import ClientError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_bucket.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadBucket" ) S3StorageBackend( bucket_name="test-bucket", create_bucket=True, ) mock_boto3_client.create_bucket.assert_called_once() def test_is_storage_backend_subclass(self, mock_boto3_client: MagicMock) -> None: """Test that S3StorageBackend is a StorageBackend.""" from shared.storage.base import StorageBackend from shared.storage.s3 import S3StorageBackend backend = S3StorageBackend(bucket_name="test-bucket") assert isinstance(backend, StorageBackend) class TestS3StorageBackendUpload: """Tests for S3StorageBackend.upload method.""" def test_upload_file( self, mock_boto3_client: MagicMock, temp_dir: Path, sample_file: Path ) -> None: """Test uploading a file.""" from botocore.exceptions import ClientError from shared.storage.s3 import S3StorageBackend # Object does not exist mock_boto3_client.head_object.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadObject" ) backend = S3StorageBackend(bucket_name="test-bucket") result = backend.upload(sample_file, "uploads/sample.txt") assert result == "uploads/sample.txt" mock_boto3_client.upload_file.assert_called_once() def test_upload_fails_if_exists_without_overwrite( self, mock_boto3_client: MagicMock, sample_file: Path ) -> None: """Test that upload fails if object exists and overwrite is False.""" from shared.storage.base import StorageError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} # Object exists backend = S3StorageBackend(bucket_name="test-bucket") with pytest.raises(StorageError, match="already exists"): backend.upload(sample_file, "sample.txt", overwrite=False) def test_upload_succeeds_with_overwrite( self, mock_boto3_client: MagicMock, sample_file: Path ) -> None: """Test that upload succeeds with overwrite=True.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} # Object exists backend = S3StorageBackend(bucket_name="test-bucket") result = backend.upload(sample_file, "sample.txt", overwrite=True) assert result == "sample.txt" mock_boto3_client.upload_file.assert_called_once() def test_upload_nonexistent_file_fails( self, mock_boto3_client: MagicMock, temp_dir: Path ) -> None: """Test that uploading nonexistent file fails.""" from shared.storage.base import FileNotFoundStorageError from shared.storage.s3 import S3StorageBackend backend = S3StorageBackend(bucket_name="test-bucket") with pytest.raises(FileNotFoundStorageError): backend.upload(temp_dir / "nonexistent.txt", "sample.txt") class TestS3StorageBackendDownload: """Tests for S3StorageBackend.download method.""" def test_download_file( self, mock_boto3_client: MagicMock, temp_dir: Path ) -> None: """Test downloading a file.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} # Object exists backend = S3StorageBackend(bucket_name="test-bucket") local_path = temp_dir / "downloaded.txt" result = backend.download("sample.txt", local_path) assert result == local_path mock_boto3_client.download_file.assert_called_once() def test_download_creates_parent_directories( self, mock_boto3_client: MagicMock, temp_dir: Path ) -> None: """Test that download creates parent directories.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} backend = S3StorageBackend(bucket_name="test-bucket") local_path = temp_dir / "deep" / "nested" / "downloaded.txt" backend.download("sample.txt", local_path) assert local_path.parent.exists() def test_download_nonexistent_object_fails( self, mock_boto3_client: MagicMock, temp_dir: Path ) -> None: """Test that downloading nonexistent object fails.""" from botocore.exceptions import ClientError from shared.storage.base import FileNotFoundStorageError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadObject" ) backend = S3StorageBackend(bucket_name="test-bucket") with pytest.raises(FileNotFoundStorageError): backend.download("nonexistent.txt", temp_dir / "file.txt") class TestS3StorageBackendExists: """Tests for S3StorageBackend.exists method.""" def test_exists_returns_true_for_existing_object( self, mock_boto3_client: MagicMock ) -> None: """Test exists returns True for existing object.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} backend = S3StorageBackend(bucket_name="test-bucket") assert backend.exists("sample.txt") is True def test_exists_returns_false_for_nonexistent_object( self, mock_boto3_client: MagicMock ) -> None: """Test exists returns False for nonexistent object.""" from botocore.exceptions import ClientError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadObject" ) backend = S3StorageBackend(bucket_name="test-bucket") assert backend.exists("nonexistent.txt") is False class TestS3StorageBackendListFiles: """Tests for S3StorageBackend.list_files method.""" def test_list_files_returns_objects( self, mock_boto3_client: MagicMock ) -> None: """Test listing objects.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.list_objects_v2.return_value = { "Contents": [ {"Key": "file1.txt"}, {"Key": "file2.txt"}, {"Key": "subdir/file3.txt"}, ] } backend = S3StorageBackend(bucket_name="test-bucket") files = backend.list_files("") assert len(files) == 3 assert "file1.txt" in files assert "file2.txt" in files assert "subdir/file3.txt" in files def test_list_files_with_prefix( self, mock_boto3_client: MagicMock ) -> None: """Test listing objects with prefix filter.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.list_objects_v2.return_value = { "Contents": [ {"Key": "images/a.png"}, {"Key": "images/b.png"}, ] } backend = S3StorageBackend(bucket_name="test-bucket") files = backend.list_files("images/") mock_boto3_client.list_objects_v2.assert_called_with( Bucket="test-bucket", Prefix="images/" ) def test_list_files_empty_bucket( self, mock_boto3_client: MagicMock ) -> None: """Test listing files in empty bucket.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.list_objects_v2.return_value = {} # No Contents key backend = S3StorageBackend(bucket_name="test-bucket") files = backend.list_files("") assert files == [] class TestS3StorageBackendDelete: """Tests for S3StorageBackend.delete method.""" def test_delete_existing_object( self, mock_boto3_client: MagicMock ) -> None: """Test deleting an existing object.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} backend = S3StorageBackend(bucket_name="test-bucket") result = backend.delete("sample.txt") assert result is True mock_boto3_client.delete_object.assert_called_once() def test_delete_nonexistent_object_returns_false( self, mock_boto3_client: MagicMock ) -> None: """Test deleting nonexistent object returns False.""" from botocore.exceptions import ClientError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadObject" ) backend = S3StorageBackend(bucket_name="test-bucket") result = backend.delete("nonexistent.txt") assert result is False class TestS3StorageBackendGetUrl: """Tests for S3StorageBackend.get_url method.""" def test_get_url_returns_s3_url( self, mock_boto3_client: MagicMock ) -> None: """Test get_url returns S3 URL.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} mock_boto3_client.generate_presigned_url.return_value = ( "https://test-bucket.s3.amazonaws.com/sample.txt" ) backend = S3StorageBackend(bucket_name="test-bucket") url = backend.get_url("sample.txt") assert "sample.txt" in url def test_get_url_nonexistent_object_raises( self, mock_boto3_client: MagicMock ) -> None: """Test get_url raises for nonexistent object.""" from botocore.exceptions import ClientError from shared.storage.base import FileNotFoundStorageError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadObject" ) backend = S3StorageBackend(bucket_name="test-bucket") with pytest.raises(FileNotFoundStorageError): backend.get_url("nonexistent.txt") class TestS3StorageBackendUploadBytes: """Tests for S3StorageBackend.upload_bytes method.""" def test_upload_bytes( self, mock_boto3_client: MagicMock ) -> None: """Test uploading bytes directly.""" from shared.storage.s3 import S3StorageBackend from botocore.exceptions import ClientError mock_boto3_client.head_object.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadObject" ) backend = S3StorageBackend(bucket_name="test-bucket") data = b"Binary content here" result = backend.upload_bytes(data, "binary.dat") assert result == "binary.dat" mock_boto3_client.put_object.assert_called_once() def test_upload_bytes_fails_if_exists_without_overwrite( self, mock_boto3_client: MagicMock ) -> None: """Test upload_bytes fails if object exists and overwrite is False.""" from shared.storage.base import StorageError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} # Object exists backend = S3StorageBackend(bucket_name="test-bucket") with pytest.raises(StorageError, match="already exists"): backend.upload_bytes(b"content", "sample.txt", overwrite=False) class TestS3StorageBackendDownloadBytes: """Tests for S3StorageBackend.download_bytes method.""" def test_download_bytes( self, mock_boto3_client: MagicMock ) -> None: """Test downloading object as bytes.""" from shared.storage.s3 import S3StorageBackend mock_response = MagicMock() mock_response.read.return_value = b"Hello, World!" mock_boto3_client.get_object.return_value = {"Body": mock_response} backend = S3StorageBackend(bucket_name="test-bucket") data = backend.download_bytes("sample.txt") assert data == b"Hello, World!" def test_download_bytes_nonexistent_raises( self, mock_boto3_client: MagicMock ) -> None: """Test downloading nonexistent object as bytes.""" from botocore.exceptions import ClientError from shared.storage.base import FileNotFoundStorageError from shared.storage.s3 import S3StorageBackend mock_boto3_client.get_object.side_effect = ClientError( {"Error": {"Code": "NoSuchKey"}}, "GetObject" ) backend = S3StorageBackend(bucket_name="test-bucket") with pytest.raises(FileNotFoundStorageError): backend.download_bytes("nonexistent.txt") class TestS3StorageBackendPresignedUrl: """Tests for S3StorageBackend.get_presigned_url method.""" def test_get_presigned_url_generates_url( self, mock_boto3_client: MagicMock ) -> None: """Test get_presigned_url generates presigned URL.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} mock_boto3_client.generate_presigned_url.return_value = ( "https://test-bucket.s3.amazonaws.com/sample.txt?X-Amz-Algorithm=..." ) backend = S3StorageBackend(bucket_name="test-bucket") url = backend.get_presigned_url("sample.txt") assert "X-Amz-Algorithm" in url or "sample.txt" in url mock_boto3_client.generate_presigned_url.assert_called_once() def test_get_presigned_url_with_custom_expiry( self, mock_boto3_client: MagicMock ) -> None: """Test get_presigned_url uses custom expiry.""" from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.return_value = {} mock_boto3_client.generate_presigned_url.return_value = "https://..." backend = S3StorageBackend(bucket_name="test-bucket") backend.get_presigned_url("sample.txt", expires_in_seconds=7200) call_args = mock_boto3_client.generate_presigned_url.call_args assert call_args[1].get("ExpiresIn") == 7200 def test_get_presigned_url_nonexistent_raises( self, mock_boto3_client: MagicMock ) -> None: """Test get_presigned_url raises for nonexistent object.""" from botocore.exceptions import ClientError from shared.storage.base import FileNotFoundStorageError from shared.storage.s3 import S3StorageBackend mock_boto3_client.head_object.side_effect = ClientError( {"Error": {"Code": "404"}}, "HeadObject" ) backend = S3StorageBackend(bucket_name="test-bucket") with pytest.raises(FileNotFoundStorageError): backend.get_presigned_url("nonexistent.txt")