Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Test package for omni-tool-runtime.

Developer: Manish Kumar <manish@omnibioai.org>
"""
53 changes: 53 additions & 0 deletions tests/test_azureblob_uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
pytest tests/test_azureblob_uploader.py -v
pytest tests/test_azureblob_uploader.py \
--cov=omni_tool_runtime/uploaders/azureblob_uploader --cov-report=term-missing -v

Developer: Manish Kumar <manish@omnibioai.org>
"""

from __future__ import annotations
Expand All @@ -23,6 +25,7 @@


def _install_azure_stubs():
"""Register fake azure/azure.storage/azure.storage.blob/azure.identity modules in sys.modules so the uploader imports cleanly without the real azure SDK."""
azure = types.ModuleType("azure")
azure_storage = types.ModuleType("azure.storage")
azure_storage_blob = types.ModuleType("azure.storage.blob")
Expand Down Expand Up @@ -56,6 +59,7 @@ def _install_azure_stubs():


def _make_uploader(**kw) -> AzureBlobUploader:
"""Build an AzureBlobUploader with sensible defaults, overridable per test."""
defaults = dict(account_name="myaccount", auth="managed_identity", connection_string=None)
return AzureBlobUploader(**{**defaults, **kw})

Expand All @@ -74,20 +78,26 @@ def _mock_blob_service():


class TestAzureBlobUploaderConstruction:
"""The AzureBlobUploader dataclass stores account_name/auth/connection_string verbatim, defaulting to managed-identity auth."""
def test_account_name_stored(self):
"""Store the given account_name unchanged."""
assert _make_uploader(account_name="acct").account_name == "acct"

def test_default_auth_is_managed_identity(self):
"""Default auth to managed_identity when not given."""
assert AzureBlobUploader(account_name="acct").auth == "managed_identity"

def test_explicit_auth_connection_string(self):
"""Store an explicitly given connection_string auth mode."""
u = _make_uploader(auth="connection_string", connection_string="cs")
assert u.auth == "connection_string"

def test_connection_string_default_is_none(self):
"""Default connection_string to None when not given."""
assert AzureBlobUploader(account_name="acct").connection_string is None

def test_connection_string_stored(self):
"""Store the given connection_string unchanged."""
cs = "DefaultEndpointsProtocol=https;..."
assert _make_uploader(connection_string=cs).connection_string == cs

Expand All @@ -98,34 +108,42 @@ def test_connection_string_stored(self):


class TestClientConnectionString:
"""_client() builds a BlobServiceClient via from_connection_string() and never touches DefaultAzureCredential."""

def _make(self, connection_string="cs://fake"):
"""Build an uploader configured for connection_string auth."""
return _make_uploader(auth="connection_string", connection_string=connection_string)

def test_calls_from_connection_string(self):
"""_client() builds the BlobServiceClient via from_connection_string() using the stored connection string."""
mock_bsc = MagicMock()
_azure_storage_blob_stub.BlobServiceClient = mock_bsc
uploader = self._make()
uploader._client()
mock_bsc.from_connection_string.assert_called_once_with("cs://fake")

def test_returns_blob_service_client(self):
"""_client() returns the BlobServiceClient instance produced by from_connection_string()."""
mock_bsc = MagicMock()
sentinel = object()
mock_bsc.from_connection_string.return_value = sentinel
_azure_storage_blob_stub.BlobServiceClient = mock_bsc
assert self._make()._client() is sentinel

def test_missing_connection_string_raises(self):
"""Reject connection_string auth when no connection string is configured (None)."""
uploader = _make_uploader(auth="connection_string", connection_string=None)
with pytest.raises(RuntimeError, match="connection_string auth requires connection_string"):
uploader._client()

def test_empty_connection_string_raises(self):
"""Reject connection_string auth when the connection string is an empty string."""
uploader = _make_uploader(auth="connection_string", connection_string="")
with pytest.raises(RuntimeError, match="connection_string auth requires connection_string"):
uploader._client()

def test_does_not_use_default_azure_credential(self):
"""Never construct a DefaultAzureCredential when using connection_string auth."""
mock_dac = MagicMock()
_azure_identity_stub.DefaultAzureCredential = mock_dac
_azure_storage_blob_stub.BlobServiceClient = MagicMock()
Expand All @@ -139,10 +157,14 @@ def test_does_not_use_default_azure_credential(self):


