import asyncio from collections.abc import Callable import sys import types import app.config as app_config import pytest from app.config import Settings from app.repositories.cache.redis_cache import ( PriceCache, PriceCacheRepositoryError, _default_redis_client_factory, ) class InMemoryRedisClient: def __init__(self, *, clock: Callable[[], float] | None = None) -> None: self._clock = clock or (lambda: 0.0) self._storage: dict[str, tuple[str, float | None]] = {} self.last_set: tuple[str, str, int | None] | None = None self.delete_calls: list[str] = [] async def get(self, key: str) -> bytes | None: cached_entry = self._storage.get(key) if cached_entry is None: return None payload, expires_at = cached_entry if expires_at is not None and self._clock() >= expires_at: self._storage.pop(key, None) return None return payload.encode("utf-8") async def set(self, key: str, value: str, ex: int | None = None) -> bool: expires_at = None if ex is None else self._clock() + ex self._storage[key] = (value, expires_at) self.last_set = (key, value, ex) return True async def delete(self, key: str) -> int: existed = key in self._storage self._storage.pop(key, None) self.delete_calls.append(key) return int(existed) def serialized_value(self, key: str) -> str: return self._storage[key][0] class FailingGetRedisClient: async def get(self, key: str) -> bytes | None: # noqa: ARG002 raise RuntimeError("redis get failed") async def set(self, key: str, value: str, ex: int | None = None) -> bool: # noqa: ARG002 return True async def delete(self, key: str) -> int: # noqa: ARG002 return 1 class FailingSetRedisClient: async def get(self, key: str) -> bytes | None: # noqa: ARG002 return None async def set(self, key: str, value: str, ex: int | None = None) -> bool: # noqa: ARG002 raise RuntimeError("redis set failed") async def delete(self, key: str) -> int: # noqa: ARG002 return 1 def test_cache_miss_returns_none() -> None: cache = PriceCache(InMemoryRedisClient(), ttl_seconds=120) result = asyncio.run(cache.get("missing")) assert result is None def test_set_get_roundtrip_uses_deterministic_serialization() -> None: redis_client = InMemoryRedisClient() cache = PriceCache(redis_client, ttl_seconds=55) first_payload = {"b": 2, "a": {"d": [3, 1], "c": "x"}} second_payload = {"a": {"c": "x", "d": [3, 1]}, "b": 2} async def scenario() -> tuple[object, object]: await cache.set("first", first_payload) await cache.set("second", second_payload) return await cache.get("first"), await cache.get("second") first_result, second_result = asyncio.run(scenario()) assert first_result == {"a": {"c": "x", "d": [3, 1]}, "b": 2} assert second_result == {"a": {"c": "x", "d": [3, 1]}, "b": 2} assert redis_client.serialized_value("first") == redis_client.serialized_value("second") assert redis_client.last_set == ("second", '{"a":{"c":"x","d":[3,1]},"b":2}', 55) def test_invalidate_removes_cached_entry() -> None: redis_client = InMemoryRedisClient() cache = PriceCache(redis_client, ttl_seconds=300) async def scenario() -> object | None: await cache.set("quote", {"price": 1990}) await cache.invalidate("quote") return await cache.get("quote") result = asyncio.run(scenario()) assert result is None assert redis_client.delete_calls == ["quote"] def test_ttl_expiration_removes_stale_value() -> None: clock_state = {"now": 100.0} redis_client = InMemoryRedisClient(clock=lambda: clock_state["now"]) cache = PriceCache(redis_client, ttl_seconds=3) async def scenario() -> tuple[object | None, object | None]: await cache.set("quote", {"provider": "cdek"}) before_expiration = await cache.get("quote") clock_state["now"] = 103.0 after_expiration = await cache.get("quote") return before_expiration, after_expiration before_expiration, after_expiration = asyncio.run(scenario()) assert before_expiration == {"provider": "cdek"} assert after_expiration is None def test_set_accepts_explicit_ttl_override() -> None: clock_state = {"now": 10.0} redis_client = InMemoryRedisClient(clock=lambda: clock_state["now"]) cache = PriceCache(redis_client, ttl_seconds=100) async def scenario() -> tuple[object | None, object | None]: await cache.set("quote", {"provider": "cdek"}, ttl=2) before_expiration = await cache.get("quote") clock_state["now"] = 12.0 after_expiration = await cache.get("quote") return before_expiration, after_expiration before_expiration, after_expiration = asyncio.run(scenario()) assert before_expiration == {"provider": "cdek"} assert after_expiration is None assert redis_client.last_set == ("quote", '{"provider":"cdek"}', 2) def test_repository_yaml_config_sets_redis_connection_and_ttl( tmp_path, monkeypatch ) -> None: config_file = tmp_path / "config.yaml" config_file.write_text( """ controller: api_prefix: "/api/v1" request_id_header: "X-Request-ID" service: provider_timeout_seconds: 10.0 max_parallel_providers: 8 business_logic: weight_round_scale: 2 provider_price_multiplier: 1.0 repository: redis_dsn: "redis://redis.internal:6380/5" price_cache_ttl_seconds: 123 adapter: cdek_base_url: "https://api.cdek.ru/v2" cdek_client_id: "test-client-id" cdek_client_secret: "test-client-secret" cdek_retry_attempts: 2 cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" auth: terminal_key: "test-terminal-key" password: "test-password" observability: enabled: false service_name: "repository-test-service" otlp_endpoint: "http://collector:4317" otlp_insecure: true """.strip(), encoding="utf-8", ) monkeypatch.setattr(app_config, "_resolve_runtime_config_file", lambda: str(config_file)) settings = Settings() redis_client = InMemoryRedisClient() seen_dsns: list[str] = [] def client_factory(redis_dsn: str) -> InMemoryRedisClient: seen_dsns.append(redis_dsn) return redis_client cache = PriceCache.from_repository_config( settings.repository, client_factory=client_factory ) async def scenario() -> object | None: await cache.set("config-key", {"ok": True}) return await cache.get("config-key") result = asyncio.run(scenario()) assert seen_dsns == ["redis://redis.internal:6380/5"] assert redis_client.last_set == ("config-key", '{"ok":true}', 123) assert result == {"ok": True} def test_default_client_factory_uses_aioredis_from_url(monkeypatch) -> None: seen: dict[str, object] = {} expected_client = object() def fake_from_url(redis_dsn: str, *, encoding: str, decode_responses: bool) -> object: seen["redis_dsn"] = redis_dsn seen["encoding"] = encoding seen["decode_responses"] = decode_responses return expected_client fake_aioredis_module = types.SimpleNamespace(from_url=fake_from_url) monkeypatch.setitem(sys.modules, "aioredis", fake_aioredis_module) actual_client = _default_redis_client_factory("redis://cache.internal:6381/9") assert actual_client is expected_client assert seen == { "redis_dsn": "redis://cache.internal:6381/9", "encoding": "utf-8", "decode_responses": False, } def test_get_raises_deterministic_repository_error_on_redis_failure() -> None: cache = PriceCache(FailingGetRedisClient(), ttl_seconds=10) with pytest.raises(PriceCacheRepositoryError, match="Redis cache read failed."): asyncio.run(cache.get("key")) def test_set_raises_deterministic_repository_error_on_redis_failure() -> None: cache = PriceCache(FailingSetRedisClient(), ttl_seconds=10) with pytest.raises(PriceCacheRepositoryError, match="Redis cache write failed."): asyncio.run(cache.set("key", {"v": 1}))