192/193 -> 229/230: main занял 192_tradein_users_auth.sql и 193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован открытым PR #2732 (228_payments.sql) - следующие реально свободные 229/230, порядок consent_proof -> retention сохранён. Правки ссылок на старые имена/префиксы: docstring-заголовки самих SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге, комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py / purge_expired_trade_in_data.py, переменные и имена тестов в test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py. (Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и test_team_api.py - это про другие, уже существующие на main миграции 192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту пару.)
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""ЭТАП 4 B2C launch — consent-before-save gate on trade_in_estimates (part A).
|
|
|
|
Covers:
|
|
- require_consent=True + payload.consent missing/False -> HTTPException(422),
|
|
raised BEFORE geocode() is ever touched and BEFORE any db.execute call
|
|
(gate is literally the first statement in estimate_quality()).
|
|
- require_consent=True + payload.consent=True -> proceeds, and the eventual
|
|
INSERT carries consent=True / client_ip / policy version / text snapshot.
|
|
- require_consent=True + consent=True but geocode fails -> the
|
|
_empty_estimate() fallback INSERT *also* carries the same consent proof
|
|
(both places an address can reach trade_in_estimates are covered).
|
|
- require_consent defaults to False (regression guard): a bare
|
|
estimate_quality(payload, db) call -- exactly the shape used by ~90 other
|
|
estimator tests that don't care about auth/consent at all -- is completely
|
|
unaffected. This is the whole reason the gate keys off an explicit
|
|
keyword-only flag instead of `created_by is None`: the first version of
|
|
this gate DID key off created_by and broke 92 unrelated tests across the
|
|
estimator test suite (every one of them calls estimate_quality(payload, db)
|
|
with created_by defaulting to None, which used to mean nothing).
|
|
- the real (only) production caller, app/api/v1/trade_in.py::estimate(),
|
|
passes require_consent=(x_authenticated_user is None) -- verified via source
|
|
inspection, since exercising it live would require a full FastAPI app
|
|
fixture (rbac_guard is DB/env-heavy and out of scope for this offline test).
|
|
|
|
Style mirrors tests/test_estimator_client_coords.py + test_estimator_event_loop_2207.py
|
|
(offline, db=MagicMock(), downstream helpers patched).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import inspect
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import anyio
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
|
_MIGRATION_229 = _SQL_DIR / "229_trade_in_estimates_consent_proof.sql"
|
|
|
|
|
|
def _make_payload(**overrides: Any) -> Any:
|
|
from app.schemas.trade_in import TradeInEstimateInput
|
|
|
|
base: dict[str, Any] = dict(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1)
|
|
base.update(overrides)
|
|
return TradeInEstimateInput(**base)
|
|
|
|
|
|
def _make_fake_geo() -> Any:
|
|
from app.services.geocoder import GeocodeResult
|
|
|
|
return GeocodeResult(
|
|
lat=56.838,
|
|
lon=60.595,
|
|
full_address="Свердловская обл., Екатеринбург, ул. Тестовая, 1",
|
|
provider="nominatim",
|
|
)
|
|
|
|
|
|
def _downstream_patches(geocode_mock: Any) -> tuple[Any, ...]:
|
|
"""Offline mocks so estimate_quality runs to completion (mirrors 2207 test's set)."""
|
|
return (
|
|
patch("app.services.estimator.geocode", new=geocode_mock),
|
|
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
|
patch("app.services.estimator.match_house_readonly", return_value=None),
|
|
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
|
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
|
|
patch("app.services.estimator._fetch_deals", return_value=[]),
|
|
patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)),
|
|
patch(
|
|
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch(
|
|
"app.services.estimator.estimate_via_cian_valuation",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
|
)
|
|
|
|
|
|
def _find_insert_call(db: MagicMock, table_marker: str) -> dict[str, Any]:
|
|
for call in db.execute.call_args_list:
|
|
stmt = call.args[0]
|
|
sql = str(getattr(stmt, "text", stmt))
|
|
if f"INSERT INTO {table_marker}" in sql:
|
|
return call.args[1]
|
|
raise AssertionError(
|
|
f"no INSERT INTO {table_marker} call captured; calls={db.execute.call_args_list}"
|
|
)
|
|
|
|
|
|
# ── require_consent=True, no consent -> 422 BEFORE any work ───────────────────
|
|
|
|
|
|
def test_require_consent_without_payload_consent_raises_422_before_geocode() -> None:
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload() # consent defaults to None
|
|
|
|
async def _run() -> None:
|
|
with patch("app.services.estimator.geocode") as geocode_mock:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await estimate_quality(payload, db, require_consent=True)
|
|
assert exc_info.value.status_code == 422
|
|
geocode_mock.assert_not_called()
|
|
|
|
anyio.run(_run)
|
|
assert not db.execute.called, "gate must precede ANY db write"
|
|
|
|
|
|
def test_require_consent_with_payload_consent_false_raises_422() -> None:
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload(consent=False)
|
|
|
|
async def _run() -> None:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await estimate_quality(payload, db, require_consent=True)
|
|
assert exc_info.value.status_code == 422
|
|
|
|
anyio.run(_run)
|
|
assert not db.execute.called
|
|
|
|
|
|
# ── require_consent=True, consent given -> proceeds, proof persisted ──────────
|
|
|
|
|
|
def test_require_consent_with_payload_consent_persists_proof() -> None:
|
|
from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION
|
|
from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload(consent=True)
|
|
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
|
|
|
async def _run() -> Any:
|
|
with contextlib.ExitStack() as stack:
|
|
for cm in _downstream_patches(geocode_mock):
|
|
stack.enter_context(cm)
|
|
return await estimate_quality(
|
|
payload, db, client_ip="203.0.113.9", require_consent=True
|
|
)
|
|
|
|
result = anyio.run(_run)
|
|
assert result.estimate_id is not None
|
|
|
|
params = _find_insert_call(db, "trade_in_estimates")
|
|
assert params["consent"] is True
|
|
assert params["client_ip"] == "203.0.113.9"
|
|
assert params["consent_policy_version"] == POLICY_VERSION
|
|
assert params["consent_text_snapshot"] == TEXT_SNAPSHOT
|
|
assert params["created_by"] is None
|
|
|
|
|
|
# ── require_consent=True, consent given, geocode fails -> _empty_estimate too ─
|
|
|
|
|
|
def test_empty_estimate_fallback_persists_proof_when_consent_required() -> None:
|
|
from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION
|
|
from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload(consent=True)
|
|
geocode_mock = AsyncMock(return_value=None) # geocode failure -> _empty_estimate path
|
|
|
|
async def _run() -> Any:
|
|
with patch("app.services.estimator.geocode", new=geocode_mock):
|
|
return await estimate_quality(
|
|
payload, db, client_ip="198.51.100.4", require_consent=True
|
|
)
|
|
|
|
result = anyio.run(_run)
|
|
assert result.n_analogs == 0
|
|
|
|
params = _find_insert_call(db, "trade_in_estimates")
|
|
assert params["consent"] is True
|
|
assert params["client_ip"] == "198.51.100.4"
|
|
assert params["consent_policy_version"] == POLICY_VERSION
|
|
assert params["consent_text_snapshot"] == TEXT_SNAPSHOT
|
|
|
|
|
|
def test_empty_estimate_fallback_gate_still_blocks_without_consent() -> None:
|
|
"""Gate precedes _empty_estimate too -- geocode is never even reached."""
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload() # no consent
|
|
|
|
async def _run() -> None:
|
|
with patch("app.services.estimator.geocode") as geocode_mock:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await estimate_quality(payload, db, require_consent=True)
|
|
assert exc_info.value.status_code == 422
|
|
geocode_mock.assert_not_called()
|
|
|
|
anyio.run(_run)
|
|
|
|
|
|
# ── require_consent defaults False -> zero blast radius on existing callers ───
|
|
|
|
|
|
def test_require_consent_defaults_false() -> None:
|
|
from app.services.estimator import estimate_quality
|
|
|
|
sig = inspect.signature(estimate_quality)
|
|
assert sig.parameters["require_consent"].default is False
|
|
assert sig.parameters["require_consent"].kind == inspect.Parameter.KEYWORD_ONLY
|
|
|
|
|
|
def test_bare_call_without_require_consent_is_unaffected() -> None:
|
|
"""The exact call shape used by ~90 other estimator tests
|
|
(estimate_quality(payload, db), no created_by/require_consent at all) --
|
|
must keep working with NO consent field on the payload whatsoever."""
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload() # no consent field
|
|
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
|
|
|
async def _run() -> Any:
|
|
with contextlib.ExitStack() as stack:
|
|
for cm in _downstream_patches(geocode_mock):
|
|
stack.enter_context(cm)
|
|
return await estimate_quality(payload, db)
|
|
|
|
result = anyio.run(_run)
|
|
assert result.estimate_id is not None
|
|
|
|
params = _find_insert_call(db, "trade_in_estimates")
|
|
assert params["consent"] is None
|
|
assert params["client_ip"] is None
|
|
assert params["consent_policy_version"] is None
|
|
assert params["consent_text_snapshot"] is None
|
|
|
|
|
|
# ── B2B pilot (created_by set, require_consent left False) -> unaffected ──────
|
|
|
|
|
|
def test_b2b_pilot_without_consent_field_unaffected() -> None:
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload() # no consent field at all -- mirrors real pilot frontend
|
|
geocode_mock = AsyncMock(return_value=_make_fake_geo())
|
|
|
|
async def _run() -> Any:
|
|
with contextlib.ExitStack() as stack:
|
|
for cm in _downstream_patches(geocode_mock):
|
|
stack.enter_context(cm)
|
|
return await estimate_quality(payload, db, created_by="kopylov")
|
|
|
|
result = anyio.run(_run)
|
|
assert result.estimate_id is not None
|
|
|
|
params = _find_insert_call(db, "trade_in_estimates")
|
|
assert params["created_by"] == "kopylov"
|
|
assert params["consent"] is None
|
|
assert params["client_ip"] is None
|
|
assert params["consent_policy_version"] is None
|
|
assert params["consent_text_snapshot"] is None
|
|
|
|
|
|
# ── production wiring: app/api/v1/trade_in.py passes require_consent correctly ─
|
|
|
|
|
|
def test_api_handler_wires_require_consent_from_auth_header() -> None:
|
|
"""Source-inspection guard: the ONLY production caller of estimate_quality
|
|
must derive require_consent from the ABSENCE of X-Authenticated-User, not
|
|
hardcode True/False. Cheaper and more robust than spinning up a full app +
|
|
rbac_guard fixture just to exercise one kwarg's wiring."""
|
|
from app.api.v1 import trade_in as trade_in_module
|
|
|
|
src = re.sub(r"\s+", " ", inspect.getsource(trade_in_module.estimate))
|
|
assert "require_consent=x_authenticated_user is None" in src
|
|
|
|
|
|
# ── Migration 229 sanity ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_migration_229_exists() -> None:
|
|
assert _MIGRATION_229.is_file(), f"missing migration: {_MIGRATION_229}"
|
|
|
|
|
|
def test_migration_229_is_transactional() -> None:
|
|
sql = _MIGRATION_229.read_text("utf-8")
|
|
assert "BEGIN;" in sql
|
|
assert "COMMIT;" in sql
|
|
|
|
|
|
def test_migration_229_is_idempotent() -> None:
|
|
sql = _MIGRATION_229.read_text("utf-8")
|
|
assert "ADD COLUMN IF NOT EXISTS consent" in sql
|
|
assert "ADD COLUMN IF NOT EXISTS client_ip" in sql
|
|
assert "CREATE INDEX IF NOT EXISTS" in sql
|
|
assert "pg_constraint" in sql # DO-block guard, not bare ADD CONSTRAINT
|
|
|
|
|
|
def test_migration_229_no_psycopg_trap() -> None:
|
|
sql = _MIGRATION_229.read_text("utf-8")
|
|
assert not re.search(r":\w+::", sql)
|
|
|
|
|
|
def test_migration_229_check_constraint_allows_null_or_true() -> None:
|
|
sql = _MIGRATION_229.read_text("utf-8")
|
|
assert "consent IS NULL OR consent IS TRUE" in sql
|