gendesign/tradein-mvp/backend/tests/test_account_quota.py
lekss361 5f3579b8c2
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
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 1m2s
Deploy Trade-In / build-backend (push) Successful in 5m35s
Deploy Trade-In / deploy (push) Successful in 1m14s
feat(tradein/auth): восстановить доступ praktika + безлимит через данные вместо хардкода (#2544)
2026-07-26 23:29:53 +00:00

886 lines
35 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, praktika 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.monthly_limit — персональный лимит вместо negative-used
хака
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
(i) account_quota_overrides.unlimited — data-driven безлимит (migration 191):
kopylov (перенесён из хардкода) и praktika (восстановленный пилот) безлимитны
через таблицу, не через код
DB мокируется через _FakeDB (роутинг по SQL-тексту, см. ниже) — реальная БД не
требуется.
"""
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
# ---------------------------------------------------------------------------
#
# is_unlimited() теперь (migration 191) может как шорткатиться БЕЗ похода в БД
# (роль admin, ИЛИ username вообще не в roles.yaml → KeyError), так и делать
# реальный SELECT unlimited FROM account_quota_overrides (обычный pilot / kopylov /
# praktika). Это значит, что порядок/количество db.execute() вызовов зависит от
# username, а не только от вызываемой функции — позиционные side_effect-списки
# были бы хрупкими. Вместо этого _FakeDB роутит execute() по ТЕКСТУ SQL, что
# устойчиво к тому, сколько раз и в каком порядке реально стучимся в БД.
class _Row:
"""Row stand-in: произвольные named-поля + позиционный доступ row[0]
(нужен increment() для RETURNING used в debug-логе)."""
def __init__(self, **fields: int | bool) -> None:
for name, value in fields.items():
setattr(self, name, value)
self._t = tuple(fields.values())
def __getitem__(self, i: int) -> int:
return self._t[i]
class _FakeDB:
"""DB session mock, роутит execute() по подстроке в SQL-тексте, а не по
порядку вызова — устойчив к тому, что is_unlimited() иногда обращается к БД
(обычный pilot / kopylov / praktika), а иногда шорткатится без неё (admin /
неизвестный username)."""
def __init__(
self,
*,
unlimited: bool | None = None,
override_limit: int | None = None,
used: int | None = None,
upsert_used: int | None = None,
) -> None:
self.unlimited = unlimited
self.override_limit = override_limit
self.used = used
self.upsert_used = upsert_used
self.commits = 0
self.execute_calls: list[tuple[str, dict]] = []
def execute(self, stmt: object, params: dict | None = None) -> MagicMock:
sql = str(stmt)
self.execute_calls.append((sql, dict(params or {})))
result = MagicMock()
if "SELECT unlimited FROM account_quota_overrides" in sql:
result.fetchone.return_value = (
None if self.unlimited is None else _Row(unlimited=self.unlimited)
)
elif "SELECT monthly_limit FROM account_quota_overrides" in sql:
result.fetchone.return_value = (
None if self.override_limit is None else _Row(monthly_limit=self.override_limit)
)
elif "INSERT INTO account_estimate_usage" in sql:
result.fetchone.return_value = (
None if self.upsert_used is None else _Row(used=self.upsert_used)
)
elif "SELECT used FROM account_estimate_usage" in sql:
result.fetchone.return_value = None if self.used is None else _Row(used=self.used)
else:
raise AssertionError(f"_FakeDB: unrecognized SQL: {sql!r}")
return result
def commit(self) -> None:
self.commits += 1
def _override_result(limit: int | None) -> MagicMock:
"""Mock результата запроса account_quota_overrides.monthly_limit — для тестов,
вызывающих user_limit() напрямую (без is_unlimited в цепочке)."""
result = MagicMock()
result.fetchone.return_value = None if limit is None else _Row(monthly_limit=limit)
return result
# ---------------------------------------------------------------------------
# (a) admin / kopylov / praktika unlimited
# ---------------------------------------------------------------------------
def test_is_unlimited_admin() -> None:
"""admin шорткатится по роли — БЕЗ похода в БД."""
db = MagicMock()
assert is_unlimited(db, "admin") is True
db.execute.assert_not_called()
def test_is_unlimited_kopylov() -> None:
"""kopylov — unlimited через account_quota_overrides.unlimited=true (migration
191), не через хардкод в коде."""
db = _FakeDB(unlimited=True)
assert is_unlimited(db, "kopylov") is True
def test_is_unlimited_praktika() -> None:
"""praktika — восстановленный пилот с безлимитным грантом (migration 191)."""
db = _FakeDB(unlimited=True)
assert is_unlimited(db, "praktika") is True
def test_is_unlimited_pilot_user1() -> None:
"""user1 — обычный pilot, нет override-строки → limited."""
db = _FakeDB()
assert is_unlimited(db, "user1") is False
def test_is_unlimited_unknown_user() -> None:
"""Неизвестный пользователь → False (KeyError трактуется как limited), БЕЗ
похода в БД."""
db = MagicMock()
assert is_unlimited(db, "ghost_unknown_xyz") is False
db.execute.assert_not_called()
def test_check_and_raise_admin_not_blocked() -> None:
"""admin с used=15 не получает 429."""
db = MagicMock()
check_and_raise(db, "admin") # не должно поднять исключение
db.execute.assert_not_called()
def test_check_and_raise_kopylov_not_blocked() -> None:
"""kopylov с used=100 (гипотетически) не получает 429 — is_unlimited гейтит
раньше usage-lookup."""
db = _FakeDB(unlimited=True)
check_and_raise(db, "kopylov") # не должно поднять исключение
def test_check_and_raise_praktika_not_blocked() -> None:
"""praktika (unlimited=true) не получает 429 независимо от used."""
db = _FakeDB(unlimited=True)
check_and_raise(db, "praktika") # не должно поднять исключение
def test_increment_admin_is_noop() -> None:
"""increment для admin → никаких db.execute вызовов."""
db = MagicMock()
assert increment(db, "admin") is True
db.execute.assert_not_called()
db.commit.assert_not_called()
def test_increment_kopylov_is_noop() -> None:
"""increment для kopylov (unlimited=true) → True, БЕЗ UPSERT/commit —
is_unlimited() гейтит раньше инкремента (единственный execute — проверка
unlimited-флага, не usage-UPSERT)."""
db = _FakeDB(unlimited=True)
assert increment(db, "kopylov") is True
assert db.commits == 0
assert not any("INSERT INTO account_estimate_usage" in sql for sql, _ in db.execute_calls)
def test_increment_praktika_is_noop() -> None:
"""increment для praktika (unlimited=true) → True, без UPSERT/commit."""
db = _FakeDB(unlimited=True)
assert increment(db, "praktika") is True
assert db.commits == 0
assert not any("INSERT INTO account_estimate_usage" in sql for sql, _ in db.execute_calls)
# ---------------------------------------------------------------------------
# (b) pilot-юзер блокируется на 16-м запросе
# ---------------------------------------------------------------------------
def test_check_and_raise_pilot_not_blocked_at_14() -> None:
"""used=14 < 15 → не блокируется."""
db = _FakeDB(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 = _FakeDB(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 = _FakeDB(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 = _FakeDB(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 → is_unlimited-lookup + user_limit-lookup + UPSERT
(3 execute), 1 commit."""
db = _FakeDB(upsert_used=1)
result = increment(db, "user1")
assert result is True
assert len(db.execute_calls) == 3
assert db.commits == 1
upsert_calls = [sql for sql, _ in db.execute_calls if "ON CONFLICT" in sql]
assert len(upsert_calls) == 1
assert "used" in upsert_calls[0]
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 = _FakeDB(upsert_used=1)
increment(db, "user5")
assert len(db.execute_calls) == 3
# ---------------------------------------------------------------------------
# (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 = _FakeDB(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_praktika_unlimited() -> None:
"""praktika (unlimited=true) → unlimited=True, remaining=limit, без деления
на ноль и без «Осталось N из 0» (limit берётся из user_limit(), не 0)."""
db = _FakeDB(unlimited=True, override_limit=999_999, used=42)
status = get_status(db, "praktika")
assert status["unlimited"] is True
assert status["limit"] == 999_999
assert status["remaining"] == 999_999
assert status["used"] == 42
assert status["limit"] > 0 # защита от «N из 0»
def test_get_status_pilot_with_used() -> None:
"""pilot с used=10 → remaining=5."""
db = _FakeDB(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 = _FakeDB()
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 = _FakeDB(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 = _FakeDB(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 _FakeDB()
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
@pytest.fixture()
def quota_app_praktika_unlimited() -> FastAPI:
"""FastAPI app где БД отдаёт unlimited=true для praktika."""
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 _FakeDB(unlimited=True, override_limit=999_999, used=100)
application.dependency_overrides[get_db] = _override_db
return application
def test_quota_endpoint_praktika_unlimited(quota_app_praktika_unlimited: FastAPI) -> None:
"""GET /quota с praktika (unlimited grant) → unlimited=True, осмысленный
(не нулевой) limit/remaining — фронт (page.tsx) всё равно скрывает эти числа
при unlimited=True, но backend не должен отдавать «0 из 0»."""
client = TestClient(quota_app_praktika_unlimited)
resp = client.get(
"/api/v1/trade-in/quota",
headers={"X-Authenticated-User": "praktika"},
)
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is True
assert data["limit"] > 0
assert data["remaining"] > 0
assert data["used"] == 100
# ---------------------------------------------------------------------------
# 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 _FakeDB(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
@pytest.fixture()
def estimate_app_praktika_unlimited() -> FastAPI:
"""FastAPI app где БД отдаёт unlimited=true для praktika (used заведомо
«за пределами» обычного лимита — проверяем что это НЕ блокирует)."""
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 _FakeDB(unlimited=True, override_limit=999_999, used=MONTHLY_LIMIT + 500)
application.dependency_overrides[get_db] = _override_db
return application
def test_estimate_praktika_not_blocked_429(estimate_app_praktika_unlimited: FastAPI) -> None:
"""POST /estimate с praktika (unlimited=true, used far above обычного лимита)
НЕ 429 — восстановленный пилот с безлимитным грантом не упирается в квоту."""
client = TestClient(estimate_app_praktika_unlimited, 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": "praktika"},
)
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, воспроизводящий:
- is_unlimited(): SELECT unlimited FROM account_quota_overrides ->
unlimited_override (None → строки нет → not unlimited);
- user_limit(): SELECT monthly_limit FROM account_quota_overrides ->
override_limit (None → глобальный MONTHLY_LIMIT);
- increment(): атомарный conditional UPSERT (#747) — WHERE used < :lim в SQL,
эмулируется через params["lim"].
Роутинг по ТЕКСТУ SQL (не по наличию "lim" в params) — обе override-lookup
query (is_unlimited и user_limit) не содержат "lim" в params, поэтому их нужно
различать по содержимому запроса, а не по форме params.
"""
def __init__(
self,
*,
used: int | None,
override_limit: int | None = None,
unlimited_override: bool | None = None,
) -> None:
self.used = used
self.override_limit = override_limit
self.unlimited_override = unlimited_override
self.commits = 0
def execute(self, stmt: object, params: dict) -> _AtomicResult:
sql = str(stmt)
if "SELECT unlimited FROM account_quota_overrides" in sql:
row = (
_Row(unlimited=self.unlimited_override)
if self.unlimited_override is not None
else None
)
return _AtomicResult(row)
if "SELECT monthly_limit FROM account_quota_overrides" in sql:
row = (
_Row(monthly_limit=self.override_limit) if self.override_limit is not None else None
)
return _AtomicResult(row)
# UPSERT — WHERE used < :lim
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(used=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 заменяет глобальный лимит полностью —
не является дополнительным потолком поверх него. username вымышленный (не в
roles.yaml) — is_unlimited() шорткатится на KeyError без похода в БД.
"""
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
assert increment(db, "user_low_override") is False
assert db.used == 5 # не выросло
def test_increment_atomic_kopylov_unlimited_bypasses_upsert() -> None:
"""kopylov (unlimited=true через account_quota_overrides) — increment() True
без похода в UPSERT-ветку, used в фейке не растёт."""
db = _AtomicQuotaFakeDB(used=MONTHLY_LIMIT, unlimited_override=True)
assert increment(db, "kopylov") is True
assert db.commits == 0
assert db.used == MONTHLY_LIMIT # не тронут — is_unlimited гейтит раньше UPSERT
# ---------------------------------------------------------------------------
# (g) account_quota_overrides.monthly_limit — персональный лимит (замена
# 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 = _FakeDB(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 = _FakeDB(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.
username вымышленный (не в roles.yaml) — is_unlimited() KeyError-шорткат."""
db = _FakeDB(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 = _FakeDB(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 = _FakeDB(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 = _FakeDB(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 _FakeDB()
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()