489 lines
15 KiB
Python
489 lines
15 KiB
Python
# pylint: disable=protected-access
|
|
# The above line disables pylint's protected member access warnings for this file,
|
|
# allowing tests to access MinioAdapter's internal methods as needed for integration testing.
|
|
"""Integration tests for the MinioAdapter."""
|
|
|
|
from collections.abc import Generator
|
|
from minio import S3Error
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
from minio import Minio
|
|
from io import BytesIO
|
|
import random
|
|
import os
|
|
import logging
|
|
from python_repositories.adapters.minio_adapter import MinioAdapter
|
|
|
|
|
|
def same_data(
|
|
data_a: BytesIO,
|
|
data_b: BytesIO,
|
|
) -> bool:
|
|
"""Check if two BytesIO-objects contain the same data."""
|
|
assert isinstance(data_a, BytesIO)
|
|
assert isinstance(data_b, BytesIO)
|
|
# prepare for being read
|
|
data_a.seek(0)
|
|
data_b.seek(0)
|
|
# convert to bytes
|
|
data_a_bytes = data_a.read()
|
|
data_b_bytes = data_b.read()
|
|
# compare size
|
|
if len(data_a_bytes) != len(data_b_bytes):
|
|
logging.error(
|
|
"data has different length: %s and %s",
|
|
len(data_a_bytes),
|
|
len(data_b_bytes),
|
|
)
|
|
return False
|
|
# compare content
|
|
if data_a_bytes != data_b_bytes:
|
|
logging.error("data has different bytes")
|
|
return False
|
|
return True
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def data() -> Generator[BytesIO]:
|
|
"""Provide a sample data bytes for tests."""
|
|
# Generate random bytes
|
|
random_bytes = random.randbytes(2**21) # 2 MiB
|
|
yield BytesIO(random_bytes)
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def data_in_minio(
|
|
raw_minio_client: Minio,
|
|
data: BytesIO,
|
|
) -> Generator[tuple[str, BytesIO]]:
|
|
"""Fixture to set up a known value in Minio before each test."""
|
|
object_name = "test_object"
|
|
bucket_name = str(os.getenv("MINIO_BUCKET"))
|
|
# Upload object
|
|
num_bytes = data.getbuffer().nbytes
|
|
data.seek(0)
|
|
raw_minio_client.put_object(
|
|
bucket_name,
|
|
object_name,
|
|
data,
|
|
length=num_bytes,
|
|
part_size=MinioAdapter.chunk_size,
|
|
)
|
|
# Reset data for reading in tests
|
|
data.seek(0)
|
|
|
|
yield object_name, data
|
|
|
|
# Cleanup
|
|
raw_minio_client.remove_object(bucket_name, object_name)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def minio_adapter() -> Generator[MinioAdapter]:
|
|
"""Fixture to provide a connected MinioAdapter instance."""
|
|
adapter = MinioAdapter()
|
|
adapter.connect()
|
|
yield adapter
|
|
adapter.disconnect()
|
|
|
|
|
|
@pytest.fixture(scope="function", autouse=True)
|
|
def clear_minio(
|
|
raw_minio_client: Minio,
|
|
) -> None:
|
|
"""Fixture to clear all Minio objects before each test."""
|
|
bucket_name = str(os.getenv("MINIO_BUCKET"))
|
|
# Clear all objects before each test
|
|
objects = raw_minio_client.list_objects(bucket_name, recursive=True)
|
|
for obj in objects:
|
|
if not obj.object_name:
|
|
continue
|
|
raw_minio_client.remove_object(bucket_name, obj.object_name)
|
|
|
|
|
|
def test_should_adhere_to_interface() -> None:
|
|
"""Test that the MinioAdapter adheres to the expected interface."""
|
|
# Instantiation fails if interface not adhered to
|
|
_ = MinioAdapter()
|
|
|
|
|
|
def test_should_have_logger_when_instantiated() -> None:
|
|
"""Test that the MinioAdapter has a logger when instantiated."""
|
|
adapter = MinioAdapter()
|
|
assert hasattr(adapter, "logger")
|
|
|
|
|
|
def test_should_not_be_connected_when_instantiated() -> None:
|
|
"""Test that the MinioAdapter is not connected when instantiated."""
|
|
adapter = MinioAdapter()
|
|
assert not adapter.is_connected
|
|
|
|
|
|
def test_should_log_info_when_already_connected(
|
|
minio_adapter: MinioAdapter,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that the MinioAdapter logs info when connect is called while already connected."""
|
|
with caplog.at_level(logging.INFO):
|
|
minio_adapter.connect()
|
|
assert "Already connected to Minio" in caplog.text
|
|
|
|
|
|
def test_should_raise_connection_error_when_unable_to_connect(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises a ConnectionError when unable to connect."""
|
|
# Arrange
|
|
monkeypatch.setenv("MINIO_ENDPOINT", "invalid_uri")
|
|
adapter = MinioAdapter()
|
|
with pytest.raises(ConnectionError):
|
|
adapter.connect()
|
|
assert not adapter.is_connected
|
|
|
|
|
|
def test_should_log_info_when_creating_expected_bucket(
|
|
raw_minio_client: Minio,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that the MinioAdapter logs info when creating the expected bucket."""
|
|
# Arrange
|
|
bucket_name = str(os.getenv("MINIO_BUCKET"))
|
|
raw_minio_client.remove_bucket(bucket_name)
|
|
adapter = MinioAdapter()
|
|
# Act
|
|
with caplog.at_level(logging.INFO):
|
|
adapter.connect()
|
|
# Assert
|
|
assert f"Creating bucket '{bucket_name}'" in caplog.text
|
|
|
|
|
|
def test_should_log_error_on_exception_during_exit(
|
|
minio_container: dict[str, str],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that the MinioAdapter logs an error if an exception occurs during __exit__."""
|
|
try:
|
|
with MinioAdapter() as adapter:
|
|
assert adapter.is_connected
|
|
raise ValueError("Simulated error")
|
|
except ValueError:
|
|
pass # Expected
|
|
# Assert error was logged
|
|
assert "Error while exiting context" in caplog.text
|
|
|
|
|
|
def test_should_have_context_manager() -> None:
|
|
"""Test that the MinioAdapter can be used as a context manager."""
|
|
with MinioAdapter() as adapter:
|
|
assert adapter._client is not None
|
|
assert adapter._client is None
|
|
|
|
|
|
def test_should_get_data(
|
|
data_in_minio: tuple[str, BytesIO],
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter can get data from a bucket."""
|
|
# Arrange
|
|
object_name, expected_data = data_in_minio
|
|
# Act
|
|
received_data = minio_adapter._get(object_name)
|
|
# Assert
|
|
assert received_data is not None
|
|
assert same_data(expected_data, received_data)
|
|
|
|
|
|
def test_should_get_none_for_nonexistent_object(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter returns None for a nonexistent object."""
|
|
# Arrange
|
|
object_name = "nonexistent_object"
|
|
# Act
|
|
received_data = minio_adapter._get(object_name)
|
|
# Assert
|
|
assert received_data is None
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_get_object_name(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ValueError when getting with an invalid object name."""
|
|
# Arrange
|
|
invalid_object_names = ["", 123, None]
|
|
# Act & Assert
|
|
for object_name in invalid_object_names:
|
|
with pytest.raises(ValueError):
|
|
minio_adapter._get(object_name) # type: ignore
|
|
|
|
|
|
def test_should_raise_connection_error_on_get_when_not_connected(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ConnectionError when getting while not connected."""
|
|
# Arrange
|
|
adapter = MinioAdapter() # not connected
|
|
# Act & Assert
|
|
with pytest.raises(ConnectionError):
|
|
adapter._get("some_object")
|
|
|
|
|
|
def test_should_log_warning_when_getting_nonexistent_object(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that the MinioAdapter logs a warning when getting a nonexistent object."""
|
|
# Arrange
|
|
adapter = MinioAdapter()
|
|
adapter._client = MagicMock(spec=Minio)
|
|
adapter._client.get_object.side_effect = S3Error(
|
|
code="NoSuchKey",
|
|
message="",
|
|
resource="",
|
|
request_id="",
|
|
host_id="",
|
|
response="",
|
|
bucket_name="test-bucket",
|
|
object_name="missing-object",
|
|
)
|
|
adapter._bucket_name = "test-bucket"
|
|
object_name = "missing-object"
|
|
# Act
|
|
with caplog.at_level("WARNING"):
|
|
result = adapter._get(object_name)
|
|
# Assert
|
|
assert result is None
|
|
assert (
|
|
f"Object '{object_name}' not found in bucket '{adapter._bucket_name}'"
|
|
in caplog.text
|
|
)
|
|
|
|
|
|
def test_should_log_error_when_getting_with_s3error_other_than_no_such_key(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that the MinioAdapter logs an error when getting a nonexistent object."""
|
|
# Arrange
|
|
adapter = MinioAdapter()
|
|
adapter._client = MagicMock(spec=Minio)
|
|
other_s3error = S3Error(
|
|
code="UnhandledError",
|
|
message="",
|
|
resource="",
|
|
request_id="",
|
|
host_id="",
|
|
response="",
|
|
bucket_name="test-bucket",
|
|
object_name="missing-object",
|
|
)
|
|
adapter._client.get_object.side_effect = other_s3error
|
|
adapter._bucket_name = "test-bucket"
|
|
object_name = "missing-object"
|
|
# Act
|
|
with caplog.at_level("ERROR"):
|
|
result = adapter._get(object_name)
|
|
# Assert
|
|
assert result is None
|
|
assert repr(other_s3error) in caplog.text
|
|
|
|
|
|
def test_should_log_error_when_getting_with_general_exception(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that the MinioAdapter logs an error when getting a nonexistent object."""
|
|
# Arrange
|
|
adapter = MinioAdapter()
|
|
adapter._client = MagicMock(spec=Minio)
|
|
general_exception = Exception("General failure")
|
|
adapter._client.get_object.side_effect = general_exception
|
|
adapter._bucket_name = "test-bucket"
|
|
object_name = "missing-object"
|
|
# Act
|
|
with caplog.at_level("ERROR"):
|
|
result = adapter._get(object_name)
|
|
# Assert
|
|
assert result is None
|
|
assert repr(general_exception) in caplog.text
|
|
|
|
|
|
def test_should_put_data(
|
|
data: BytesIO,
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter can put data into a bucket."""
|
|
# Arrange
|
|
object_name = "new_test_object"
|
|
received_data = minio_adapter._get(object_name)
|
|
assert received_data is None # ensure object does not exist yet
|
|
# Act
|
|
minio_adapter._put(object_name, data)
|
|
# Assert
|
|
received_data = minio_adapter._get(object_name)
|
|
assert received_data is not None
|
|
assert same_data(data, received_data)
|
|
# Cleanup
|
|
bucket_name = str(os.getenv("MINIO_BUCKET"))
|
|
minio_adapter._client.remove_object(bucket_name, object_name) # type: ignore
|
|
|
|
|
|
def test_should_update_data(
|
|
data_in_minio: tuple[str, BytesIO],
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter can update data in a bucket."""
|
|
# Arrange
|
|
object_name, _ = data_in_minio
|
|
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
|
received_data = minio_adapter._get(object_name)
|
|
assert received_data is not None
|
|
assert not same_data(received_data, new_data)
|
|
# Act
|
|
minio_adapter._put(object_name, new_data)
|
|
# Assert
|
|
received_data = minio_adapter._get(object_name)
|
|
assert received_data is not None
|
|
assert same_data(new_data, received_data)
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_put_object_name(
|
|
data: BytesIO,
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ValueError when putting with an invalid object name."""
|
|
# Arrange
|
|
invalid_object_names = ["", 123, None]
|
|
# Act & Assert
|
|
for object_name in invalid_object_names:
|
|
with pytest.raises(ValueError):
|
|
minio_adapter._put(object_name, data) # type: ignore
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_put_data(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ValueError when putting with invalid data."""
|
|
# Arrange
|
|
object_name = "valid_object_name"
|
|
invalid_data = ["not_bytesio", 123, None]
|
|
# Act & Assert
|
|
for data in invalid_data:
|
|
with pytest.raises(ValueError):
|
|
minio_adapter._put(object_name, data) # type: ignore
|
|
|
|
|
|
def test_should_raise_connection_error_on_put_when_not_connected(
|
|
data: BytesIO,
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ConnectionError when putting while not connected."""
|
|
# Arrange
|
|
adapter = MinioAdapter() # not connected
|
|
# Act & Assert
|
|
with pytest.raises(ConnectionError):
|
|
adapter._put("some_object", data)
|
|
|
|
|
|
def test_should_delete_object(
|
|
data_in_minio: tuple[str, BytesIO],
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter can delete an object from a bucket."""
|
|
# Arrange
|
|
object_name, _ = data_in_minio
|
|
received_data = minio_adapter._get(object_name)
|
|
assert received_data is not None # ensure object exists
|
|
# Act
|
|
minio_adapter._delete(object_name)
|
|
# Assert
|
|
received_data = minio_adapter._get(object_name)
|
|
assert received_data is None
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_delete_object_name(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ValueError when deleting with an invalid object name."""
|
|
# Arrange
|
|
invalid_object_names = ["", 123, None]
|
|
# Act & Assert
|
|
for object_name in invalid_object_names:
|
|
with pytest.raises(ValueError):
|
|
minio_adapter._delete(object_name) # type: ignore
|
|
|
|
|
|
def test_should_raise_connection_error_on_delete_when_not_connected(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ConnectionError when deleting while not connected."""
|
|
# Arrange
|
|
adapter = MinioAdapter() # not connected
|
|
# Act & Assert
|
|
with pytest.raises(ConnectionError):
|
|
adapter._delete("some_object")
|
|
|
|
|
|
def test_should_list_objects(
|
|
data_in_minio: tuple[str, BytesIO],
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter can list objects in a bucket."""
|
|
# Arrange
|
|
object_name, _ = data_in_minio
|
|
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
|
new_data_name = "another_test_object"
|
|
minio_adapter._put(new_data_name, new_data)
|
|
# Act
|
|
objects = minio_adapter._list_objects()
|
|
# Assert
|
|
assert isinstance(objects, list)
|
|
assert len(objects) == 2
|
|
assert object_name in objects
|
|
assert new_data_name in objects
|
|
|
|
|
|
def test_should_list_objects_with_prefix(
|
|
data_in_minio: tuple[str, BytesIO],
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter can list objects in a bucket with a prefix."""
|
|
# Arrange
|
|
object_name, _ = data_in_minio
|
|
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
|
new_data_name = "prefix_test_object"
|
|
minio_adapter._put(new_data_name, new_data)
|
|
prefix = "prefix_"
|
|
# Act
|
|
objects = minio_adapter._list_objects(prefix)
|
|
# Assert
|
|
assert isinstance(objects, list)
|
|
assert len(objects) == 1
|
|
assert new_data_name in objects
|
|
assert object_name not in objects
|
|
|
|
|
|
def test_should_raise_value_error_on_invalid_list_objects_prefix(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ValueError when listing with an invalid prefix."""
|
|
# Arrange
|
|
invalid_prefixes = [123, None]
|
|
# Act & Assert
|
|
for prefix in invalid_prefixes:
|
|
with pytest.raises(ValueError):
|
|
minio_adapter._list_objects(prefix) # type: ignore
|
|
|
|
|
|
def test_should_raise_connection_error_on_list_objects_when_not_connected(
|
|
minio_adapter: MinioAdapter,
|
|
) -> None:
|
|
"""Test that the MinioAdapter raises ConnectionError when listing while not connected."""
|
|
# Arrange
|
|
adapter = MinioAdapter() # not connected
|
|
# Act & Assert
|
|
with pytest.raises(ConnectionError):
|
|
adapter._list_objects()
|
|
|
|
|
|
# allows local debugging by running file as script
|
|
if __name__ == "__main__":
|
|
pytest.main(["-s", "-v", __file__])
|