gendesign/tradein-mvp/backend/tests/test_trade_in_lead.py
bot-backend 56f0cdbdf7
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 21s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m53s
CI Trade-In / backend-tests (pull_request) Successful in 6m56s
CI / backend-tests (pull_request) Successful in 8m25s
fix(mera/lead): о новой заявке узнаёт ответственный, конверсия «оценка → заявка» на дашборде
#1971, два невыполненных пункта DoD.

1. Уведомления не было. Заявка с результата оценки только ложилась в
trade_in_leads (docstring прямо называл уведомление «вне scope»). На проде
17.09: 4 заявки, notified_at пуст у всех, последняя 12.07. Теперь после ответа
клиенту фоновая задача шлёт сообщение в support-топик тем же ботом, что и
веб-чат поддержки, и при успехе ставит notified_at. Отказ Telegram не меняет ни
ответ (200), ни сохранённый лид — только лог. Телефона в сообщении нет: копию в
Telegram не стирает механизм удаления ПДн, поэтому туда идут id заявки,
пользователь и id оценки.

2. Доли заявок от оценок не было нигде. Панель на продуктовом дашборде: лиды за
7 суток / успешные оценки за 7 суток (знаменатель — только outcome=ok: форма
заявки показывается только при посчитанной оценке). Выражение проверено на
боевом Prometheus 17.09: 0 / 91.008 = 0.

Closes #1971

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:27:44 +05:00

372 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
- IDOR guard (security-audit, зеркалит #690/test_estimate_idor.py): estimate_id
чужого пользователя -> 404; admin может привязать любой; нет
X-Authenticated-User -> 401; неизвестная роль -> 403
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, 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)
@pytest.fixture(autouse=True)
def _restore_get_role():
"""Restore app.core.auth.get_role after each test (mirror test_estimate_idor.py)."""
from app.core import auth as auth_mod
original = auth_mod.get_role
yield
auth_mod.get_role = original
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:
"""Owner привязывает лид к своей же оценке -> 200."""
from app.core import auth as auth_mod
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
lead_id = str(uuid4())
exists_result = MagicMock()
exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
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,
},
headers={"X-Authenticated-User": "kopylov"},
)
assert r.status_code == 200, r.text
params = db.execute.call_args.args[1]
assert params["estimate_id"] == estimate_id
assert params["source"] == "result"
# ── IDOR guard (security-audit): estimate_id ownership ─────────────────────────
def test_lead_estimate_id_owned_by_other_user_gets_404(client: TestClient, db: MagicMock) -> None:
"""Чужой estimate_id -> 404 (существование не подтверждаем), лид НЕ создаётся."""
from app.core import auth as auth_mod
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
exists_result = MagicMock()
exists_result.fetchone.return_value = SimpleNamespace(created_by="victim")
db.execute.return_value = exists_result
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
headers={"X-Authenticated-User": "attacker"},
)
assert r.status_code == 404, r.text
assert not db.commit.called
def test_lead_estimate_id_admin_can_attach_any_200(client: TestClient, db: MagicMock) -> None:
"""Admin может привязать лид к чужой оценке (owner-or-admin, зеркалит #690)."""
from app.core import auth as auth_mod
auth_mod.get_role = lambda _u: "admin" # type: ignore[assignment]
lead_id = str(uuid4())
exists_result = MagicMock()
exists_result.fetchone.return_value = SimpleNamespace(created_by="someone_else")
db.execute.side_effect = [exists_result, _insert_result(lead_id)]
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
headers={"X-Authenticated-User": "admin"},
)
assert r.status_code == 200, r.text
def test_lead_estimate_id_requires_authenticated_user_401(
client: TestClient, db: MagicMock
) -> None:
"""estimate_id задан, но нет X-Authenticated-User -> 401 (defense-in-depth: в
проде rbac_guard уже требует заголовок раньше, см. app/main.py)."""
exists_result = MagicMock()
exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
db.execute.return_value = exists_result
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
)
assert r.status_code == 401, r.text
assert not db.commit.called
def test_lead_estimate_id_unknown_role_403(client: TestClient, db: MagicMock) -> None:
"""Аутентифицирован через Caddy, но роль отсутствует в roles.yaml -> 403."""
from app.core import auth as auth_mod
def _raise_keyerror(_u: str):
raise KeyError(_u)
auth_mod.get_role = _raise_keyerror # type: ignore[assignment]
exists_result = MagicMock()
exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
db.execute.return_value = exists_result
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
headers={"X-Authenticated-User": "ghost"},
)
assert r.status_code == 403, r.text
assert not db.commit.called
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
# ── #1971: уведомление ответственному в support-топик ─────────────────────────
_NOTIFY_LEAD_ID = "00000000-0000-0000-0000-000000000001"
@pytest.fixture
def telegram(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
"""Бот настроен, клиент и сессия фоновой задачи подменены."""
from app.api.v1 import lead as lead_module
monkeypatch.setattr(lead_module.settings, "telegram_bot_token", "fake-token")
monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", -100123456789)
monkeypatch.setattr(lead_module.settings, "telegram_support_topic_id", 2)
fake_client = SimpleNamespace(send_message=AsyncMock(return_value={"message_id": 7}))
monkeypatch.setattr(lead_module, "get_telegram_client", lambda: fake_client)
notify_db = MagicMock()
monkeypatch.setattr(lead_module, "SessionLocal", lambda: notify_db)
return SimpleNamespace(client=fake_client, db=notify_db)
def test_lead_notifies_topic_without_phone_and_sets_notified_at(
client: TestClient, db: MagicMock, telegram: SimpleNamespace
) -> None:
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+7 (912) 345-67-89", "consent": True},
headers={"X-Authenticated-User": "alice"},
)
assert r.status_code == 200, r.text
telegram.client.send_message.assert_awaited_once()
kwargs = telegram.client.send_message.await_args.kwargs
assert kwargs["chat_id"] == -100123456789
assert kwargs["message_thread_id"] == 2
assert _NOTIFY_LEAD_ID in kwargs["text"]
assert "alice" in kwargs["text"]
# Телефон в Telegram не уходит ни в каком написании.
assert "9123456789" not in "".join(ch for ch in kwargs["text"] if ch.isdigit())
sql, params = telegram.db.execute.call_args.args
assert "SET notified_at = now()" in str(sql)
assert params == {"id": _NOTIFY_LEAD_ID}
assert telegram.db.commit.called
assert telegram.db.close.called
def test_lead_telegram_failure_keeps_200_and_notified_at_empty(
client: TestClient, db: MagicMock, telegram: SimpleNamespace
) -> None:
from app.services.tgbot.client import TelegramError
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
telegram.client.send_message.side_effect = TelegramError("boom")
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True},
headers={"X-Authenticated-User": "alice"},
)
assert r.status_code == 200, r.text
assert r.json()["id"] == _NOTIFY_LEAD_ID
assert db.commit.called # лид сохранён до попытки уведомления
telegram.client.send_message.assert_awaited_once()
assert not telegram.db.execute.called
def test_lead_bot_not_configured_skips_notification(
client: TestClient,
db: MagicMock,
telegram: SimpleNamespace,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.api.v1 import lead as lead_module
monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", 0)
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True})
assert r.status_code == 200, r.text
telegram.client.send_message.assert_not_awaited()
assert not telegram.db.execute.called