gendesign/tradein-mvp/backend/tests/test_account_quota.py
bot-backend d530602fbd
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
fix(tradein/quota): per-user quota override + no-burn on empty estimate (#2517)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-07-13 20:18:42 +00:00

711 lines
27 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 app.services.account_quota — monthly estimate quota enforcement.
Coverage:
(a) admin и kopylov unlimited — не блокируются, increment является no-op
(b) обычный pilot-юзер блокируется на 16-м запросе (429 + нужный detail)
(c) increment растит used счётчик
(d) get_status корректен для different сценариев
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
(f) #747 — атомарно-условный increment (TOCTOU fix)
(g) account_quota_overrides — персональный лимит вместо negative-used хака
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
DB мокируется через MagicMock — реальная БД не требуется.
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
# psycopg v3 driver required; stub DATABASE_URL before any app import
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint requires GTK — not present in CI/Windows
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
from app.services.account_quota import ( # noqa: E402
LIMIT_EXHAUSTED_MESSAGE,
MONTHLY_LIMIT,
check_and_raise,
get_status,
increment,
is_unlimited,
user_limit,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _Row:
"""Row stand-in поддерживающий и .used/.monthly_limit, и индексный доступ row[0]."""
def __init__(self, value: int) -> None:
self.used = value
self.monthly_limit = value
self._t = (value,)
def __getitem__(self, i: int) -> int:
return self._t[i]
def _result(row: _Row | None) -> MagicMock:
result = MagicMock()
result.fetchone.return_value = row
return result
def _override_result(limit: int | None) -> MagicMock:
"""Mock результата запроса account_quota_overrides.monthly_limit (user_limit())."""
return _result(None if limit is None else _Row(limit))
def _used_result(used: int | None) -> MagicMock:
"""Mock результата запроса account_estimate_usage.used (или UPSERT RETURNING used)."""
return _result(None if used is None else _Row(used))
def _db_with_used(used: int, *, override_limit: int | None = None) -> MagicMock:
"""DB session mock для get_status/check_and_raise: ровно 2 execute() —
(1) user_limit() override lookup, (2) account_estimate_usage.used lookup."""
db = MagicMock()
db.execute.side_effect = [_override_result(override_limit), _used_result(used)]
return db
def _db_no_row(*, override_limit: int | None = None) -> MagicMock:
"""DB session mock где строки usage ещё нет (первая оценка месяца)."""
db = MagicMock()
db.execute.side_effect = [_override_result(override_limit), _used_result(None)]
return db
def _db_for_increment(*, override_limit: int | None, upsert_used: int | None) -> MagicMock:
"""DB session mock для increment(): (1) user_limit() override lookup,
(2) UPSERT ... RETURNING used (None если WHERE used < lim не матчит)."""
db = MagicMock()
db.execute.side_effect = [_override_result(override_limit), _used_result(upsert_used)]
return db
# ---------------------------------------------------------------------------
# (a) admin and kopylov are unlimited
# ---------------------------------------------------------------------------
def test_is_unlimited_admin() -> None:
assert is_unlimited("admin") is True
def test_is_unlimited_kopylov() -> None:
assert is_unlimited("kopylov") is True
def test_is_unlimited_pilot_user1() -> None:
assert is_unlimited("user1") is False
def test_is_unlimited_unknown_user() -> None:
"""Неизвестный пользователь → False (KeyError трактуется как limited)."""
assert is_unlimited("ghost_unknown_xyz") is False
def test_check_and_raise_admin_not_blocked() -> None:
"""admin с used=15 не получает 429."""
db = _db_with_used(MONTHLY_LIMIT)
# Should not raise
check_and_raise(db, "admin")
def test_check_and_raise_kopylov_not_blocked() -> None:
"""kopylov с used=100 не получает 429."""
db = _db_with_used(100)
check_and_raise(db, "kopylov")
def test_increment_admin_is_noop() -> None:
"""increment для admin → никаких db.execute вызовов."""
db = MagicMock()
increment(db, "admin")
db.execute.assert_not_called()
db.commit.assert_not_called()
def test_increment_kopylov_is_noop() -> None:
"""increment для kopylov → no-op."""
db = MagicMock()
increment(db, "kopylov")
db.execute.assert_not_called()
# ---------------------------------------------------------------------------
# (b) pilot-юзер блокируется на 16-м запросе
# ---------------------------------------------------------------------------
def test_check_and_raise_pilot_not_blocked_at_14() -> None:
"""used=14 < 15 → не блокируется."""
db = _db_with_used(14)
check_and_raise(db, "user1") # должно пройти без исключения
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
Логика: used >= limit → block. После 15-й успешной оценки
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
"""
db = _db_with_used(MONTHLY_LIMIT)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user1")
assert exc_info.value.status_code == 429
assert exc_info.value.detail == LIMIT_EXHAUSTED_MESSAGE
def test_check_and_raise_pilot_blocked_exact_detail() -> None:
"""Проверяем точный текст сообщения 429."""
db = _db_with_used(MONTHLY_LIMIT)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user3")
assert exc_info.value.detail == (
f"Лимит из {MONTHLY_LIMIT} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
)
def test_check_and_raise_pilot_blocked_over_limit() -> None:
"""used=20 тоже блокируется (нет override → глобальный лимит)."""
db = _db_with_used(20)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user2")
assert exc_info.value.status_code == 429
# ---------------------------------------------------------------------------
# (c) increment растит used
# ---------------------------------------------------------------------------
def test_increment_pilot_calls_upsert() -> None:
"""increment для pilot → выполняет user_limit lookup + UPSERT (2 execute), 1 commit."""
db = _db_for_increment(override_limit=None, upsert_used=1)
result = increment(db, "user1")
assert result is True
assert db.execute.call_count == 2
db.commit.assert_called_once()
# Второй вызов — UPSERT; проверяем что SQL содержит ON CONFLICT ... DO UPDATE
call_args = db.execute.call_args_list[1]
sql_text = str(call_args[0][0]) # first positional arg — text() object
assert "ON CONFLICT" in sql_text
assert "used" in sql_text
def test_increment_none_username_is_noop() -> None:
"""None username → no-op."""
db = MagicMock()
increment(db, None)
db.execute.assert_not_called()
def test_increment_pilot_first_estimate_of_month() -> None:
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
db = _db_for_increment(override_limit=None, upsert_used=1)
increment(db, "user5")
assert db.execute.call_count == 2
# ---------------------------------------------------------------------------
# (d) get_status корректен
# ---------------------------------------------------------------------------
def test_get_status_none_username() -> None:
"""username None → unlimited, used=0, remaining=15."""
db = MagicMock()
status = get_status(db, None)
assert status["unlimited"] is True
assert status["used"] == 0
assert status["remaining"] == MONTHLY_LIMIT
assert status["limit"] == MONTHLY_LIMIT
db.execute.assert_not_called()
def test_get_status_admin() -> None:
"""admin → unlimited, remaining=limit вне зависимости от used."""
db = _db_with_used(7)
status = get_status(db, "admin")
assert status["unlimited"] is True
assert status["remaining"] == MONTHLY_LIMIT
assert status["used"] == 7 # фактический used из БД
def test_get_status_pilot_with_used() -> None:
"""pilot с used=10 → remaining=5."""
db = _db_with_used(10)
status = get_status(db, "user2")
assert status["unlimited"] is False
assert status["used"] == 10
assert status["remaining"] == 5
assert status["limit"] == MONTHLY_LIMIT
def test_get_status_pilot_no_row_yet() -> None:
"""Новый месяц — строки нет → used=0, remaining=15."""
db = _db_no_row()
status = get_status(db, "user3")
assert status["used"] == 0
assert status["remaining"] == MONTHLY_LIMIT
assert status["unlimited"] is False
def test_get_status_pilot_exhausted() -> None:
"""used=15 → remaining=0."""
db = _db_with_used(MONTHLY_LIMIT)
status = get_status(db, "user4")
assert status["remaining"] == 0
assert status["unlimited"] is False
def test_get_status_pilot_over_limit_remaining_zero() -> None:
"""used=20 → remaining=0 (не отрицательное)."""
db = _db_with_used(20)
status = get_status(db, "user5")
assert status["remaining"] == 0
# ---------------------------------------------------------------------------
# (e) отсутствие заголовка = unlimited (через /quota endpoint)
# ---------------------------------------------------------------------------
def test_check_and_raise_no_header_is_noop() -> None:
"""Без заголовка (None) → no-op, не поднимает HTTPException."""
db = MagicMock()
check_and_raise(db, None) # не должно поднять исключение
db.execute.assert_not_called()
# ---------------------------------------------------------------------------
# Integration: /quota endpoint через TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def quota_app() -> FastAPI:
"""Минимальное FastAPI app с /quota endpoint и мок-БД."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_no_row()
application.dependency_overrides[get_db] = _override_db
return application
def test_quota_endpoint_no_header(quota_app: FastAPI) -> None:
"""GET /quota без заголовка → unlimited=True, remaining=15."""
client = TestClient(quota_app)
resp = client.get("/api/v1/trade-in/quota")
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is True
assert data["remaining"] == MONTHLY_LIMIT
assert data["used"] == 0
def test_quota_endpoint_with_pilot_user(quota_app: FastAPI) -> None:
"""GET /quota с user1 (pilot, нет строки в БД) → unlimited=False, used=0."""
client = TestClient(quota_app)
resp = client.get(
"/api/v1/trade-in/quota",
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is False
assert data["used"] == 0
assert data["remaining"] == MONTHLY_LIMIT
def test_quota_endpoint_admin_unlimited(quota_app: FastAPI) -> None:
"""GET /quota с admin → unlimited=True."""
client = TestClient(quota_app)
resp = client.get(
"/api/v1/trade-in/quota",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is True
# ---------------------------------------------------------------------------
# Integration: POST /estimate quota enforcement через TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def estimate_app_exhausted() -> FastAPI:
"""FastAPI app где БД возвращает used=15 для pilot → 429 на POST /estimate."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_with_used(MONTHLY_LIMIT)
application.dependency_overrides[get_db] = _override_db
return application
def test_estimate_pilot_blocked_429(estimate_app_exhausted: FastAPI) -> None:
"""POST /estimate с user1 при used=15 → 429 с нужным detail."""
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 429
assert resp.json()["detail"] == LIMIT_EXHAUSTED_MESSAGE
def test_estimate_admin_not_blocked(estimate_app_exhausted: FastAPI) -> None:
"""POST /estimate с admin при used=15 → не 429 (blocked by quota), но может падать
с другой ошибкой (нет реального estimator). Ключевое: не 429."""
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code != 429
def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None:
"""POST /estimate без заголовка → не 429 (fail-open, dev-режим)."""
client = TestClient(estimate_app_exhausted, raise_server_exceptions=False)
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
)
assert resp.status_code != 429
# ---------------------------------------------------------------------------
# (f) #747 — атомарно-условный increment (TOCTOU fix)
# ---------------------------------------------------------------------------
class _AtomicResult:
def __init__(self, row: _Row | None) -> None:
self._row = row
def fetchone(self) -> _Row | None:
return self._row
class _AtomicQuotaFakeDB:
"""In-memory fake, воспроизводящий user_limit() override-lookup + атомарный
conditional UPSERT (#747):
- execute() без ключа "lim" в params → user_limit() override lookup —
возвращает override_limit (или None → increment() падает на глобальный
MONTHLY_LIMIT как :lim в следующем вызове).
- execute() с ключом "lim" → UPSERT: строки ещё нет (used=None) → INSERT used=1,
RETURNING row (WHERE не применяется к INSERT); used < lim → used+=1, RETURNING
row; used >= lim → конфликтная строка НЕ обновлена, RETURNING пуст (None).
"""
def __init__(self, *, used: int | None, override_limit: int | None = None) -> None:
self.used = used
self.override_limit = override_limit
self.commits = 0
def execute(self, _stmt: object, params: dict) -> _AtomicResult:
if "lim" not in params:
row = _Row(self.override_limit) if self.override_limit is not None else None
return _AtomicResult(row)
lim = params["lim"]
if self.used is None:
self.used = 1
elif self.used < lim:
self.used += 1
else:
return _AtomicResult(None)
return _AtomicResult(_Row(self.used))
def commit(self) -> None:
self.commits += 1
def test_increment_atomic_refuses_second_at_limit() -> None:
"""used=MONTHLY_LIMIT-1: первый increment True (used→limit), второй False —
used НЕ растёт за лимит (TOCTOU-гонка двух /estimate не превышает лимит)."""
db = _AtomicQuotaFakeDB(used=MONTHLY_LIMIT - 1)
assert increment(db, "user1") is True
assert db.used == MONTHLY_LIMIT
assert increment(db, "user1") is False
assert db.used == MONTHLY_LIMIT # не MONTHLY_LIMIT + 1
def test_increment_atomic_fresh_insert_succeeds() -> None:
"""Свежая вставка (строки ещё нет) НЕ блокируется WHERE → True, used=1."""
db = _AtomicQuotaFakeDB(used=None)
assert increment(db, "user1") is True
assert db.used == 1
def test_increment_atomic_returns_bool() -> None:
"""increment возвращает bool (контракт #747), а не None."""
db = _AtomicQuotaFakeDB(used=0)
assert increment(db, "user1") is True
def test_increment_atomic_unlimited_noop_returns_true() -> None:
"""unlimited (admin) → True без db.execute/commit."""
db = _AtomicQuotaFakeDB(used=MONTHLY_LIMIT)
assert increment(db, "admin") is True
assert db.commits == 0
assert db.used == MONTHLY_LIMIT # не тронут
def test_increment_atomic_respects_override_limit() -> None:
"""override=50 (user2): used=49 → increment True (used→50); used=50 → False.
Глобальный MONTHLY_LIMIT (15) НЕ применяется — используется персональный override."""
db = _AtomicQuotaFakeDB(used=49, override_limit=50)
assert increment(db, "user2") is True
assert db.used == 50
assert increment(db, "user2") is False
assert db.used == 50 # не 51
def test_increment_atomic_override_below_global_blocks_early() -> None:
"""override=5 (ниже глобального 15): used=5 уже блокирует, хотя < MONTHLY_LIMIT.
Демонстрирует что per-user override заменяет глобальный лимит полностью —
не является дополнительным потолком поверх него.
"""
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
assert increment(db, "user_low_override") is False
assert db.used == 5 # не выросло
# ---------------------------------------------------------------------------
# (g) account_quota_overrides — персональный лимит (замена negative-used хака)
# ---------------------------------------------------------------------------
def test_user_limit_no_override_returns_global() -> None:
"""Нет строки в account_quota_overrides → user_limit() возвращает MONTHLY_LIMIT."""
db = MagicMock()
db.execute.return_value = _override_result(None)
assert user_limit(db, "user1") == MONTHLY_LIMIT
def test_user_limit_with_override_returns_override() -> None:
"""Есть строка override → user_limit() возвращает monthly_limit из неё."""
db = MagicMock()
db.execute.return_value = _override_result(50)
assert user_limit(db, "user2") == 50
def test_get_status_override_limit() -> None:
"""user2 с override=50, used=0 (после сброса хака) → limit=50, remaining=50."""
db = _db_with_used(0, override_limit=50)
status = get_status(db, "user2")
assert status["limit"] == 50
assert status["remaining"] == 50
assert status["used"] == 0
def test_get_status_override_remaining_clamped_even_if_used_negative() -> None:
"""Кламп: даже если used снова просочится отрицательным (regression прежнего
negative-used хака), remaining НЕ превышает limit — не «Осталось 50 из 15»."""
db = _db_with_used(-35, override_limit=50)
status = get_status(db, "user2")
assert status["limit"] == 50
assert status["used"] == -35 # raw used не скрываем — диагностическая честность
assert status["remaining"] == 50 # clamp: max(0, 50 - max(0, -35)) == 50
assert status["remaining"] <= status["limit"]
def test_check_and_raise_override_blocks_below_global_limit() -> None:
"""override=5 (ниже глобального 15) — used=5 блокируется, хотя < MONTHLY_LIMIT."""
db = _db_with_used(5, override_limit=5)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user_low_override")
assert exc_info.value.status_code == 429
def test_check_and_raise_override_allows_above_global_limit() -> None:
"""override=50 — used=20 (> глобального 15) НЕ блокируется."""
db = _db_with_used(20, override_limit=50)
check_and_raise(db, "user2") # не должно поднять исключение
def test_increment_override_blocks_at_override_not_global() -> None:
"""increment уважает per-user override: used>=override → False, даже если
used < MONTHLY_LIMIT (15)."""
db = _db_for_increment(override_limit=5, upsert_used=None) # WHERE used<5 не матчит
assert increment(db, "user_low_override") is False
def test_increment_override_allows_above_global_limit() -> None:
"""increment с override=50: used=20 (>15 глобального) успешно инкрементит."""
db = _db_for_increment(override_limit=50, upsert_used=21)
assert increment(db, "user2") is True
# ---------------------------------------------------------------------------
# (h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
# ---------------------------------------------------------------------------
def _insufficient_estimate() -> AggregatedEstimate:
"""Пустой результат — как _empty_estimate() в estimator.py: median=0 → #697
computed_field insufficient_data=True."""
return AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=0,
range_low_rub=0,
range_high_rub=0,
median_price_per_m2=0,
confidence="low",
confidence_explanation="address_not_geocoded",
n_analogs=0,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
)
def _real_estimate() -> AggregatedEstimate:
"""Непустой результат — insufficient_data=False (median_price_rub > 0)."""
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 estimate_app_ok() -> FastAPI:
"""FastAPI app где check_and_raise проходит (used=0, без override) —
estimate_quality мокается отдельно в каждом тесте."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_no_row()
application.dependency_overrides[get_db] = _override_db
return application
def test_estimate_insufficient_data_does_not_increment_quota(
estimate_app_ok: FastAPI,
) -> None:
"""POST /estimate с insufficient_data=True результатом (нерезолвящийся адрес) →
account_quota.increment НЕ вызывается (пустой результат не списывает платный слот)."""
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
with (
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_insufficient_estimate()),
),
patch("app.services.account_quota.increment") as mock_increment,
):
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, несуществующий адрес xyz",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
assert resp.json()["median_price_rub"] == 0
assert resp.json()["insufficient_data"] is True
mock_increment.assert_not_called()
def test_estimate_real_result_still_increments_quota(estimate_app_ok: FastAPI) -> None:
"""Контрольный тест: непустой результат (insufficient_data=False) — increment
вызывается как обычно (регресс-guard, не ломаем #747 TOCTOU-семантику)."""
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
with (
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_real_estimate()),
),
patch("app.services.account_quota.increment", return_value=True) as mock_increment,
):
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
assert resp.json()["insufficient_data"] is False
mock_increment.assert_called_once()