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