import asyncio from collections.abc import Callable from app.config import Settings from app.repositories.cache.redis_cache import PriceCache 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] 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( """ repository: redis_dsn: "redis://redis.internal:6380/5" price_cache_ttl_seconds: 123 """.strip(), encoding="utf-8", ) monkeypatch.setenv("G2S_CONFIG_FILE", 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}