diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..fb0658f --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,83 @@ +"""Integration tests configuration.""" + +import os +from typing import Generator +import pytest +import redis +import structlog +import logging + +from testcontainers.redis import RedisContainer + + +@pytest.fixture(scope="session", autouse=True) +def configure_logging() -> None: + """Configure logging for the test session.""" + # Configure structlog + structlog.configure( + processors=[ + structlog.processors.JSONRenderer(), + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + # Set up basic logging configuration + logging.basicConfig(level=logging.ERROR) + + +@pytest.fixture(scope="session") +def redis_container() -> Generator[str, None, None]: + """Set up a Redis container for testing and yield the Redis URI.""" + # Start container + container = RedisContainer( + image="redis/redis-stack:7.2.0-v0", + port=6379, + ).with_bind_ports( + container=6379, + host=6379, + ) + container.start() + # Set environment variable for Redis URI + redis_host = container.get_container_host_ip() + redis_port = container.get_exposed_port(6379) + redis_uri = f"redis://{redis_host}:{redis_port}" + + yield redis_uri + + # Stop container + container.stop() + + +@pytest.fixture(scope="session", autouse=True) +def set_environment_variables( + redis_container: str, +) -> Generator[dict[str, str], None, None]: + """Set environment variables needed for tests.""" + # Build environment variables dictionary + env_vars = {"REDIS_URI": redis_container} + # Set environment variables + for key, value in env_vars.items(): + os.environ[key] = value + + yield env_vars + + # Cleanup + for key in env_vars: + _ = os.environ.pop(key, default=None) + + +@pytest.fixture(scope="session") +def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]: + """Provide a raw Redis client connected to the test Redis container.""" + # Connect client + client = redis.Redis.from_url( + url=redis_container, + socket_connect_timeout=10, + ) + + yield client + + # Cleanup + client.flushall() + client.close() diff --git a/tests/integration/connection_aware_interface_test.py b/tests/integration/connection_aware_interface_test.py new file mode 100644 index 0000000..59cb06a --- /dev/null +++ b/tests/integration/connection_aware_interface_test.py @@ -0,0 +1,105 @@ +"""Integration 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 + + @property + def is_connected(self) -> bool: + return False + + with pytest.raises(TypeError): + _ = Incomplete() + + +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 + + @property + def is_connected(self) -> bool: + return False + + with pytest.raises(TypeError): + _ = Incomplete() + + +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() + + +def test_connect_raises_not_implemented_if_not_overwritten() -> None: + """Test that connect raises NotImplementedError if not implemented.""" + class Incomplete(ConnectionAwareInterface): + """A class that does not implement connect.""" + def connect(self) -> None: + super().connect() + + def disconnect(self) -> None: + pass + + @property + def is_connected(self) -> bool: + return False + + instance = Incomplete() + with pytest.raises(NotImplementedError): + instance.connect() + + +def test_disconnect_raises_not_implemented_if_not_overwritten() -> None: + """Test that disconnect raises NotImplementedError if not implemented.""" + class Incomplete(ConnectionAwareInterface): + """A class that does not implement disconnect.""" + def connect(self) -> None: + pass + + def disconnect(self) -> None: + super().disconnect() + + @property + def is_connected(self) -> bool: + return False + + instance = Incomplete() + with pytest.raises(NotImplementedError): + instance.disconnect() + + +def test_is_connected_raises_not_implemented_if_not_overwritten() -> None: + """Test that is_connected raises NotImplementedError if not implemented.""" + class Incomplete(ConnectionAwareInterface): + """A class that does not implement is_connected.""" + def connect(self) -> None: + pass + + def disconnect(self) -> None: + pass + + @property + def is_connected(self) -> bool: + return super().is_connected + + instance = Incomplete() + with pytest.raises(NotImplementedError): + _ = instance.is_connected diff --git a/tests/integration/context_aware_interface_test.py b/tests/integration/context_aware_interface_test.py new file mode 100644 index 0000000..2a6882d --- /dev/null +++ b/tests/integration/context_aware_interface_test.py @@ -0,0 +1,62 @@ +"""Integration tests for ContextAwareInterface.""" + +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() + + +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) -> ContextAwareInterface: + return self + + with pytest.raises(TypeError): + _ = Incomplete() + + +def test_enter_raises_not_implemented_if_not_overwritten() -> None: + """Test that __enter__ raises NotImplementedError if not implemented.""" + class Incomplete(ContextAwareInterface): + """A class that does not implement __enter__.""" + def __enter__(self) -> ContextAwareInterface: + super().__enter__() + + def __exit__( + self, exc_type: type | None, exc_val: object | None, exc_tb: object | None + ) -> None: + pass + + instance = Incomplete() + with pytest.raises(NotImplementedError): + instance.__enter__() + + +def test_exit_raises_not_implemented_if_not_overwritten() -> None: + """Test that __exit__ raises NotImplementedError if not implemented.""" + class Incomplete(ContextAwareInterface): + """A class that does not implement __exit__.""" + def __enter__(self) -> ContextAwareInterface: + return self + + def __exit__( + self, exc_type: type | None, exc_val: object | None, exc_tb: object | None + ) -> None: + super().__exit__(exc_type, exc_val, exc_tb) + + instance = Incomplete() + with pytest.raises(NotImplementedError): + instance.__exit__(None, None, None) diff --git a/tests/integration/redis_adapter_test.py b/tests/integration/redis_adapter_test.py new file mode 100644 index 0000000..8b2cda5 --- /dev/null +++ b/tests/integration/redis_adapter_test.py @@ -0,0 +1,340 @@ +# pylint: disable=protected-access +# The above line disables pylint's protected member access warnings for this file, +# allowing tests to access RedisAdapter's internal methods as needed for integration testing. +"""Integration tests for the RedisAdapter.""" + +from typing import Generator +import pytest +import redis +from redis.commands.json.path import Path as RedisPath +from python_repositories.adapters.redis_adapter import RedisAdapter + + +@pytest.fixture(scope="session") +def data() -> Generator[dict[str, str], None, None]: + """Provide a sample data dictionary for tests.""" + yield {"foo": "bar"} + + +@pytest.fixture(scope="function") +def data_in_redis( + raw_redis_client: redis.Redis, + data: dict[str, str], +) -> Generator[tuple[str, dict[str, str]], None, None]: + """Fixture to set up a known value in Redis before each test.""" + key = "test_key" + path = RedisPath.root_path() + raw_redis_client.json().set(key, path, data) + + yield key, data + + # Cleanup + raw_redis_client.delete(key) + + +@pytest.fixture(scope="module") +def redis_adapter(redis_container: str) -> Generator[RedisAdapter, None, None]: + """Fixture to provide a connected RedisAdapter instance.""" + adapter = RedisAdapter() + adapter.connect() + yield adapter + adapter.disconnect() + + +@pytest.fixture(scope="function", autouse=True) +def clear_redis(raw_redis_client: redis.Redis) -> None: + """Fixture to clear all Redis keys before each test.""" + # Clear all keys before each test + raw_redis_client.flushall() + + +def test_should_adhere_to_interface( + redis_container: str +) -> None: + """Test that the RedisAdapter adheres to the expected interface.""" + # Instantiation fails if interface not adhered to + _ = RedisAdapter() + + +def test_should_have_logger_when_instantiated( + redis_container: str +) -> None: + """Test that the RedisAdapter has a logger when instantiated.""" + adapter = RedisAdapter() + assert hasattr(adapter, "logger") + assert adapter.logger is not None + + +def test_should_not_be_connected_when_instantiated( + redis_container: str +) -> None: + """Test that the RedisAdapter is not connected when instantiated.""" + adapter = RedisAdapter() + assert adapter._client is None + assert not adapter.is_connected + + +def test_should_raise_connection_error_when_unable_to_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that the RedisAdapter raises ConnectionError when unable to connect.""" + # Arrange + monkeypatch.setenv("REDIS_URI", "redis://invalid:6379") + adapter = RedisAdapter() + # Act & Assert + with pytest.raises(ConnectionError): + adapter.connect() + assert adapter._client is None + assert not adapter.is_connected + + +def test_connect_raises_connection_error_when_unable_to_ping( + monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that the RedisAdapter raises ConnectionError when ping fails.""" + # Set an invalid URI + monkeypatch.setenv("REDIS_URI", "redis://invalid:6379") + + # Monkeypatch redis.Redis.from_url to return a mock client + class MockRedis: + """A mock Redis client that simulates a failed ping.""" + def ping(self) -> bool: + """Simulate a failed ping.""" + return False # Simulate failed ping + + monkeypatch.setattr("redis.Redis.from_url", lambda *a, **kw: MockRedis()) + + adapter = RedisAdapter() + with pytest.raises(ConnectionError, match="Could not connect to Redis"): + adapter.connect() + + +def test_should_connect_and_disconnect( + redis_container: str, +) -> None: + """Test that the RedisAdapter can connect and disconnect.""" + # Connect + adapter = RedisAdapter() + adapter.connect() + # Assert connected + assert adapter._client is not None + assert adapter.is_connected + # Disconnect + adapter.disconnect() + # Assert disconnected + assert adapter._client is None + assert not adapter.is_connected + + +def test_should_log_error_on_exception_during_exit( + redis_container: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the RedisAdapter logs an error when an exception occurs during context exit.""" + try: + with RedisAdapter() 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( + redis_container: str +) -> None: + """Test that the RedisAdapter can be used as a context manager.""" + with RedisAdapter() as adapter: + assert adapter._client is not None + assert adapter._client is None + + +def test_should_get_value( + data_in_redis: tuple[str, dict[str, str]], + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter can get a value.""" + # Arrange + key, data = data_in_redis + # Act + value = redis_adapter._get(key) + # Assert + assert value == data + + +def test_should_get_none_for_missing_key( + redis_adapter: RedisAdapter, +) -> None: + """Test that getting a non-existent key returns None.""" + # Act + value = redis_adapter._get("nonexistent_key") + # Assert + assert value is None + + +def test_should_raise_value_error_on_invalid_get_key( + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter raises ValueError when getting with an invalid key.""" + # Arrange + invalid_keys = ["", 123, None] + # Act & Assert + for key in invalid_keys: + with pytest.raises(ValueError): + redis_adapter._get(key) + + +def test_should_raise_connection_error_on_get_when_not_connected( + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter raises ConnectionError when getting while not connected.""" + # Arrange + adapter = RedisAdapter() + # Act & Assert + with pytest.raises(ConnectionError): + adapter._get("some_key") + + +def test_should_set_value( + data: dict[str, str], + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter can set a value.""" + # Arrange + key = "test_key" + # Act + redis_adapter._set(key, data) + # Assert + assert redis_adapter._get(key) == data + + +def test_should_update_value( + data_in_redis: tuple[str, dict[str, str]], + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter can update an existing value.""" + # Arrange + key, _ = data_in_redis + new_data = {"new_key": "new_value"} + # Act + redis_adapter._set(key, new_data) + # Assert + assert redis_adapter._get(key) == new_data + + +def test_should_raise_value_error_on_invalid_set_key( + redis_adapter: RedisAdapter, + data: dict[str, str], +) -> None: + """Test that the RedisAdapter raises ValueError when setting with an invalid key.""" + # Arrange + invalid_keys = ["", 123, None] + # Act & Assert + for key in invalid_keys: + with pytest.raises(ValueError): + redis_adapter._set(key, data) + + +def test_should_raise_value_error_on_invalid_set_data( + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter raises ValueError when setting with invalid data.""" + # Arrange + key = "test_key" + invalid_data = ["", 123, None, [], {}] + # Act & Assert + for data in invalid_data: + with pytest.raises(ValueError): + redis_adapter._set(key, data) + + +def test_should_raise_connection_error_on_set_when_not_connected( + redis_adapter: RedisAdapter, + data: dict[str, str], +) -> None: + """Test that the RedisAdapter raises ConnectionError when setting while not connected.""" + # Arrange + adapter = RedisAdapter() + key = "test_key" + # Act & Assert + with pytest.raises(ConnectionError): + adapter._set(key, data) + + +def test_should_delete_key( + data_in_redis: tuple[str, dict[str, str]], + redis_adapter: RedisAdapter, +) -> None: + """Test that deleting a key removes it from Redis.""" + # Arrange + key, _ = data_in_redis + # Act + redis_adapter._delete(key) + # Assert + assert redis_adapter._get(key) is None + + +def test_should_raise_value_error_on_invalid_delete_key( + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter raises ValueError when deleting with an invalid key.""" + # Arrange + invalid_keys = ["", 123, None] + # Act & Assert + for key in invalid_keys: + with pytest.raises(ValueError): + redis_adapter._delete(key) + + +def test_should_raise_connection_error_on_delete_when_not_connected( + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter raises ConnectionError when deleting while not connected.""" + # Arrange + adapter = RedisAdapter() + # Act & Assert + with pytest.raises(ConnectionError): + adapter._delete("some_key") + + +def test_should_list_keys( + redis_adapter: RedisAdapter, +) -> None: + """Test listing keys matching a pattern returns correct keys.""" + # Arrange + redis_adapter._set("key1", {"a": 1}) + redis_adapter._set("key2", {"b": 2}) + # Act + keys = redis_adapter._list_keys("key*") + # Assert + assert set(keys) == {"key1", "key2"} + + +def test_should_raise_value_error_on_invalid_list_keys_pattern( + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter raises ValueError when listing keys with an invalid pattern.""" + # Arrange + invalid_patterns = ["", 123, None] + # Act & Assert + for pattern in invalid_patterns: + with pytest.raises(ValueError): + redis_adapter._list_keys(pattern) + + +def test_should_raise_connection_error_on_list_keys_when_not_connected( + redis_adapter: RedisAdapter, +) -> None: + """Test that the RedisAdapter raises ConnectionError when listing keys while not connected.""" + # Arrange + adapter = RedisAdapter() + # Act & Assert + with pytest.raises(ConnectionError): + adapter._list_keys("some_pattern") + + +# allows local debugging by running file as script +if __name__ == "__main__": + pytest.main(["-s", "-v", __file__])