diff --git a/auth/roles.yaml b/auth/roles.yaml index 7747012b..6d8b00f2 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -13,7 +13,7 @@ # any pattern in `deny`. `deny` overrides `paths` (deny-by-default within # match scope). # -# Username list MUST match exactly the 12 entries in caddy/users.caddy.snippet +# Username list MUST match exactly the entries in caddy/users.caddy.snippet # — Caddy basic_auth sets X-Authenticated-User from {http.auth.user.id}, and # unknown users hit /me with 403 ("user not in roles config"). # @@ -53,5 +53,6 @@ users: user8: pilot user9: pilot user10: pilot + praktika: pilot # пилот-аккаунт агентства «Практика» 2026-05-29 admintest: admin # temp QA 2026-05-26 pilottest: pilot # temp QA 2026-05-26 diff --git a/caddy/users.caddy.snippet b/caddy/users.caddy.snippet index 2afe61b4..8c9f3051 100644 --- a/caddy/users.caddy.snippet +++ b/caddy/users.caddy.snippet @@ -19,6 +19,7 @@ basic_auth bcrypt "GenDesign Pilot" { user8 JDJhJDE0JDVxNEE4RVRVWi4yUkY2OG9KR2Z4MU8xYWVDYTdkTDNVckJGZHBZM3c5VjlqS3ZyNEFtcWVl # pilot slot (regenerated 2026-05-26) user9 JDJhJDE0JGwuaDl1U2JkT1dOWDguVXhJMFozZk9Ec09NVmxyM2J2VVdxdlFzdktXWVNkZDVBMVBuY3Z5 # pilot slot (regenerated 2026-05-26) user10 JDJhJDE0JEN4QlV2Z3Q3c2VqMU92OFV4YUo3cWVUUXJkRGExVU9RL3BYNzUweXhXZDdhdGh1MlFwcnN5 # pilot slot (regenerated 2026-05-26) + praktika JDJhJDE0JDV4UzZiWmNPRXZIOWNERkpQSURjbXU0bjJJUnBCNHZDaFZYTkw2d0YwT3d0ekQ0U1dPVjdL # Практика (pilot) 2026-05-29 kopylov JDJhJDE0JExUald3alVMbS5RdVV0TTRTcGk0cE9YeHNXS2E1QU5rTFp2TnFFQzdmWWNFV3djRVAzOUdH # Копылов (2026-05-23) admintest JDJhJDE0JHB6TDV6ZWZFTmZuTTd0S2I0Uy8vZ3VlL2w5bEpKbjEwTGVYVTd2bkRhNW5qbGlYTlhvVkxT # QA test admin role 2026-05-26 pilottest JDJhJDE0JFQwTW9UV1I0WHUwek5qeFR5SWxTQy4veFFFUzlvL1VoMHFycnJDTkU5czRoVXdwNXRKM2ph # QA test pilot role 2026-05-26 diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 3f8db648..0119af6e 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -10,7 +10,7 @@ from datetime import UTC, date, datetime, timedelta from typing import Annotated, Any from uuid import UUID -from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile +from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile from sqlalchemy import text from sqlalchemy.orm import Session @@ -26,6 +26,7 @@ from app.schemas.trade_in import ( PhotoMeta, PlacementHistoryEntry, PriceHistoryYearPoint, + QuotaStatus, RecentSoldEntry, SalesListingPair, SalesVsListingsResponse, @@ -34,6 +35,7 @@ from app.schemas.trade_in import ( StreetDealsResponse, TradeInEstimateInput, ) +from app.services import account_quota from app.services.exporters.trade_in_pdf import generate_trade_in_pdf from app.services.image_sanitizer import ImageSanitizationError, sanitize_image @@ -46,6 +48,7 @@ router = APIRouter() async def estimate( payload: TradeInEstimateInput, db: Annotated[Session, Depends(get_db)], + x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> AggregatedEstimate: """Реальная оценка через SQL aggregation поверх listings + deals. @@ -53,9 +56,28 @@ async def estimate( 2. PostGIS ST_DWithin радиус 800м (или 2км fallback) 3. Tukey IQR outlier filter 4. Median + Q1 + Q3 + confidence с explanation + + Применяется лимит 15 успешных оценок в месяц на аккаунт (кроме admin/kopylov). """ + account_quota.check_and_raise(db, x_authenticated_user) from app.services.estimator import estimate_quality - return await estimate_quality(payload, db) + result = await estimate_quality(payload, db) + account_quota.increment(db, x_authenticated_user) + return result + + +@router.get("/quota", response_model=QuotaStatus) +def get_quota( + db: Annotated[Session, Depends(get_db)], + x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, +) -> QuotaStatus: + """Статус квоты оценок для текущего аккаунта. + + Возвращает limit / used / remaining / unlimited для X-Authenticated-User. + Без заголовка (dev-режим без Caddy) — unlimited True, used 0. + """ + status = account_quota.get_status(db, x_authenticated_user) + return QuotaStatus(**status) @router.get("/estimate/{estimate_id}", response_model=AggregatedEstimate) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 31340351..54c0ddfd 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -382,3 +382,15 @@ class SalesVsListingsResponse(BaseModel): linkage_rate_pct: float # deals_with_listings / total_deals * 100 median_discount_pct: float | None # медиана по парам с listing pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC + + +# ── Account quota (#quota) ────────────────────────────────────────────────── + + +class QuotaStatus(BaseModel): + """Статус квоты оценок для текущего аккаунта (GET /api/v1/trade-in/quota).""" + + limit: int # MONTHLY_LIMIT = 15 + used: int # использовано в текущем месяце + remaining: int # max(0, limit - used) + unlimited: bool # True для admin / kopylov / без заголовка diff --git a/tradein-mvp/backend/app/services/account_quota.py b/tradein-mvp/backend/app/services/account_quota.py new file mode 100644 index 00000000..4b51f5d7 --- /dev/null +++ b/tradein-mvp/backend/app/services/account_quota.py @@ -0,0 +1,157 @@ +"""Сервис квоты оценок trade-in — 15 успешных оценок в месяц на аккаунт. + +Правила: +- Лимит = 15 успешных оценок за календарный месяц (UTC, период 'YYYY-MM'). +- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'. +- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality). +- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited, + лимит не применяется (fail-open). +- При исчерпании лимита поднимается HTTPException(429). +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from fastapi import HTTPException +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.auth import get_role + +logger = logging.getLogger(__name__) + +MONTHLY_LIMIT = 15 +LIMIT_EXHAUSTED_MESSAGE = ( + "Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым." +) + + +def current_period() -> str: + """Возвращает текущий период в формате 'YYYY-MM' (UTC).""" + return datetime.now(UTC).strftime("%Y-%m") + + +def is_unlimited(username: str) -> bool: + """True если пользователь не ограничен квотой. + + Unlimited: роль admin ИЛИ username == 'kopylov'. + KeyError (неизвестный пользователь) → трактуется как limited (False). + """ + if username == "kopylov": + return True + try: + role = get_role(username) + return role == "admin" + except KeyError: + return False + + +def get_status(db: Session, username: str | None) -> dict: + """Возвращает статус квоты для пользователя. + + Если username is None → unlimited True, used 0, remaining 15. + Если unlimited → used = фактический или 0, remaining = limit. + """ + if username is None: + return { + "limit": MONTHLY_LIMIT, + "used": 0, + "remaining": MONTHLY_LIMIT, + "unlimited": True, + } + + unlimited = is_unlimited(username) + period = current_period() + + row = db.execute( + text( + """ + SELECT used FROM account_estimate_usage + WHERE username = :u AND period_month = :p + """ + ), + {"u": username, "p": period}, + ).fetchone() + + used = row.used if row is not None else 0 + + if unlimited: + return { + "limit": MONTHLY_LIMIT, + "used": used, + "remaining": MONTHLY_LIMIT, + "unlimited": True, + } + + remaining = max(0, MONTHLY_LIMIT - used) + return { + "limit": MONTHLY_LIMIT, + "used": used, + "remaining": remaining, + "unlimited": False, + } + + +def check_and_raise(db: Session, username: str | None) -> None: + """Проверяет лимит квоты и поднимает 429 если исчерпан. + + Если username is None или пользователь unlimited → no-op. + """ + if username is None: + return + + if is_unlimited(username): + return + + period = current_period() + row = db.execute( + text( + """ + SELECT used FROM account_estimate_usage + WHERE username = :u AND period_month = :p + """ + ), + {"u": username, "p": period}, + ).fetchone() + + used = row.used if row is not None else 0 + if used >= MONTHLY_LIMIT: + logger.warning( + "quota exhausted: username=%r period=%s used=%d limit=%d", + username, period, used, MONTHLY_LIMIT, + ) + raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE) + + +def increment(db: Session, username: str | None) -> None: + """Инкрементирует счётчик успешных оценок для пользователя. + + Если username is None или пользователь unlimited → no-op. + UPSERT: INSERT ... ON CONFLICT DO UPDATE SET used = used + 1. + """ + if username is None: + return + + if is_unlimited(username): + return + + period = current_period() + db.execute( + text( + """ + INSERT INTO account_estimate_usage (username, period_month, used, updated_at) + VALUES (:u, :p, 1, NOW()) + ON CONFLICT (username, period_month) + DO UPDATE SET + used = account_estimate_usage.used + 1, + updated_at = NOW() + """ + ), + {"u": username, "p": period}, + ) + db.commit() + logger.debug( + "quota incremented: username=%r period=%s", username, period + ) diff --git a/tradein-mvp/backend/data/sql/076_account_estimate_quota.sql b/tradein-mvp/backend/data/sql/076_account_estimate_quota.sql new file mode 100644 index 00000000..b5373cc3 --- /dev/null +++ b/tradein-mvp/backend/data/sql/076_account_estimate_quota.sql @@ -0,0 +1,36 @@ +-- Migration 076: account_estimate_usage — квота оценок на аккаунт (#quota) +-- +-- Хранит счётчик успешных оценок per (username, period_month). +-- period_month — строка 'YYYY-MM' (UTC). Лимит проверяется/инкрементится +-- в app.services.account_quota. +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS + существующие COMMENT переписываются +-- безопасно (COMMENT ON TABLE/COLUMN — DML, не DDL, не падает при повторе). + +BEGIN; + +CREATE TABLE IF NOT EXISTS account_estimate_usage ( + username text NOT NULL, + period_month text NOT NULL, -- 'YYYY-MM' (UTC) + used integer NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY (username, period_month) +); + +COMMENT ON TABLE account_estimate_usage IS + 'Счётчик успешных оценок trade-in per (username, calendar month UTC). ' + 'Используется для применения лимита 15 оценок/месяц на pilot-аккаунтах.'; + +COMMENT ON COLUMN account_estimate_usage.username IS + 'Имя пользователя из заголовка X-Authenticated-User (Caddy basic_auth).'; + +COMMENT ON COLUMN account_estimate_usage.period_month IS + 'Календарный месяц UTC в формате YYYY-MM, напр. ''2026-05''.'; + +COMMENT ON COLUMN account_estimate_usage.used IS + 'Количество успешных оценок в данном месяце.'; + +COMMENT ON COLUMN account_estimate_usage.updated_at IS + 'Время последнего инкремента (UTC).'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_account_quota.py b/tradein-mvp/backend/tests/test_account_quota.py new file mode 100644 index 00000000..5c55b4b3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_account_quota.py @@ -0,0 +1,390 @@ +"""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) + +DB мокируется через MagicMock — реальная БД не требуется. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +# 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.services.account_quota import ( # noqa: E402 + LIMIT_EXHAUSTED_MESSAGE, + MONTHLY_LIMIT, + check_and_raise, + get_status, + increment, + is_unlimited, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _db_with_used(used: int) -> MagicMock: + """DB session mock whose execute().fetchone() returns row with .used = N.""" + row = MagicMock() + row.used = used + execute_result = MagicMock() + execute_result.fetchone.return_value = row + db = MagicMock() + db.execute.return_value = execute_result + return db + + +def _db_no_row() -> MagicMock: + """DB session mock where no row exists yet (first estimate of the month).""" + execute_result = MagicMock() + execute_result.fetchone.return_value = None + db = MagicMock() + db.execute.return_value = execute_result + 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 >= MONTHLY_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 == ( + "Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым." + ) + + +def test_check_and_raise_pilot_blocked_over_limit() -> None: + """used=20 тоже блокируется.""" + 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 → выполняет db.execute (UPSERT) и db.commit.""" + db = MagicMock() + increment(db, "user1") + db.execute.assert_called_once() + db.commit.assert_called_once() + + # Проверяем что SQL содержит ON CONFLICT ... DO UPDATE + call_args = db.execute.call_args + 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 = MagicMock() + increment(db, "user5") + db.execute.assert_called_once() + + +# --------------------------------------------------------------------------- +# (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=15 вне зависимости от 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 diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 5b62b253..58f4c47c 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -7,6 +7,7 @@ */ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; import "@/components/trade-in/trade-in.css"; import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in"; @@ -27,6 +28,7 @@ import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard"; import { OfferCard } from "@/components/trade-in/OfferCard"; import { TestPresets } from "@/components/trade-in/TestPresets"; import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api"; +import { useQuota } from "@/lib/useQuota"; function useEstimateId() { if (typeof window === "undefined") return null; @@ -36,6 +38,17 @@ function useEstimateId() { export default function TradeInPage() { const router = useRouter(); + const queryClient = useQueryClient(); + const quota = useQuota(); + + const blocked = quota.data + ? !quota.data.unlimited && quota.data.remaining <= 0 + : false; + const remainingQuota = + quota.data && !quota.data.unlimited ? quota.data.remaining : null; + const limitQuota = + quota.data && !quota.data.unlimited ? quota.data.limit : null; + const [freshResult, setFreshResult] = useState<{ estimate: AggregatedEstimate; input: TradeInEstimateInput; @@ -59,6 +72,7 @@ export default function TradeInPage() { setFreshResult({ estimate, input }); // basePath auto-prefixes — passing "/" returns user to /trade-in/?id=... router.replace(`/?id=${estimate.estimate_id}`, { scroll: false }); + void queryClient.invalidateQueries({ queryKey: ["trade-in", "quota"] }); }, }); } @@ -142,7 +156,14 @@ export default function TradeInPage() {