"""限流计数后端契约测试(不连 Redis)。 锁定两条必须成立的语义: 1. 正常路径:窗口内计数递增,并把"剩余窗口秒数"作为 `Retry-After` 的来源返回; 键首次创建(`TTL` 为 -1)时补一次过期,避免出现永不过期的脏计数键。 2. **降级路径**:Redis 构造失败或命令失败一律返回 `None`(=无法判定),由调用方放行。 限流是保护措施,不能因为 Redis 故障把正常请求全部拒掉——这条如果回归,故障时 整个平台会 429 全灭。 """ from typing import Any from app.infrastructure.rate_limiter import RedisCounterBackend class FakePipeline: def __init__(self, client: "FakeRedis") -> None: self._client = client async def __aenter__(self) -> "FakePipeline": return self async def __aexit__(self, *exc: object) -> bool: return False def incr(self, key: str) -> "FakePipeline": self._client.incr_keys.append(key) return self def ttl(self, key: str) -> "FakePipeline": self._client.ttl_keys.append(key) return self async def execute(self) -> list[Any]: if self._client.fail: raise ConnectionError("redis unavailable") self._client.count += 1 return [self._client.count, self._client.ttl_value] class FakeRedis: def __init__(self, *, fail: bool = False, ttl_value: int = 42) -> None: self.fail = fail self.ttl_value = ttl_value self.count = 0 self.incr_keys: list[str] = [] self.ttl_keys: list[str] = [] self.expired: list[tuple[str, int]] = [] def pipeline(self, transaction: bool = False) -> FakePipeline: del transaction return FakePipeline(self) async def expire(self, key: str, seconds: int) -> bool: self.expired.append((key, seconds)) return True def backend(client: Any) -> RedisCounterBackend: return RedisCounterBackend( "redis://unused", connect_timeout_seconds=0.1, client_factory=lambda: client ) async def test_first_request_counts_one_and_reports_remaining_window() -> None: client = FakeRedis(ttl_value=42) result = await backend(client).increment("k", 60) assert result == (1, 42) assert client.incr_keys == ["k"] async def test_missing_ttl_is_repaired_with_window_expiry() -> None: """`TTL` 为 -1 说明键没有过期时间:必须补一次,否则计数永远不归零。""" client = FakeRedis(ttl_value=-1) result = await backend(client).increment("k", 60) assert result == (1, 60) assert client.expired == [("k", 60)] async def test_redis_command_failure_degrades_to_unknown() -> None: client = FakeRedis(fail=True) assert await backend(client).increment("k", 60) is None async def test_client_construction_failure_degrades_to_unknown() -> None: def exploding_factory() -> Any: raise RuntimeError("redis not installed") instance = RedisCounterBackend( "redis://unused", connect_timeout_seconds=0.1, client_factory=exploding_factory ) assert await instance.increment("k", 60) is None