"""Unit tests for MinioAdapter instantiation and injection.""" from __future__ import annotations from dataclasses import replace from unittest.mock import MagicMock import pytest from minio import Minio, S3Error from urllib3.response import BaseHTTPResponse from python_repositories.adapters.minio_adapter import MinioAdapter from python_repositories.interfaces import ObjectRepositoryInterface from tests.conftest import TEST_MINIO_CONFIG def test_should_adhere_to_interface() -> None: assert issubclass(MinioAdapter, ObjectRepositoryInterface) _ = MinioAdapter(config=TEST_MINIO_CONFIG) def test_should_have_logger_when_instantiated() -> None: adapter = MinioAdapter(config=TEST_MINIO_CONFIG) assert hasattr(adapter, "logger") assert adapter.logger is not None def test_should_not_be_connected_when_instantiated() -> None: adapter = MinioAdapter(config=TEST_MINIO_CONFIG) assert not adapter.is_connected() def test_constructs_with_injected_config_without_env( monkeypatch: pytest.MonkeyPatch, ) -> None: for var in ( "MINIO_ENDPOINT", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY", "MINIO_BUCKET", ): monkeypatch.delenv(var, raising=False) adapter = MinioAdapter(config=TEST_MINIO_CONFIG) assert adapter._config == TEST_MINIO_CONFIG def test_injected_client_sets_bucket_name() -> None: mock_client = MagicMock(spec=Minio) adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) assert adapter._bucket_name == "test-bucket" assert adapter._client is mock_client def test_raises_when_client_provided_without_config() -> None: mock_client = MagicMock(spec=Minio) with pytest.raises(ValueError, match="config is required"): MinioAdapter(client=mock_client) def test_connect_with_injected_client_succeeds() -> None: mock_client = MagicMock(spec=Minio) adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) adapter.connect() mock_client.list_buckets.assert_called_once() def test_connect_with_injected_client_raises_on_failure() -> None: mock_client = MagicMock(spec=Minio) mock_client.list_buckets.side_effect = Exception("connection lost") adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) with pytest.raises(ConnectionError, match="Could not connect to Minio"): adapter.connect() def test_connect_disconnects_before_reconnect( monkeypatch: pytest.MonkeyPatch, ) -> None: stale_client = MagicMock(spec=Minio) new_client = MagicMock(spec=Minio) adapter = MinioAdapter(config=TEST_MINIO_CONFIG) adapter._client = stale_client adapter._bucket_name = None monkeypatch.setattr( "python_repositories.adapters.minio_adapter.minio.Minio", lambda *args, **kwargs: new_client, ) adapter.connect() assert adapter._client is new_client new_client.list_buckets.assert_called_once() def test_connect_raises_when_bucket_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: mock_client = MagicMock(spec=Minio) mock_client.bucket_exists.return_value = False monkeypatch.setattr( "python_repositories.adapters.minio_adapter.minio.Minio", lambda *args, **kwargs: mock_client, ) adapter = MinioAdapter(config=TEST_MINIO_CONFIG) with pytest.raises(ConnectionError, match="does not exist"): adapter.connect() mock_client.make_bucket.assert_not_called() def test_connect_creates_bucket_when_create_bucket_if_missing_enabled( monkeypatch: pytest.MonkeyPatch, ) -> None: mock_client = MagicMock(spec=Minio) mock_client.bucket_exists.return_value = False config = replace(TEST_MINIO_CONFIG, create_bucket_if_missing=True) monkeypatch.setattr( "python_repositories.adapters.minio_adapter.minio.Minio", lambda *args, **kwargs: mock_client, ) adapter = MinioAdapter(config=config) adapter.connect() mock_client.bucket_exists.assert_called_once_with(config.bucket) mock_client.make_bucket.assert_called_once_with(config.bucket) def test_get_closes_response_on_success() -> None: """get() must close and release the get_object HTTP response.""" mock_client = MagicMock(spec=Minio) mock_client.bucket_exists.return_value = True mock_response = MagicMock() mock_response.read.side_effect = [b"data", b""] mock_client.get_object.return_value = mock_response adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) result = adapter.get("some-object") assert result is not None assert result.read() == b"data" mock_response.close.assert_called_once() mock_response.release_conn.assert_called_once() def test_get_closes_response_when_read_fails() -> None: """get() must close and release the response even if read() raises.""" mock_client = MagicMock(spec=Minio) mock_client.bucket_exists.return_value = True mock_response = MagicMock() mock_response.read.side_effect = OSError("connection reset") mock_client.get_object.return_value = mock_response adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) with pytest.raises(OSError, match="connection reset"): adapter.get("some-object") mock_response.close.assert_called_once() mock_response.release_conn.assert_called_once() def test_get_returns_none_for_no_such_key() -> None: """get() returns None when the object does not exist.""" 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) result = adapter.get("missing-object") assert result is None def test_get_reraises_other_s3_errors() -> None: """get() re-raises S3 errors other than NoSuchKey.""" mock_client = MagicMock(spec=Minio) mock_client.bucket_exists.return_value = True other_s3error = S3Error( MagicMock(spec=BaseHTTPResponse), "AccessDenied", "", "", "", "", bucket_name="test-bucket", object_name="some-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("some-object") assert exc_info.value.code == "AccessDenied" def test_get_reraises_general_exception_from_get_object() -> None: """get() re-raises unexpected exceptions from get_object.""" mock_client = MagicMock(spec=Minio) mock_client.bucket_exists.return_value = True mock_client.get_object.side_effect = Exception("General failure") adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) with pytest.raises(Exception, match="General failure"): adapter.get("some-object")