Add config and client injection with test reorganization.

Introduce typed config objects, optional adapter injection, and .env loading to simplify testing while preserving env-based defaults for production usage.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Brian Bjarke Jensen
2026-07-06 20:44:08 +02:00
co-authored by Cursor
parent 366831ac52
commit 7991dabdbc
28 changed files with 645 additions and 377 deletions
@@ -1,4 +1,4 @@
"""Tests for optional dependency import behavior."""
"""Unit tests for lazy adapter loading in adapters subpackage."""
from __future__ import annotations
+27
View File
@@ -0,0 +1,27 @@
"""Unit test fixtures for mocked adapters."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
import redis
from minio import Minio
from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.adapters.redis_adapter import RedisAdapter
from tests.conftest import TEST_MINIO_CONFIG, TEST_REDIS_CONFIG
@pytest.fixture
def redis_adapter() -> RedisAdapter:
"""Provide a RedisAdapter with an injected mock client."""
mock_client = MagicMock(spec=redis.Redis)
return RedisAdapter(config=TEST_REDIS_CONFIG, client=mock_client)
@pytest.fixture
def minio_adapter() -> MinioAdapter:
"""Provide a MinioAdapter with an injected mock client."""
mock_client = MagicMock(spec=Minio)
return MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
@@ -1,39 +1,25 @@
"""Tests for TTL-cached connection health checks on adapters."""
"""Unit tests for TTL-cached connection health checks on ConnectionAwareAdapter."""
from __future__ import annotations
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
import redis
from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.adapters.redis_adapter import RedisAdapter
@pytest.fixture
def redis_adapter(monkeypatch: pytest.MonkeyPatch) -> RedisAdapter:
monkeypatch.setenv("REDIS_URI", "redis://localhost:6379")
return RedisAdapter()
@pytest.fixture
def minio_adapter(monkeypatch: pytest.MonkeyPatch) -> MinioAdapter:
monkeypatch.setenv("MINIO_ENDPOINT", "localhost:9000")
monkeypatch.setenv("MINIO_ACCESS_KEY", "minioadmin")
monkeypatch.setenv("MINIO_SECRET_KEY", "minioadmin")
monkeypatch.setenv("MINIO_BUCKET", "test-bucket")
return MinioAdapter()
from tests.conftest import TEST_MINIO_CONFIG, TEST_REDIS_CONFIG
class TestRedisConnectionHealth:
def test_not_connected_when_no_client(self, redis_adapter: RedisAdapter) -> None:
assert not redis_adapter.is_connected()
def test_not_connected_when_no_client(self) -> None:
adapter = RedisAdapter(config=TEST_REDIS_CONFIG)
assert not adapter.is_connected()
def test_connected_when_probe_succeeds(self, redis_adapter: RedisAdapter) -> None:
mock_client = MagicMock(spec=redis.Redis)
mock_client = cast(MagicMock, redis_adapter._client)
mock_client.ping.return_value = True
redis_adapter._client = mock_client
assert redis_adapter.is_connected()
mock_client.ping.assert_called_once()
@@ -41,16 +27,15 @@ class TestRedisConnectionHealth:
def test_stale_connection_when_probe_fails(
self, redis_adapter: RedisAdapter
) -> None:
mock_client = MagicMock(spec=redis.Redis)
mock_client.ping.side_effect = redis.ConnectionError("connection lost")
redis_adapter._client = mock_client
cast(MagicMock, redis_adapter._client).ping.side_effect = redis.ConnectionError(
"connection lost"
)
assert not redis_adapter.is_connected()
def test_cache_hit_avoids_second_probe(self, redis_adapter: RedisAdapter) -> None:
mock_client = MagicMock(spec=redis.Redis)
mock_client = cast(MagicMock, redis_adapter._client)
mock_client.ping.return_value = True
redis_adapter._client = mock_client
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
@@ -62,9 +47,8 @@ class TestRedisConnectionHealth:
mock_client.ping.assert_called_once()
def test_cache_miss_runs_probe_again(self, redis_adapter: RedisAdapter) -> None:
mock_client = MagicMock(spec=redis.Redis)
mock_client = cast(MagicMock, redis_adapter._client)
mock_client.ping.return_value = True
redis_adapter._client = mock_client
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
@@ -76,9 +60,8 @@ class TestRedisConnectionHealth:
assert mock_client.ping.call_count == 2
def test_disconnect_clears_cache(self, redis_adapter: RedisAdapter) -> None:
mock_client = MagicMock(spec=redis.Redis)
mock_client = cast(MagicMock, redis_adapter._client)
mock_client.ping.return_value = True
redis_adapter._client = mock_client
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
@@ -87,34 +70,35 @@ class TestRedisConnectionHealth:
assert redis_adapter.is_connected()
redis_adapter.disconnect()
redis_adapter._client = mock_client
reinjected = RedisAdapter(
config=redis_adapter._config,
client=mock_client,
)
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
return_value=100.0,
):
assert redis_adapter.is_connected()
assert reinjected.is_connected()
assert mock_client.ping.call_count == 2
class TestMinioConnectionHealth:
def test_not_connected_when_no_client(self, minio_adapter: MinioAdapter) -> None:
assert not minio_adapter.is_connected()
def test_not_connected_when_no_client(self) -> None:
adapter = MinioAdapter(config=TEST_MINIO_CONFIG)
assert not adapter.is_connected()
def test_not_connected_when_bucket_name_missing(
self, minio_adapter: MinioAdapter
) -> None:
minio_adapter._client = MagicMock()
minio_adapter._bucket_name = None
assert not minio_adapter.is_connected()
def test_connected_when_probe_succeeds(self, minio_adapter: MinioAdapter) -> None:
mock_client = MagicMock()
mock_client = cast(MagicMock, minio_adapter._client)
mock_client.bucket_exists.return_value = True
minio_adapter._client = mock_client
minio_adapter._bucket_name = "test-bucket"
assert minio_adapter.is_connected()
mock_client.bucket_exists.assert_called_once_with("test-bucket")
@@ -122,18 +106,15 @@ class TestMinioConnectionHealth:
def test_stale_connection_when_probe_fails(
self, minio_adapter: MinioAdapter
) -> None:
mock_client = MagicMock()
mock_client.bucket_exists.side_effect = Exception("connection lost")
minio_adapter._client = mock_client
minio_adapter._bucket_name = "test-bucket"
cast(MagicMock, minio_adapter._client).bucket_exists.side_effect = Exception(
"connection lost"
)
assert not minio_adapter.is_connected()
def test_cache_hit_avoids_second_probe(self, minio_adapter: MinioAdapter) -> None:
mock_client = MagicMock()
mock_client = cast(MagicMock, minio_adapter._client)
mock_client.bucket_exists.return_value = True
minio_adapter._client = mock_client
minio_adapter._bucket_name = "test-bucket"
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
@@ -145,10 +126,8 @@ class TestMinioConnectionHealth:
mock_client.bucket_exists.assert_called_once()
def test_cache_miss_runs_probe_again(self, minio_adapter: MinioAdapter) -> None:
mock_client = MagicMock()
mock_client = cast(MagicMock, minio_adapter._client)
mock_client.bucket_exists.return_value = True
minio_adapter._client = mock_client
minio_adapter._bucket_name = "test-bucket"
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
@@ -160,10 +139,8 @@ class TestMinioConnectionHealth:
assert mock_client.bucket_exists.call_count == 2
def test_disconnect_clears_cache(self, minio_adapter: MinioAdapter) -> None:
mock_client = MagicMock()
mock_client = cast(MagicMock, minio_adapter._client)
mock_client.bucket_exists.return_value = True
minio_adapter._client = mock_client
minio_adapter._bucket_name = "test-bucket"
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
@@ -172,13 +149,15 @@ class TestMinioConnectionHealth:
assert minio_adapter.is_connected()
minio_adapter.disconnect()
minio_adapter._client = mock_client
minio_adapter._bucket_name = "test-bucket"
reinjected = MinioAdapter(
config=minio_adapter._config,
client=mock_client,
)
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
return_value=100.0,
):
assert minio_adapter.is_connected()
assert reinjected.is_connected()
assert mock_client.bucket_exists.call_count == 2
@@ -0,0 +1,54 @@
"""Unit tests for ConnectionAwareInterface."""
import pytest
from python_repositories.interfaces.connection_aware_interface import (
ConnectionAwareInterface,
)
def test_instantiation_fails_when_connect_not_implemented() -> None:
"""Test that instantiation fails if connect is not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement connect."""
def disconnect(self) -> None:
pass
def is_connected(self) -> bool:
return False
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_disconnect_not_implemented() -> None:
"""Test that instantiation fails if disconnect is not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement disconnect."""
def connect(self) -> None:
pass
def is_connected(self) -> bool:
return False
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_is_connected_not_implemented() -> None:
"""Test that instantiation fails if is_connected is not implemented."""
class Incomplete(ConnectionAwareInterface):
"""A class that does not implement is_connected."""
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
@@ -0,0 +1,34 @@
"""Unit tests for ContextAwareInterface."""
from __future__ import annotations
import pytest
from python_repositories.interfaces.context_aware_interface import ContextAwareInterface
def test_instantiation_fails_when_enter_not_implemented() -> None:
"""Test that instantiation fails if __enter__ is not implemented."""
class Incomplete(ContextAwareInterface):
"""A class that does not implement __enter__."""
def __exit__(
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
) -> None:
pass
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_exit_not_implemented() -> None:
"""Test that instantiation fails if __exit__ is not implemented."""
class Incomplete(ContextAwareInterface):
"""A class that does not implement __exit__."""
def __enter__(self) -> Incomplete:
return self
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
+26
View File
@@ -0,0 +1,26 @@
"""Unit tests for dotenv_loader."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from python_repositories.config.dotenv_loader import load_dotenv
def test_load_dotenv_loads_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
env_file = tmp_path / ".env"
env_file.write_text("DOTENV_TEST_VAR=loaded_value\n")
monkeypatch.delenv("DOTENV_TEST_VAR", raising=False)
loaded = load_dotenv(env_file)
assert loaded is True
assert os.getenv("DOTENV_TEST_VAR") == "loaded_value"
def test_load_dotenv_returns_false_for_missing_file(tmp_path: Path) -> None:
missing = tmp_path / "missing.env"
assert load_dotenv(missing) is False
@@ -0,0 +1,82 @@
"""Unit tests for JsonRepositoryInterface."""
import pytest
from python_repositories.interfaces.json_repository_interface import (
JsonRepositoryInterface,
)
def test_instantiation_fails_when_get_not_implemented() -> None:
"""Test that instantiation fails if get is not implemented."""
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement get."""
def set(self, key: str, data: dict) -> None:
pass
def delete(self, key: str) -> None:
pass
def list_keys(self, pattern: str) -> list[str]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_set_not_implemented() -> None:
"""Test that instantiation fails if set is not implemented."""
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement set."""
def get(self, key: str) -> dict | None:
return None
def delete(self, key: str) -> None:
pass
def list_keys(self, pattern: str) -> list[str]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_delete_not_implemented() -> None:
"""Test that instantiation fails if delete is not implemented."""
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement delete."""
def get(self, key: str) -> dict | None:
return None
def set(self, key: str, data: dict) -> None:
pass
def list_keys(self, pattern: str) -> list[str]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_list_keys_not_implemented() -> None:
"""Test that instantiation fails if list_keys is not implemented."""
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement list_keys."""
def get(self, key: str) -> dict | None:
return None
def set(self, key: str, data: dict) -> None:
pass
def delete(self, key: str) -> None:
pass
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
+55
View File
@@ -0,0 +1,55 @@
"""Unit tests for MinioAdapter instantiation and injection."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from minio import Minio
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)
+31
View File
@@ -0,0 +1,31 @@
"""Unit tests for MinioConfig."""
from __future__ import annotations
import pytest
from python_repositories.config import MinioConfig
def test_from_env_loads_all_fields(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MINIO_ENDPOINT", "localhost:9000")
monkeypatch.setenv("MINIO_ACCESS_KEY", "access")
monkeypatch.setenv("MINIO_SECRET_KEY", "secret")
monkeypatch.setenv("MINIO_BUCKET", "my-bucket")
config = MinioConfig.from_env(use_dotenv=False)
assert config.endpoint == "localhost:9000"
assert config.access_key == "access"
assert config.secret_key == "secret"
assert config.bucket == "my-bucket"
assert config.secure is False
def test_from_env_raises_when_endpoint_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("MINIO_ENDPOINT", raising=False)
monkeypatch.setenv("MINIO_ACCESS_KEY", "access")
monkeypatch.setenv("MINIO_SECRET_KEY", "secret")
monkeypatch.setenv("MINIO_BUCKET", "my-bucket")
with pytest.raises(Exception):
MinioConfig.from_env(use_dotenv=False)
@@ -0,0 +1,99 @@
"""Unit tests for ObjectRepositoryInterface."""
from io import BytesIO
import pytest
from python_repositories.interfaces.object_repository_interface import (
ObjectRepositoryInterface,
)
def test_instantiation_fails_when_get_not_implemented() -> None:
"""Test that instantiation fails if get is not implemented."""
class Incomplete(ObjectRepositoryInterface):
"""A class that does not implement get."""
def put(
self,
object_name: str,
data: BytesIO,
content_type: str = "application/octet-stream",
) -> None:
pass
def delete(self, object_name: str) -> None:
pass
def list_objects(self, prefix: str = "") -> list[str]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_put_not_implemented() -> None:
"""Test that instantiation fails if put is not implemented."""
class Incomplete(ObjectRepositoryInterface):
"""A class that does not implement put."""
def get(self, object_name: str) -> BytesIO | None:
return None
def delete(self, object_name: str) -> None:
pass
def list_objects(self, prefix: str = "") -> list[str]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_delete_not_implemented() -> None:
"""Test that instantiation fails if delete is not implemented."""
class Incomplete(ObjectRepositoryInterface):
"""A class that does not implement delete."""
def get(self, object_name: str) -> BytesIO | None:
return None
def put(
self,
object_name: str,
data: BytesIO,
content_type: str = "application/octet-stream",
) -> None:
pass
def list_objects(self, prefix: str = "") -> list[str]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_list_objects_not_implemented() -> None:
"""Test that instantiation fails if list_objects is not implemented."""
class Incomplete(ObjectRepositoryInterface):
"""A class that does not implement list_objects."""
def get(self, object_name: str) -> BytesIO | None:
return None
def put(
self,
object_name: str,
data: BytesIO,
content_type: str = "application/octet-stream",
) -> None:
pass
def delete(self, object_name: str) -> None:
pass
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
+71
View File
@@ -0,0 +1,71 @@
"""Unit tests for RedisAdapter instantiation and injection."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
import redis
from python_repositories.adapters.redis_adapter import RedisAdapter
from python_repositories.interfaces import JsonRepositoryInterface
from tests.conftest import TEST_REDIS_CONFIG
def test_should_adhere_to_interface() -> None:
assert issubclass(RedisAdapter, JsonRepositoryInterface)
_ = RedisAdapter(config=TEST_REDIS_CONFIG)
def test_should_have_logger_when_instantiated() -> None:
adapter = RedisAdapter(config=TEST_REDIS_CONFIG)
assert hasattr(adapter, "logger")
assert adapter.logger is not None
def test_should_not_be_connected_when_instantiated() -> None:
adapter = RedisAdapter(config=TEST_REDIS_CONFIG)
assert adapter._client is None
assert not adapter.is_connected()
def test_constructs_with_injected_config_without_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("REDIS_URI", raising=False)
adapter = RedisAdapter(config=TEST_REDIS_CONFIG)
assert adapter._config == TEST_REDIS_CONFIG
def test_constructs_with_injected_client_without_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("REDIS_URI", raising=False)
mock_client = MagicMock(spec=redis.Redis)
adapter = RedisAdapter(config=TEST_REDIS_CONFIG, client=mock_client)
assert adapter._client is mock_client
assert adapter._client_injected is True
def test_raises_when_client_provided_without_config() -> None:
mock_client = MagicMock(spec=redis.Redis)
with pytest.raises(ValueError, match="config is required"):
RedisAdapter(client=mock_client)
def test_disconnect_does_not_close_injected_client() -> None:
mock_client = MagicMock(spec=redis.Redis)
adapter = RedisAdapter(config=TEST_REDIS_CONFIG, client=mock_client)
adapter.disconnect()
mock_client.close.assert_not_called()
assert adapter._client is None
class CustomEnvRedisAdapter(RedisAdapter):
uri_env_var_name = "CUSTOM_REDIS_URI"
def test_subclass_custom_env_var_name(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CUSTOM_REDIS_URI", "redis://custom:6379")
adapter = CustomEnvRedisAdapter()
assert adapter._config.uri == "redis://custom:6379"
+27
View File
@@ -0,0 +1,27 @@
"""Unit tests for RedisConfig."""
from __future__ import annotations
import pytest
from python_repositories.config import RedisConfig
def test_from_env_loads_uri(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("REDIS_URI", "redis://example:6379")
config = RedisConfig.from_env(use_dotenv=False)
assert config.uri == "redis://example:6379"
def test_from_env_raises_when_uri_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("REDIS_URI", raising=False)
with pytest.raises(Exception):
RedisConfig.from_env(use_dotenv=False)
def test_from_env_respects_custom_env_var_name(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("CUSTOM_REDIS_URI", "redis://custom:6379")
config = RedisConfig.from_env("CUSTOM_REDIS_URI", use_dotenv=False)
assert config.uri == "redis://custom:6379"