gendesign/tradein-mvp/backend/tests/test_estimate_rate_limit.py
lekss361 d409e31f00
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 4m23s
Deploy Trade-In / deploy (push) Successful in 2m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8 (#2546)
2026-08-25 16:27:02 +00:00

154 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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