"""真实 Redis 上的限流验证(文档 §3.5 的 429 / §3.6 的 `RATE_LIMITED`)。 替身测试能证明闸门逻辑,但证明不了"Redis 计数路径真的在跑":`INCR`/`TTL` 的写法、 pipeline 用法、键的过期都可能只在真实 Redis 上暴露。本用例用**真实后端**(不注入 替身)走一遍 HTTP: - Redis 不可用时 `skip`(此时按设计降级放行,429 不会发生,测下去只会假失败); - 计数键前缀带随机串,避免与并行进程的计数互相污染; - 阈值临时设为 1,第二个请求必须 429 且带 `Retry-After`。 不连数据库:会话消息查询对不存在的会话返回空列表。 """ from uuid import uuid4 import httpx import pytest from app.api.dependencies.auth import build_request_context from app.core.config import get_settings from app.core.contracts import RequestContext from app.main import create_app pytestmark = pytest.mark.integration PATH = f"/api/v1/conversations/rate-limit-{uuid4()}/messages" async def redis_is_available() -> bool: try: from redis.asyncio import Redis settings = get_settings() client = Redis.from_url(settings.redis_url, socket_connect_timeout=settings.redis_connect_timeout_seconds) try: return bool(await client.ping()) finally: await client.aclose() except Exception: return False async def test_real_redis_counter_returns_429_with_retry_after( monkeypatch: pytest.MonkeyPatch, ) -> None: if not await redis_is_available(): pytest.skip("Redis 不可用:按设计限流降级放行,无法在此环境验证 429") settings = get_settings() # 前缀必须在 lambda 外算好:`get_settings()` 每次请求都会被调用,写在 lambda 里 # 会让每个请求落到不同的计数键上,限流永远不触发(这正是本用例要防住的错误)。 prefix = f"test:rate_limit:{uuid4().hex}" monkeypatch.setattr( "app.api.dependencies.rate_limit.get_settings", lambda: settings.model_copy(update={ "rate_limit_enabled": True, "rate_limit_window_seconds": 60, "rate_limit_max_requests": 1, "rate_limit_key_prefix": prefix, }), ) context = RequestContext(user_id=str(uuid4().int % 10**6), trace_id="trace-redis-limit", roles=("customer",), permissions=()) application = create_app() application.dependency_overrides[build_request_context] = lambda: context async with httpx.AsyncClient(transport=httpx.ASGITransport(app=application), base_url="http://test") as client: first = await client.get(PATH) second = await client.get(PATH) assert first.status_code == 200, first.text assert second.status_code == 429, second.text assert second.json()["error"]["code"] == "RATE_LIMITED" assert second.json()["error"]["retryable"] is True assert int(second.headers["Retry-After"]) >= 1