Files
invoice-master-poc-v2/tests/shared/storage/test_azure.py
Yaojia Wang a516de4320 WIP
2026-02-01 00:08:40 +01:00

719 lines
26 KiB
Python

"""
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()