class TestClientManagedIdentity:
"""_client() builds a BlobServiceClient authenticated via DefaultAzureCredential and never calls from_connection_string()."""

def _make(self):
"""Build an uploader configured for managed_identity auth against a fixed account name."""
return _make_uploader(auth="managed_identity", account_name="storageacct")

def test_creates_default_azure_credential(self):
"""_client() constructs a DefaultAzureCredential with interactive browser login excluded."""
mock_dac = MagicMock()
mock_bsc = MagicMock()
_azure_identity_stub.DefaultAzureCredential = mock_dac
Expand All @@ -151,6 +173,7 @@ def test_creates_default_azure_credential(self):
mock_dac.assert_called_once_with(exclude_interactive_browser_credential=True)

def test_builds_correct_account_url(self):
"""_client() builds the account_url as https://<account>.blob.core.windows.net."""
mock_dac = MagicMock()
mock_bsc = MagicMock()
_azure_identity_stub.DefaultAzureCredential = mock_dac
Expand All @@ -160,6 +183,7 @@ def test_builds_correct_account_url(self):
assert call_kwargs["account_url"] == "https://storageacct.blob.core.windows.net"

def test_credential_passed_to_blob_service_client(self):
"""_client() passes the constructed DefaultAzureCredential through to BlobServiceClient."""
fake_cred = object()
mock_dac = MagicMock(return_value=fake_cred)
mock_bsc = MagicMock()
Expand All @@ -169,6 +193,7 @@ def test_credential_passed_to_blob_service_client(self):
assert mock_bsc.call_args.kwargs["credential"] is fake_cred

def test_does_not_call_from_connection_string(self):
"""Never call from_connection_string() when using managed_identity auth."""
mock_dac = MagicMock()
mock_bsc = MagicMock()
_azure_identity_stub.DefaultAzureCredential = mock_dac
Expand All @@ -177,6 +202,7 @@ def test_does_not_call_from_connection_string(self):
mock_bsc.from_connection_string.assert_not_called()

def test_returns_blob_service_client_instance(self):
"""_client() returns the BlobServiceClient instance built for managed-identity auth."""
sentinel = object()
mock_dac = MagicMock()
mock_bsc = MagicMock(return_value=sentinel)
Expand All @@ -191,7 +217,10 @@ def test_returns_blob_service_client_instance(self):


class TestClientMissingPackages:
"""_client() must fail with a clear, actionable error when the azure-storage-blob or azure-identity packages are not installed."""

