Scope integration fixtures per backend subdirectory.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 13s
Test Python Package / integration-tests (pull_request) Successful in 23s
Test Python Package / coverage-report (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Successful in 49s
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 13s
Test Python Package / integration-tests (pull_request) Successful in 23s
Test Python Package / coverage-report (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Successful in 49s
Split Redis and MinIO container setup into backend-specific conftest modules so only the containers needed for collected tests are imported and started. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
co-authored by
Cursor
parent
6c66fe4e21
commit
b28ac6e803
@@ -0,0 +1,437 @@
|
||||
"""Integration tests for the MinioAdapter."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import replace
|
||||
from io import BytesIO
|
||||
import logging
|
||||
import random
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from minio import Minio, S3Error
|
||||
import pytest
|
||||
from urllib3.response import BaseHTTPResponse
|
||||
|
||||
from python_repositories.adapters.minio_adapter import MinioAdapter
|
||||
from python_repositories.config import MinioConfig
|
||||
from tests.conftest import TEST_MINIO_CONFIG
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.needs_minio]
|
||||
|
||||
|
||||
def same_data(
|
||||
data_a: BytesIO,
|
||||
data_b: BytesIO,
|
||||
) -> bool:
|
||||
"""Check if two BytesIO-objects contain the same data."""
|
||||
data_a.seek(0)
|
||||
data_b.seek(0)
|
||||
data_a_bytes = data_a.read()
|
||||
data_b_bytes = data_b.read()
|
||||
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
|
||||
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, None, None]:
|
||||
"""Provide a sample data bytes for tests."""
|
||||
random_bytes = random.randbytes(2**21)
|
||||
yield BytesIO(random_bytes)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def data_in_minio(
|
||||
raw_minio_client: Minio,
|
||||
minio_config: MinioConfig,
|
||||
data: BytesIO,
|
||||
) -> Generator[tuple[str, BytesIO], None, None]:
|
||||
"""Fixture to set up a known value in Minio before each test."""
|
||||
object_name = "test_object"
|
||||
bucket_name = minio_config.bucket
|
||||
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,
|
||||
)
|
||||
data.seek(0)
|
||||
|
||||
yield object_name, data
|
||||
|
||||
raw_minio_client.remove_object(bucket_name, object_name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def minio_adapter(minio_config: MinioConfig) -> Generator[MinioAdapter, None, None]:
|
||||
"""Fixture to provide a connected MinioAdapter instance."""
|
||||
adapter = MinioAdapter(config=minio_config)
|
||||
adapter.connect()
|
||||
yield adapter
|
||||
adapter.disconnect()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def clear_minio(
|
||||
raw_minio_client: Minio,
|
||||
minio_config: MinioConfig,
|
||||
) -> None:
|
||||
"""Fixture to clear all Minio objects before each test."""
|
||||
bucket_name = minio_config.bucket
|
||||
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_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" in caplog.text
|
||||
assert "Minio" in caplog.text
|
||||
|
||||
|
||||
def test_should_raise_connection_error_when_unable_to_connect() -> None:
|
||||
"""Test that the MinioAdapter raises a ConnectionError when unable to connect."""
|
||||
config = MinioConfig(
|
||||
endpoint="invalid_uri",
|
||||
access_key="minioadmin",
|
||||
secret_key="minioadmin",
|
||||
bucket="test-bucket",
|
||||
)
|
||||
adapter = MinioAdapter(config=config)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.connect()
|
||||
assert not adapter.is_connected()
|
||||
|
||||
|
||||
def test_connect_raises_when_bucket_missing(
|
||||
raw_minio_client: Minio,
|
||||
minio_config: MinioConfig,
|
||||
) -> None:
|
||||
"""Test that connect fails when the configured bucket is missing."""
|
||||
bucket_name = minio_config.bucket
|
||||
raw_minio_client.remove_bucket(bucket_name)
|
||||
adapter = MinioAdapter(config=minio_config)
|
||||
|
||||
try:
|
||||
with pytest.raises(ConnectionError, match="does not exist"):
|
||||
adapter.connect()
|
||||
assert not adapter.is_connected()
|
||||
finally:
|
||||
raw_minio_client.make_bucket(bucket_name)
|
||||
|
||||
|
||||
def test_connect_creates_bucket_when_create_bucket_if_missing_enabled(
|
||||
raw_minio_client: Minio,
|
||||
minio_config: MinioConfig,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that connect can create the configured bucket when enabled."""
|
||||
bucket_name = minio_config.bucket
|
||||
raw_minio_client.remove_bucket(bucket_name)
|
||||
config = replace(minio_config, create_bucket_if_missing=True)
|
||||
adapter = MinioAdapter(config=config)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
adapter.connect()
|
||||
|
||||
assert "Creating bucket" in caplog.text
|
||||
assert bucket_name in caplog.text
|
||||
|
||||
|
||||
def test_should_log_error_on_exception_during_exit(
|
||||
minio_config: MinioConfig,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that the MinioAdapter logs an error if an exception occurs during __exit__."""
|
||||
try:
|
||||
with MinioAdapter(config=minio_config) as adapter:
|
||||
assert adapter.is_connected()
|
||||
raise ValueError("Simulated error")
|
||||
except ValueError:
|
||||
pass
|
||||
assert "Error while exiting context" in caplog.text
|
||||
|
||||
|
||||
def test_should_have_context_manager(minio_config: MinioConfig) -> None:
|
||||
"""Test that the MinioAdapter can be used as a context manager."""
|
||||
with MinioAdapter(config=minio_config) 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."""
|
||||
object_name, expected_data = data_in_minio
|
||||
received_data = minio_adapter.get(object_name)
|
||||
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."""
|
||||
received_data = minio_adapter.get("nonexistent_object")
|
||||
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."""
|
||||
invalid_object_names = ["", 123, None]
|
||||
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_config: MinioConfig,
|
||||
) -> None:
|
||||
"""Test that the MinioAdapter raises ConnectionError when getting while not connected."""
|
||||
adapter = MinioAdapter(config=minio_config)
|
||||
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."""
|
||||
mock_client = MagicMock(spec=Minio)
|
||||
mock_client.bucket_exists.return_value = True
|
||||
mock_client.get_object.side_effect = S3Error(
|
||||
MagicMock(spec=BaseHTTPResponse),
|
||||
"NoSuchKey",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
bucket_name="test-bucket",
|
||||
object_name="missing-object",
|
||||
)
|
||||
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
|
||||
object_name = "missing-object"
|
||||
with caplog.at_level("WARNING"):
|
||||
result = adapter.get(object_name)
|
||||
assert result is None
|
||||
assert "Object not found" in caplog.text
|
||||
assert object_name in caplog.text
|
||||
bucket_name = adapter._bucket_name
|
||||
assert bucket_name is not None
|
||||
assert bucket_name in caplog.text
|
||||
|
||||
|
||||
def test_should_reraise_s3error_other_than_no_such_key() -> None:
|
||||
"""Test that the MinioAdapter re-raises unhandled S3 errors."""
|
||||
mock_client = MagicMock(spec=Minio)
|
||||
mock_client.bucket_exists.return_value = True
|
||||
other_s3error = S3Error(
|
||||
MagicMock(spec=BaseHTTPResponse),
|
||||
"UnhandledError",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
bucket_name="test-bucket",
|
||||
object_name="missing-object",
|
||||
)
|
||||
mock_client.get_object.side_effect = other_s3error
|
||||
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
|
||||
with pytest.raises(S3Error) as exc_info:
|
||||
adapter.get("missing-object")
|
||||
assert exc_info.value.code == "UnhandledError"
|
||||
|
||||
|
||||
def test_should_reraise_general_exception() -> None:
|
||||
"""Test that the MinioAdapter re-raises general exceptions during get."""
|
||||
mock_client = MagicMock(spec=Minio)
|
||||
mock_client.bucket_exists.return_value = True
|
||||
general_exception = Exception("General failure")
|
||||
mock_client.get_object.side_effect = general_exception
|
||||
adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
|
||||
with pytest.raises(Exception, match="General failure"):
|
||||
adapter.get("missing-object")
|
||||
|
||||
|
||||
def test_should_put_data(
|
||||
data: BytesIO,
|
||||
minio_adapter: MinioAdapter,
|
||||
minio_config: MinioConfig,
|
||||
) -> None:
|
||||
"""Test that the MinioAdapter can put data into a bucket."""
|
||||
object_name = "new_test_object"
|
||||
received_data = minio_adapter.get(object_name)
|
||||
assert received_data is None
|
||||
minio_adapter.put(object_name, data)
|
||||
received_data = minio_adapter.get(object_name)
|
||||
assert received_data is not None
|
||||
assert same_data(data, received_data)
|
||||
minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr]
|
||||
|
||||
|
||||
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."""
|
||||
object_name, _ = data_in_minio
|
||||
new_data = BytesIO(random.randbytes(2**21))
|
||||
received_data = minio_adapter.get(object_name)
|
||||
assert received_data is not None
|
||||
assert not same_data(received_data, new_data)
|
||||
minio_adapter.put(object_name, new_data)
|
||||
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."""
|
||||
invalid_object_names = ["", 123, None]
|
||||
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."""
|
||||
object_name = "valid_object_name"
|
||||
invalid_data = ["not_bytesio", 123, None]
|
||||
for invalid in invalid_data:
|
||||
with pytest.raises(ValueError):
|
||||
minio_adapter.put(object_name, invalid) # type: ignore
|
||||
|
||||
|
||||
def test_should_raise_value_error_on_invalid_put_content_type(
|
||||
data: BytesIO,
|
||||
minio_adapter: MinioAdapter,
|
||||
) -> None:
|
||||
"""Test that the MinioAdapter raises ValueError when putting with an invalid content type."""
|
||||
object_name = "valid_object_name"
|
||||
invalid_content_types = ["", 123, None]
|
||||
for content_type in invalid_content_types:
|
||||
with pytest.raises(ValueError):
|
||||
minio_adapter.put(object_name, data, content_type) # type: ignore
|
||||
|
||||
|
||||
def test_should_raise_connection_error_on_put_when_not_connected(
|
||||
minio_config: MinioConfig,
|
||||
data: BytesIO,
|
||||
) -> None:
|
||||
"""Test that the MinioAdapter raises ConnectionError when putting while not connected."""
|
||||
adapter = MinioAdapter(config=minio_config)
|
||||
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."""
|
||||
object_name, _ = data_in_minio
|
||||
received_data = minio_adapter.get(object_name)
|
||||
assert received_data is not None
|
||||
minio_adapter.delete(object_name)
|
||||
assert minio_adapter.get(object_name) 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."""
|
||||
invalid_object_names = ["", 123, None]
|
||||
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_config: MinioConfig,
|
||||
) -> None:
|
||||
"""Test that the MinioAdapter raises ConnectionError when deleting while not connected."""
|
||||
adapter = MinioAdapter(config=minio_config)
|
||||
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."""
|
||||
object_name, _ = data_in_minio
|
||||
new_data = BytesIO(random.randbytes(2**21))
|
||||
new_data_name = "another_test_object"
|
||||
minio_adapter.put(new_data_name, new_data)
|
||||
objects = minio_adapter.list_objects()
|
||||
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."""
|
||||
object_name, _ = data_in_minio
|
||||
new_data = BytesIO(random.randbytes(2**21))
|
||||
new_data_name = "prefix_test_object"
|
||||
minio_adapter.put(new_data_name, new_data)
|
||||
prefix = "prefix_"
|
||||
objects = minio_adapter.list_objects(prefix)
|
||||
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."""
|
||||
invalid_prefixes = [123, None]
|
||||
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_config: MinioConfig,
|
||||
) -> None:
|
||||
"""Test that the MinioAdapter raises ConnectionError when listing while not connected."""
|
||||
adapter = MinioAdapter(config=minio_config)
|
||||
with pytest.raises(ConnectionError):
|
||||
adapter.list_objects()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-s", "-v", __file__])
|
||||
Reference in New Issue
Block a user