261 lines
8.3 KiB
Python
261 lines
8.3 KiB
Python
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"
|
|
notification_url: "https://merchant.test/tbank/notification"
|
|
success_url: "https://merchant.test/payment/success"
|
|
auth:
|
|
terminal_key: "test-terminal-key"
|
|
password: "test-password"
|
|
|
|
postgres:
|
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/repository_test"
|
|
|
|
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}))
|