def test_missing_azure_storage_blob_raises(self):
"""Raise RuntimeError naming azure-storage-blob when that package is unavailable."""
uploader = _make_uploader(auth="managed_identity")
with (
patch.dict(sys.modules, {"azure.storage.blob": None}),
Expand All @@ -200,6 +229,7 @@ def test_missing_azure_storage_blob_raises(self):
uploader._client()

def test_missing_azure_identity_raises(self):
"""Raise RuntimeError naming azure-identity when that package is unavailable under managed-identity auth."""
uploader = _make_uploader(auth="managed_identity")
# azure.storage.blob is present but azure.identity is missing
real_bsc = _azure_storage_blob_stub.BlobServiceClient
Expand All @@ -212,6 +242,7 @@ def test_missing_azure_identity_raises(self):
_azure_storage_blob_stub.BlobServiceClient = real_bsc

def test_missing_blob_error_message_mentions_install(self):
"""Point to the omnibioai-tool-runtime install extra in the missing-azure-storage-blob error message."""
uploader = _make_uploader(auth="managed_identity")
with (
patch.dict(sys.modules, {"azure.storage.blob": None}),
Expand All @@ -226,7 +257,10 @@ def test_missing_blob_error_message_mentions_install(self):


class TestUploadBytes:
"""upload_bytes() forwards container/blob_path/data/content_type to the Azure client, lowercasing the container name."""

def _call(self, uploader=None, **kw):
"""Call upload_bytes with defaults, injecting a mocked BlobServiceClient via _client()."""
uploader = uploader or _make_uploader()
defaults = dict(
container="MyContainer",
Expand All @@ -240,43 +274,53 @@ def _call(self, uploader=None, **kw):
return mock_svc, mock_bc

def test_get_blob_client_called(self):
"""Call get_blob_client exactly once per upload."""
mock_svc, _ = self._call()
mock_svc.get_blob_client.assert_called_once()

def test_container_lowercased(self):
"""Lowercase a mixed-case container name before passing it to get_blob_client (Azure containers are case-sensitive and conventionally lowercase)."""
mock_svc, _ = self._call(container="MyContainer")
assert mock_svc.get_blob_client.call_args.kwargs["container"] == "mycontainer"

def test_already_lowercase_container_unchanged(self):
"""Leave an already-lowercase container name unchanged."""
mock_svc, _ = self._call(container="mycontainer")
assert mock_svc.get_blob_client.call_args.kwargs["container"] == "mycontainer"

def test_blob_path_passed_correctly(self):
"""Forward the given blob_path unchanged to get_blob_client."""
mock_svc, _ = self._call(blob_path="output/run-1/results.json")
assert mock_svc.get_blob_client.call_args.kwargs["blob"] == "output/run-1/results.json"

def test_upload_blob_called(self):
"""Call upload_blob exactly once per upload."""
_, mock_bc = self._call()
mock_bc.upload_blob.assert_called_once()

def test_data_passed_to_upload_blob(self):
"""Forward the given data bytes as upload_blob's first positional argument unmodified."""
payload = b'{"result": 42}'
_, mock_bc = self._call(data=payload)
assert mock_bc.upload_blob.call_args.args[0] == payload

def test_overwrite_is_true(self):
"""Always pass overwrite=True to upload_blob so re-runs replace prior results."""
_, mock_bc = self._call()
assert mock_bc.upload_blob.call_args.kwargs["overwrite"] is True

def test_content_type_passed(self):
"""Forward the given content_type to upload_blob."""
_, mock_bc = self._call(content_type="text/plain")
assert mock_bc.upload_blob.call_args.kwargs["content_type"] == "text/plain"

def test_default_content_type_json(self):
"""Forward application/json as content_type when that is the given content_type."""
_, mock_bc = self._call(content_type="application/json")
assert mock_bc.upload_blob.call_args.kwargs["content_type"] == "application/json"

def test_client_called_once(self):
"""Call _client() exactly once per upload."""
uploader = _make_uploader()
mock_svc, _ = _mock_blob_service()
with patch.object(uploader, "_client", return_value=mock_svc) as mock_client:
Expand All @@ -289,6 +333,7 @@ def test_client_called_once(self):
mock_client.assert_called_once()

def test_returns_none(self):
"""upload_bytes returns None (the Azure call result is not surfaced to the caller)."""
uploader = _make_uploader()
mock_svc, _ = _mock_blob_service()
with patch.object(uploader, "_client", return_value=mock_svc):
Expand All @@ -301,12 +346,14 @@ def test_returns_none(self):
assert result is None

def test_upload_blob_receives_bytes_not_string(self):
"""Pass the payload through to upload_blob as bytes, not a str."""
payload = b"binary-content"
_, mock_bc = self._call(data=payload)
uploaded = mock_bc.upload_blob.call_args.args[0]
assert isinstance(uploaded, bytes)

def test_stdout_logged(self, capsys):
"""Log the container and blob path (structural, non-sensitive identifiers) to stdout on upload."""
uploader = _make_uploader()
mock_svc, _ = _mock_blob_service()
with patch.object(uploader, "_client", return_value=mock_svc):
Expand All @@ -327,7 +374,10 @@ def test_stdout_logged(self, capsys):


class TestUploadBytesEndToEndConnectionString:
"""Full upload_bytes() flow using connection_string auth, from client construction through the final upload_blob call."""

def test_full_flow_connection_string(self):
"""Full upload_bytes flow using connection_string auth: builds the client via from_connection_string, uploads to the exact container/blob/body/content-type given."""
conn = "DefaultEndpointsProtocol=https;AccountName=x;..."
uploader = _make_uploader(
auth="connection_string",
Expand Down Expand Up @@ -360,7 +410,10 @@ def test_full_flow_connection_string(self):


class TestUploadBytesEndToEndManagedIdentity:
"""Full upload_bytes() flow using managed_identity auth, from client construction through the final upload_blob call."""

def test_full_flow_managed_identity(self):
"""Full upload_bytes flow using managed_identity auth: builds the client via DefaultAzureCredential, lowercases the container, and uploads the exact blob/body/content-type given."""
uploader = _make_uploader(auth="managed_identity", account_name="storageacct")
mock_svc, mock_bc = _mock_blob_service()

Expand Down
Loading
Loading