""" Tests for AzureBlobStorageBackend. TDD Phase 1: RED - Write tests first, then implement to pass. Uses mocking to avoid requiring actual Azure credentials. """ import tempfile from pathlib import Path from typing import Any from unittest.mock import MagicMock, PropertyMock, patch import pytest @pytest.fixture def mock_blob_service_client() -> MagicMock: """Create a mock BlobServiceClient.""" return MagicMock() @pytest.fixture def mock_container_client(mock_blob_service_client: MagicMock) -> MagicMock: """Create a mock ContainerClient.""" container_client = MagicMock() mock_blob_service_client.get_container_client.return_value = container_client return container_client @pytest.fixture def mock_blob_client(mock_container_client: MagicMock) -> MagicMock: """Create a mock BlobClient.""" blob_client = MagicMock() mock_container_client.get_blob_client.return_value = blob_client return blob_client class TestAzureBlobStorageBackendCreation: """Tests for AzureBlobStorageBackend instantiation.""" @patch("shared.storage.azure.BlobServiceClient") def test_create_with_connection_string( self, mock_service_class: MagicMock ) -> None: """Test creating backend with connection string.""" from shared.storage.azure import AzureBlobStorageBackend connection_string = "DefaultEndpointsProtocol=https;AccountName=test;..." backend = AzureBlobStorageBackend( connection_string=connection_string, container_name="training-images", ) mock_service_class.from_connection_string.assert_called_once_with( connection_string ) assert backend.container_name == "training-images" @patch("shared.storage.azure.BlobServiceClient") def test_create_creates_container_if_not_exists( self, mock_service_class: MagicMock ) -> None: """Test that container is created if it doesn't exist.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_container.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="new-container", create_container=True, ) mock_container.create_container.assert_called_once() @patch("shared.storage.azure.BlobServiceClient") def test_create_does_not_create_container_by_default( self, mock_service_class: MagicMock ) -> None: """Test that container is not created by default.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_container.exists.return_value = True backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="existing-container", ) mock_container.create_container.assert_not_called() @patch("shared.storage.azure.BlobServiceClient") def test_is_storage_backend_subclass( self, mock_service_class: MagicMock ) -> None: """Test that AzureBlobStorageBackend is a StorageBackend.""" from shared.storage.azure import AzureBlobStorageBackend from shared.storage.base import StorageBackend backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) assert isinstance(backend, StorageBackend) class TestAzureBlobStorageBackendUpload: """Tests for AzureBlobStorageBackend.upload method.""" @patch("shared.storage.azure.BlobServiceClient") def test_upload_file(self, mock_service_class: MagicMock) -> None: """Test uploading a file.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f: f.write(b"Hello, World!") temp_path = Path(f.name) try: result = backend.upload(temp_path, "uploads/sample.txt") assert result == "uploads/sample.txt" mock_container.get_blob_client.assert_called_with("uploads/sample.txt") mock_blob.upload_blob.assert_called_once() finally: temp_path.unlink() @patch("shared.storage.azure.BlobServiceClient") def test_upload_fails_if_blob_exists_without_overwrite( self, mock_service_class: MagicMock ) -> None: """Test that upload fails if blob exists and overwrite is False.""" from shared.storage.azure import AzureBlobStorageBackend from shared.storage.base import StorageError mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f: f.write(b"content") temp_path = Path(f.name) try: with pytest.raises(StorageError, match="already exists"): backend.upload(temp_path, "existing.txt", overwrite=False) finally: temp_path.unlink() @patch("shared.storage.azure.BlobServiceClient") def test_upload_succeeds_with_overwrite( self, mock_service_class: MagicMock ) -> None: """Test that upload succeeds with overwrite=True.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f: f.write(b"content") temp_path = Path(f.name) try: result = backend.upload(temp_path, "existing.txt", overwrite=True) assert result == "existing.txt" mock_blob.upload_blob.assert_called_once() # Check overwrite=True was passed call_kwargs = mock_blob.upload_blob.call_args[1] assert call_kwargs.get("overwrite") is True finally: temp_path.unlink() @patch("shared.storage.azure.BlobServiceClient") def test_upload_nonexistent_file_fails( self, mock_service_class: MagicMock ) -> None: """Test that uploading nonexistent file fails.""" from shared.storage.azure import AzureBlobStorageBackend from shared.storage.base import FileNotFoundStorageError mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with pytest.raises(FileNotFoundStorageError): backend.upload(Path("/nonexistent/file.txt"), "sample.txt") class TestAzureBlobStorageBackendDownload: """Tests for AzureBlobStorageBackend.download method.""" @patch("shared.storage.azure.BlobServiceClient") def test_download_file(self, mock_service_class: MagicMock) -> None: """Test downloading a file.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True # Mock download_blob to return stream mock_stream = MagicMock() mock_stream.readall.return_value = b"Hello, World!" mock_blob.download_blob.return_value = mock_stream backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with tempfile.TemporaryDirectory() as temp_dir: local_path = Path(temp_dir) / "downloaded.txt" result = backend.download("remote/sample.txt", local_path) assert result == local_path assert local_path.exists() assert local_path.read_bytes() == b"Hello, World!" @patch("shared.storage.azure.BlobServiceClient") def test_download_creates_parent_directories( self, mock_service_class: MagicMock ) -> None: """Test that download creates parent directories.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True mock_stream = MagicMock() mock_stream.readall.return_value = b"content" mock_blob.download_blob.return_value = mock_stream backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with tempfile.TemporaryDirectory() as temp_dir: local_path = Path(temp_dir) / "deep" / "nested" / "downloaded.txt" result = backend.download("sample.txt", local_path) assert local_path.exists() @patch("shared.storage.azure.BlobServiceClient") def test_download_nonexistent_blob_fails( self, mock_service_class: MagicMock ) -> None: """Test that downloading nonexistent blob fails.""" from shared.storage.azure import AzureBlobStorageBackend from shared.storage.base import FileNotFoundStorageError mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with pytest.raises(FileNotFoundStorageError, match="nonexistent.txt"): backend.download("nonexistent.txt", Path("/tmp/file.txt")) class TestAzureBlobStorageBackendExists: """Tests for AzureBlobStorageBackend.exists method.""" @patch("shared.storage.azure.BlobServiceClient") def test_exists_returns_true_for_existing_blob( self, mock_service_class: MagicMock ) -> None: """Test exists returns True for existing blob.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) assert backend.exists("existing.txt") is True @patch("shared.storage.azure.BlobServiceClient") def test_exists_returns_false_for_nonexistent_blob( self, mock_service_class: MagicMock ) -> None: """Test exists returns False for nonexistent blob.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) assert backend.exists("nonexistent.txt") is False class TestAzureBlobStorageBackendListFiles: """Tests for AzureBlobStorageBackend.list_files method.""" @patch("shared.storage.azure.BlobServiceClient") def test_list_files_empty_container( self, mock_service_class: MagicMock ) -> None: """Test listing files in empty container.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_container.list_blobs.return_value = [] backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) assert backend.list_files("") == [] @patch("shared.storage.azure.BlobServiceClient") def test_list_files_returns_all_blobs( self, mock_service_class: MagicMock ) -> None: """Test listing all blobs.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container # Create mock blob items mock_blob1 = MagicMock() mock_blob1.name = "file1.txt" mock_blob2 = MagicMock() mock_blob2.name = "file2.txt" mock_blob3 = MagicMock() mock_blob3.name = "subdir/file3.txt" mock_container.list_blobs.return_value = [mock_blob1, mock_blob2, mock_blob3] backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) files = backend.list_files("") assert len(files) == 3 assert "file1.txt" in files assert "file2.txt" in files assert "subdir/file3.txt" in files @patch("shared.storage.azure.BlobServiceClient") def test_list_files_with_prefix( self, mock_service_class: MagicMock ) -> None: """Test listing files with prefix filter.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob1 = MagicMock() mock_blob1.name = "images/a.png" mock_blob2 = MagicMock() mock_blob2.name = "images/b.png" mock_container.list_blobs.return_value = [mock_blob1, mock_blob2] backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) files = backend.list_files("images/") mock_container.list_blobs.assert_called_with(name_starts_with="images/") assert len(files) == 2 class TestAzureBlobStorageBackendDelete: """Tests for AzureBlobStorageBackend.delete method.""" @patch("shared.storage.azure.BlobServiceClient") def test_delete_existing_blob( self, mock_service_class: MagicMock ) -> None: """Test deleting an existing blob.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) result = backend.delete("sample.txt") assert result is True mock_blob.delete_blob.assert_called_once() @patch("shared.storage.azure.BlobServiceClient") def test_delete_nonexistent_blob_returns_false( self, mock_service_class: MagicMock ) -> None: """Test deleting nonexistent blob returns False.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) result = backend.delete("nonexistent.txt") assert result is False mock_blob.delete_blob.assert_not_called() class TestAzureBlobStorageBackendGetUrl: """Tests for AzureBlobStorageBackend.get_url method.""" @patch("shared.storage.azure.BlobServiceClient") def test_get_url_returns_blob_url( self, mock_service_class: MagicMock ) -> None: """Test get_url returns blob URL.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True mock_blob.url = "https://account.blob.core.windows.net/container/sample.txt" backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) url = backend.get_url("sample.txt") assert url == "https://account.blob.core.windows.net/container/sample.txt" @patch("shared.storage.azure.BlobServiceClient") def test_get_url_nonexistent_blob_fails( self, mock_service_class: MagicMock ) -> None: """Test get_url for nonexistent blob fails.""" from shared.storage.azure import AzureBlobStorageBackend from shared.storage.base import FileNotFoundStorageError mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with pytest.raises(FileNotFoundStorageError): backend.get_url("nonexistent.txt") class TestAzureBlobStorageBackendUploadBytes: """Tests for AzureBlobStorageBackend.upload_bytes method.""" @patch("shared.storage.azure.BlobServiceClient") def test_upload_bytes(self, mock_service_class: MagicMock) -> None: """Test uploading bytes directly.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) data = b"Binary content here" result = backend.upload_bytes(data, "binary.dat") assert result == "binary.dat" mock_blob.upload_blob.assert_called_once() class TestAzureBlobStorageBackendDownloadBytes: """Tests for AzureBlobStorageBackend.download_bytes method.""" @patch("shared.storage.azure.BlobServiceClient") def test_download_bytes(self, mock_service_class: MagicMock) -> None: """Test downloading blob as bytes.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = True mock_stream = MagicMock() mock_stream.readall.return_value = b"Hello, World!" mock_blob.download_blob.return_value = mock_stream backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) data = backend.download_bytes("sample.txt") assert data == b"Hello, World!" @patch("shared.storage.azure.BlobServiceClient") def test_download_bytes_nonexistent( self, mock_service_class: MagicMock ) -> None: """Test downloading nonexistent blob as bytes.""" from shared.storage.azure import AzureBlobStorageBackend from shared.storage.base import FileNotFoundStorageError mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with pytest.raises(FileNotFoundStorageError): backend.download_bytes("nonexistent.txt") class TestAzureBlobStorageBackendBatchOperations: """Tests for batch operations in AzureBlobStorageBackend.""" @patch("shared.storage.azure.BlobServiceClient") def test_upload_directory(self, mock_service_class: MagicMock) -> None: """Test uploading an entire directory.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container mock_blob = MagicMock() mock_container.get_blob_client.return_value = mock_blob mock_blob.exists.return_value = False backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) (temp_path / "file1.txt").write_text("content1") (temp_path / "subdir").mkdir() (temp_path / "subdir" / "file2.txt").write_text("content2") results = backend.upload_directory(temp_path, "uploads/") assert len(results) == 2 assert "uploads/file1.txt" in results assert "uploads/subdir/file2.txt" in results @patch("shared.storage.azure.BlobServiceClient") def test_download_directory(self, mock_service_class: MagicMock) -> None: """Test downloading blobs matching a prefix.""" from shared.storage.azure import AzureBlobStorageBackend mock_service = MagicMock() mock_service_class.from_connection_string.return_value = mock_service mock_container = MagicMock() mock_service.get_container_client.return_value = mock_container # Mock blob listing mock_blob1 = MagicMock() mock_blob1.name = "images/a.png" mock_blob2 = MagicMock() mock_blob2.name = "images/b.png" mock_container.list_blobs.return_value = [mock_blob1, mock_blob2] # Mock blob clients mock_blob_client = MagicMock() mock_container.get_blob_client.return_value = mock_blob_client mock_blob_client.exists.return_value = True mock_stream = MagicMock() mock_stream.readall.return_value = b"image content" mock_blob_client.download_blob.return_value = mock_stream backend = AzureBlobStorageBackend( connection_string="connection_string", container_name="container", ) with tempfile.TemporaryDirectory() as temp_dir: local_path = Path(temp_dir) results = backend.download_directory("images/", local_path) assert len(results) == 2 # Files should be created relative to prefix assert (local_path / "a.png").exists() or (local_path / "images" / "a.png").exists()