"""Offline-тесты POST /api/v1/trade-in/lead (#2376, sub-issue родителя #1971). Покрытие (db мокается, NO live network/DB): - happy path: consent=True + валидный телефон -> 200, INSERT + commit - consent=False -> 422 (Literal[True] guard) - невалидный формат телефона -> 422 - телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening) - source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы) - estimate_id, которого нет в trade_in_estimates -> 404 """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock from uuid import uuid4 import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @pytest.fixture def db() -> MagicMock: return MagicMock() @pytest.fixture def client(db: MagicMock) -> TestClient: from app.api.v1 import lead as lead_module from app.core.db import get_db app = FastAPI() app.include_router(lead_module.router, prefix="/api/v1/trade-in") def fake_db() -> Any: yield db app.dependency_overrides[get_db] = fake_db return TestClient(app) def _insert_result(lead_id: str) -> MagicMock: result = MagicMock() result.mappings.return_value.one.return_value = { "id": lead_id, "created_at": datetime(2026, 7, 4, 12, 0, tzinfo=UTC), } return result def test_lead_happy_path(client: TestClient, db: MagicMock) -> None: from app.api.v1 import lead as lead_module lead_id = str(uuid4()) db.execute.return_value = _insert_result(lead_id) r = client.post( "/api/v1/trade-in/lead", json={"phone": "+7 (912) 345-67-89", "consent": True}, headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"}, ) assert r.status_code == 200, r.text body = r.json() assert body["id"] == lead_id assert body["status"] == "received" assert db.commit.called params = db.execute.call_args.args[1] assert params["phone"] == "+7 (912) 345-67-89" assert params["consent"] is True assert params["source"] == "result" assert params["estimate_id"] is None # 152-ФЗ proof-of-consent (migration 182): client_ip / policy version / text # snapshot must reach the INSERT params, not just the audit log (#2497 TODO). assert params["client_ip"] == "203.0.113.7" # first hop of X-Forwarded-For assert params["consent_policy_version"] == lead_module._CONSENT_POLICY_VERSION assert params["consent_text_snapshot"] == lead_module._CONSENT_TEXT_SNAPSHOT def test_lead_client_ip_falls_back_to_peer_when_no_xff(client: TestClient, db: MagicMock) -> None: # No X-Forwarded-For header -> falls back to request.client.host (TestClient # reports "testclient"), still persisted rather than silently None. lead_id = str(uuid4()) db.execute.return_value = _insert_result(lead_id) r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True}, ) assert r.status_code == 200, r.text params = db.execute.call_args.args[1] assert params["client_ip"] == "testclient" def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None: r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": False}, ) assert r.status_code == 422 assert not db.execute.called def test_lead_consent_missing_422(client: TestClient) -> None: r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789"}) assert r.status_code == 422 def test_lead_invalid_phone_422(client: TestClient) -> None: r = client.post( "/api/v1/trade-in/lead", json={"phone": "not-a-phone!!", "consent": True}, ) assert r.status_code == 422 def test_lead_with_unknown_estimate_id_404(client: TestClient, db: MagicMock) -> None: result = MagicMock() result.fetchone.return_value = None db.execute.return_value = result r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())}, ) assert r.status_code == 404 assert not db.commit.called def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> None: lead_id = str(uuid4()) exists_result = MagicMock() exists_result.fetchone.return_value = (1,) db.execute.side_effect = [exists_result, _insert_result(lead_id)] estimate_id = str(uuid4()) r = client.post( "/api/v1/trade-in/lead", json={ "phone": "+79123456789", "consent": True, "estimate_id": estimate_id, }, ) assert r.status_code == 200, r.text params = db.execute.call_args.args[1] assert params["estimate_id"] == estimate_id assert params["source"] == "result" def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None: # "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы), # но содержит 0 цифр -> должно отклоняться digit-валидатором. r = client.post( "/api/v1/trade-in/lead", json={"phone": "(()) -- ..", "consent": True}, ) assert r.status_code == 422, r.text assert not db.execute.called def test_lead_too_few_digits_phone_422(client: TestClient, db: MagicMock) -> None: # 6 цифр < минимума 10 -> 422 (маску проходит по длине, digit-guard режет). r = client.post( "/api/v1/trade-in/lead", json={"phone": "123-456", "consent": True}, ) assert r.status_code == 422, r.text assert not db.execute.called def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> None: # "landing"-воронка недостижима (rbac_guard закрывает /lead) — литерал убран # из схемы, поэтому явный source="landing" теперь 422. r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "source": "landing"}, ) assert r.status_code == 422, r.text assert not db.execute.called