All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 58s
- [MED] phone: маска r"^[+]?[\d\s().-]{5,32}$" делала цифры опциональными
("(()) -- .." проходило) — добавлен field_validator, требующий 10-15
реальных цифр (re.sub(r"\D","")), формат-толерантность сохранена.
- [MED] source: убран мёртвый литерал "landing" из Literal — воронка
недостижима (rbac_guard закрывает /lead 401 без X-Authenticated-User,
публичного лендинг-роута нет), контракт больше не обещает невозможное.
- [LOW] 152-ФЗ: захват client_ip (X-Forwarded-For -> peer) и
_CONSENT_POLICY_VERSION в audit-лог; live-схема trade_in_leads не имеет
колонок под IP/policy/consent-snapshot -> TODO на follow-up миграцию
(out of scope), durable proof-of-consent gap задокументирован.
Тесты: +digit-free/too-few-digits phone 422, +source=landing 422,
estimate-тест переведён на source=result. 9/9 pass, ruff clean.
163 lines
5.3 KiB
Python
163 lines
5.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:
|
|
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},
|
|
)
|
|
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
|
|
|
|
|
|
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
|