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
886 lines
35 KiB
Python
886 lines
35 KiB
Python
"""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()
|