gendesign/tradein-mvp/backend/tests/test_trade_in_lead.py
bot-backend 60c21ae2a5
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Successful in 58s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / build-frontend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m20s
feat(tradein/lead): persist 152-ФЗ proof-of-consent (LOW audit R2 #9) (#2511)
2026-07-12 22:13:36 +00:00

187 lines
6.3 KiB
Python

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