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