"""Tests for the dedicated POST /estimate rate limiter (#b2c-antiabuse-2 п.4). Отдельный, куда более строгий лимит частоты specifically на дорогой публичный путь POST /estimate (`app.api.v1.trade_in._estimate_limiter`, settings.estimate_rate_limit/_window_s) — поверх общего RateLimitMiddleware (300 req/60с, app/main.py), который рассчитан на дешёвые запросы. Один вызов /estimate запускает цепочку внешних вызовов, суммарно занимающую десятки секунд (см. estimator._with_budget), поэтому burst нескольких параллельных вызовов от одного ключа нужно резать раньше, гораздо строже. Изолировано от account_quota (mock'ается no-op) и от estimate_quality (canned result, без реальной сети/DB) — цель проверить ИМЕННО срабатывание узкого rate-limit гейта, который стоит ПЕРВЫМ в хендлере (до квоты и до дорогой цепочки). `tests/conftest.py::_reset_estimate_rate_limiter` сбрасывает `_estimate_limiter` перед каждым тестом (иначе состояние утекало бы между файлами) — здесь мы поверх этого сброса ещё и сужаем лимит через autouse-фикстуру, чтобы не тестировать прод-значения (5/300с) напрямую (медленно/шумно). """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from app.api.v1 import trade_in as trade_in_module from app.core.db import get_db from app.core.ratelimit import SlidingWindowLimiter from app.schemas.trade_in import AggregatedEstimate def _canned_estimate() -> AggregatedEstimate: return AggregatedEstimate( estimate_id=uuid4(), median_price_rub=5_000_000, range_low_rub=4_500_000, range_high_rub=5_500_000, median_price_per_m2=100_000, confidence="medium", n_analogs=8, period_months=24, analogs=[], actual_deals=[], expires_at=datetime.now(tz=UTC) + timedelta(hours=24), ) @pytest.fixture() def app() -> FastAPI: """Минимальное приложение вокруг trade_in-роутера; DB не используется реально — account_quota мокается no-op в каждом тесте отдельно.""" application = FastAPI() application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") def _override_db(): yield None application.dependency_overrides[get_db] = _override_db return application @pytest.fixture(autouse=True) def _narrow_estimate_limiter(monkeypatch: pytest.MonkeyPatch) -> None: """Узкий лимитер (2 запроса / 60с) — тестируем СРАБАТЫВАНИЕ механизма, не прод-пороги (settings.estimate_rate_limit=5 / estimate_rate_limit_window_s=300 было бы медленно/шумно гонять напрямую в юнит-тесте).""" monkeypatch.setattr( trade_in_module, "_estimate_limiter", SlidingWindowLimiter(limit=2, window_s=60.0) ) def _post_estimate(client: TestClient, headers: dict[str, str] | None = None): return client.post( "/api/v1/trade-in/estimate", json={"address": "г. Екатеринбург, ул. Малышева, 1", "area_m2": 50.0, "rooms": 2}, headers=headers or {}, ) def test_estimate_rate_limit_fires_after_narrow_threshold(app: FastAPI) -> None: """3-й запрос той же анонимной корзины (лимит=2) → 429 с Retry-After и текстом ПРО ОЦЕНКУ (отличимо от общего RateLimitMiddleware "Слишком много запросов").""" client = TestClient(app, raise_server_exceptions=False) with ( patch("app.services.account_quota.check_and_raise"), patch("app.services.account_quota.increment", return_value=True), patch( "app.services.estimator.estimate_quality", new=AsyncMock(return_value=_canned_estimate()), ), ): for _ in range(2): resp = _post_estimate(client) assert resp.status_code == 200 blocked = _post_estimate(client) assert blocked.status_code == 429 assert "оценку" in blocked.json()["detail"] assert "Retry-After" in blocked.headers def test_estimate_rate_limit_per_key_isolation(app: FastAPI) -> None: """alice упирается в узкий лимит; bob (свой ключ) — не задет.""" client = TestClient(app, raise_server_exceptions=False) with ( patch("app.services.account_quota.check_and_raise"), patch("app.services.account_quota.increment", return_value=True), patch( "app.services.estimator.estimate_quality", new=AsyncMock(return_value=_canned_estimate()), ), ): alice = {"X-Authenticated-User": "alice"} for _ in range(2): assert _post_estimate(client, alice).status_code == 200 assert _post_estimate(client, alice).status_code == 429 bob = {"X-Authenticated-User": "bob"} assert _post_estimate(client, bob).status_code == 200 def test_estimate_rate_limit_applies_regardless_of_quota_role(app: FastAPI) -> None: """Rate limit — самая дешёвая проверка, стоит ПЕРВОЙ в хендлере (до account_quota). Даже quota-unlimited роль (admin/kopylov) упирается в него — burst-защита capacity сервера не зависит от business-роли.""" client = TestClient(app, raise_server_exceptions=False) admin_headers = {"X-Authenticated-User": "admin"} with ( patch("app.services.account_quota.check_and_raise") as mock_check, patch("app.services.account_quota.increment", return_value=True), patch( "app.services.estimator.estimate_quality", new=AsyncMock(return_value=_canned_estimate()), ), ): for _ in range(2): assert _post_estimate(client, admin_headers).status_code == 200 blocked = _post_estimate(client, admin_headers) assert blocked.status_code == 429 # account_quota.check_and_raise НЕ вызывается для 3-го запроса — rate limit # короткозамкнул обработку раньше, чем дело дошло до квоты. assert mock_check.call_count == 2