gendesign/tradein-mvp/backend/tests/test_support.py
bot-backend 091befc9ff
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-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 / changes (pull_request) Failing after 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 8m0s
fix(tradein): идемпотентная отправка сообщения в поддержку (#3471)
Сеть Selectel -> api.telegram.org теряет заметную долю коротких запросов,
поэтому браузерный ретрай/двойной клик/переотправка по таймауту при отправке
в веб-чат поддержки создавали ВТОРУЮ строку в web_support_messages И второе
зеркало в support-топике Telegram, а не только дубль в БД.

Ключ идемпотентности (миграция 301, колонка idempotency_key +
partial unique индекс (thread_id, idempotency_key) WHERE direction='in'):
- явный заголовок Idempotency-Key от клиента, если он есть и валидной формы;
- иначе детерминированный fallback-отпечаток sha256(identity|текст|минутное
  окно) — старые клиенты без заголовка продолжают работать без изменений.

Pre-check резолвит тред по identity и ищет существующее inbound-сообщение с
этим ключом ДО похода в Telegram (не только до записи в БД) — иначе повтор
всё равно отправил бы второе зеркало, даже если бы вторая строка в БД не
создавалась. Гонку двух одновременных запросов с одним ключом закрывает
INSERT ... ON CONFLICT DO NOTHING на уникальном индексе в
web_support_storage.record_inbound (не read-then-write), а не сам pre-check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:59:52 +03:00

1223 lines
54 KiB
Python
Raw 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-тесты веб-чата поддержки (#tgsupport-web) —
POST/GET /api/v1/trade-in/support/{messages,unread,read}.
Storage-слой (`app.services.tgbot.web_support_storage`) мокается целиком (как
mock'ается DB в test_trade_in_lead.py) — эти тесты проверяют РОУТЕР: изоляцию
тредов (нет параметра, которым можно адресовать чужой тред), валидацию
входа, поведение при несконфигурированном боте, rate-limit и то, что реплай
парсится в `bridge.py` в web-ветку (см. tests/services/tgbot/test_bridge.py —
маршрутизация реплая тестируется ТАМ; здесь — только HTTP-контракт отправки/
чтения).
NEVER touches real DB / real Telegram API.
"""
from __future__ import annotations
import os
import time
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from typing import Any, ClassVar
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.exc import SQLAlchemyError
from app.api.v1 import support as support_module
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter
from app.services.tgbot.client import TelegramApiError, TelegramNetworkError
@pytest.fixture(autouse=True)
def _bot_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""По умолчанию бот считается сконфигурированным — отдельные тесты переопределяют."""
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "fake-token")
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", -100123456789)
monkeypatch.setattr(support_module.settings, "telegram_support_topic_id", 42)
@pytest.fixture(autouse=True)
def _fresh_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Модульный `_send_limiter` иначе накапливает состояние МЕЖДУ тестами (один
процесс pytest) — свежий лимитер на каждый тест, щедрый дефолт (rate-limit
тестируется отдельно на СВОЁМ, явно узком экземпляре)."""
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
)
@pytest.fixture(autouse=True)
def _fresh_failure_limiters(monkeypatch: pytest.MonkeyPatch) -> None:
"""То же, что `_fresh_rate_limiter`, но для счётчиков ОТКАЗОВ отправки: они
модульные, и тесты, специально роняющие Telegram, иначе оставляли бы cooldown
следующим тестам в том же процессе."""
for name in ("_send_failure_limiter", "_anon_ip_failure_limiter"):
monkeypatch.setattr(support_module, name, SlidingWindowLimiter(limit=1000, window_s=30.0))
class _FakeTelegramClient:
"""Подменяет `TelegramClient` внутри `support` модуля — никакого httpx/сети."""
calls: ClassVar[list[dict[str, Any]]] = []
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 555}
def __init__(self, _token: str) -> None:
pass
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
_FakeTelegramClient.calls.append(kwargs)
if isinstance(_FakeTelegramClient._response, Exception):
raise _FakeTelegramClient._response
return _FakeTelegramClient._response
@pytest.fixture(autouse=True)
def _no_existing_idempotent_message(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолт для тестов, которые не проверяют идемпотентность (#3471) напрямую:
без этого автостаба pre-check в `support.py` дёргал бы РЕАЛЬНУЮ
storage-функцию против MagicMock `db` в каждом тесте, где замокан
`find_thread_id` для своих целей (rate-limit / DB-failure / anon-сценарии
и т.д.) — падало бы на MagicMock-результате, никак не связанное с тем, что
тест на самом деле проверяет. Тесты идемпотентности переопределяют это
через `_install_fake_storage` ниже."""
monkeypatch.setattr(
support_module.storage, "find_inbound_by_idempotency_key", lambda *a, **kw: None
)
@pytest.fixture(autouse=True)
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
_FakeTelegramClient.calls = []
_FakeTelegramClient._response = {"message_id": 555}
# Ручка берёт ОБЩИЙ клиент приложения (#tg-connection-resilience), а не
# создаёт свой на запрос — подменяем аксессор, а не класс.
monkeypatch.setattr(
support_module, "get_telegram_client", lambda: _FakeTelegramClient("fake-token")
)
return _FakeTelegramClient
@pytest.fixture
def db() -> MagicMock:
return MagicMock()
@pytest.fixture
def client(db: MagicMock) -> TestClient:
app = FastAPI()
app.include_router(support_module.router, prefix="/api/v1/trade-in")
def fake_db() -> Any:
yield db
app.dependency_overrides[get_db] = fake_db
# https, а не дефолтный http: анонимная ветка ставит идентити-куку с
# `secure=True` (как session-cookie), и по http httpx её не вернул бы в
# следующем запросе — тесты «тот же тред / тот же бюджет лимита» тихо
# проверяли бы каждый раз НОВОГО анонима. Прод и так только https.
return TestClient(app, base_url="https://testserver")
def _auth(username: str = "alice") -> dict[str, str]:
return {"x-authenticated-user": username}
# ── auth guard ────────────────────────────────────────────────────────────────
def test_send_message_without_auth_header_401(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"})
assert r.status_code == 401
def test_list_messages_without_auth_header_401(client: TestClient) -> None:
r = client.get("/api/v1/trade-in/support/messages")
assert r.status_code == 401
def test_whitespace_only_auth_header_401(client: TestClient) -> None:
"""review L4: заголовок из одних пробелов после `.strip()` пуст — не должен
считаться валидным auth (не проваливается тихо в "" как username)."""
r = client.get("/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " "})
assert r.status_code == 401
def test_auth_header_with_surrounding_whitespace_is_stripped(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""review L4: лишний пробел от прокси не должен заводить ВТОРОЙ тред для, по
сути, того же пользователя — username резолвится в тред по .strip()-нутому
значению."""
seen_usernames = []
def fake_find_thread_id(db, username):
seen_usernames.append(username)
return None
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
r = client.get(
"/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " alice "}
)
assert r.status_code == 200
assert seen_usernames == ["alice"]
# ── validation ────────────────────────────────────────────────────────────────
def test_send_message_blank_text_422(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
called = []
monkeypatch.setattr(
support_module.storage, "get_or_create_thread", lambda *a, **kw: called.append(1) or 1
)
r = client.post("/api/v1/trade-in/support/messages", json={"text": " "}, headers=_auth())
assert r.status_code == 422, r.text
assert called == [] # ничего не персистится на невалидном вводе
def test_send_message_empty_text_422(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/support/messages", json={"text": ""}, headers=_auth())
assert r.status_code == 422
def test_send_message_too_long_422(client: TestClient) -> None:
too_long = "a" * (support_module.MAX_MESSAGE_LENGTH + 1)
r = client.post("/api/v1/trade-in/support/messages", json={"text": too_long}, headers=_auth())
assert r.status_code == 422
def test_send_message_at_max_length_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda *a, **kw: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
at_limit = "a" * support_module.MAX_MESSAGE_LENGTH
r = client.post("/api/v1/trade-in/support/messages", json={"text": at_limit}, headers=_auth())
assert r.status_code == 200, r.text
# ── bot not configured ──────────────────────────────────────────────────────
def test_send_message_bot_token_empty_returns_503_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
thread_created = []
monkeypatch.setattr(
support_module.storage, "get_or_create_thread", lambda *a, **kw: thread_created.append(1)
)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 503
assert r.status_code != 500
assert thread_created == [] # ничего не создаём/не пишем, если зеркалировать некуда
def test_send_message_support_chat_id_unset_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", 0)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 503
# ── happy path + mirror content ──────────────────────────────────────────────
def test_send_message_happy_path_mirrors_with_website_marker(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
thread_calls: list[str] = []
def fake_get_or_create_thread(db, username):
thread_calls.append(username)
return 7
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create_thread)
recorded = {}
def fake_record_inbound(
db, *, thread_id, text_body, topic_message_id, support_chat_id, idempotency_key=None
):
recorded.update(
thread_id=thread_id,
text_body=text_body,
topic_message_id=topic_message_id,
support_chat_id=support_chat_id,
)
return {
"id": 100,
"direction": "in",
"text_body": text_body,
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
monkeypatch.setattr(support_module.storage, "record_inbound", fake_record_inbound)
r = client.post(
"/api/v1/trade-in/support/messages",
json={"text": "У меня вопрос про trade-in"},
headers=_auth("kopylov"),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == 100
assert body["direction"] == "in"
# Успешный путь ничего не знает про флаг — дефолт `True` (#persisted).
assert body["persisted"] is True
# Зеркало помечено "С САЙТА" + username — оператор не путает с TG-клиентом.
assert len(_fake_telegram_client.calls) == 1
mirror_call = _fake_telegram_client.calls[0]
assert "С САЙТА" in mirror_call["text"]
assert "kopylov" in mirror_call["text"]
assert "У меня вопрос про trade-in" in mirror_call["text"]
assert mirror_call["chat_id"] == support_module.settings.telegram_support_chat_id
assert mirror_call["message_thread_id"] == 42
# review H1: интерактивный узкий бюджет ретраев/timeout, не воркерный дефолт.
assert mirror_call["max_retries"] == support_module._INTERACTIVE_SEND_MAX_RETRIES
assert mirror_call["timeout"] == support_module._INTERACTIVE_SEND_TIMEOUT_S
assert recorded["thread_id"] == 7
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
assert recorded["support_chat_id"] == support_module.settings.telegram_support_chat_id
assert thread_calls == ["kopylov"]
assert db.commit.called
def test_send_message_telegram_failure_returns_502_and_does_not_persist(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
_fake_telegram_client._response = TelegramApiError("sendMessage", 400, "chat not found")
thread_created = []
monkeypatch.setattr(
support_module.storage,
"get_or_create_thread",
lambda db, username: thread_created.append(1),
)
record_called = []
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: record_called.append(1),
)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 502
assert record_called == [] # неотправленное сообщение не персистится
# review H1: thread создаётся ПОСЛЕ успешной отправки — на неудаче до БД не доходит вообще.
assert thread_created == []
def test_send_message_telegram_unreachable_returns_502_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Пользователь не должен получить 500 из-за таймаута до Telegram (#3456).
Отказ площадки уже отдавал задуманный 502, а вот её недоступность летела
сырым `httpx.ConnectTimeout` мимо `except TelegramApiError` — FastAPI
превращал это в 500, и в GlitchTip падала ошибка сервера вместо внятного
«сервис временно недоступен».
"""
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
record_called = []
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: record_called.append(1),
)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 502, "недоступный Telegram снова отдаёт 500"
assert record_called == []
# ── rate limit ────────────────────────────────────────────────────────────────
def test_send_message_rate_limited_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
first = client.post("/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth())
assert first.status_code == 200, first.text
second = client.post("/api/v1/trade-in/support/messages", json={"text": "two"}, headers=_auth())
assert second.status_code == 429
assert "Retry-After" in second.headers
def test_send_message_failed_attempts_do_not_consume_rate_limit(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""review L3: неудачная отправка НЕ должна расходовать rate-limit бюджет —
иначе клиент, которому не повезло с транзиентной Telegram-ошибкой, терял бы
попытки, не доставив НИ ОДНОГО сообщения."""
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
for _ in range(3):
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 502
# "Телеграм" снова работает — бюджет (лимит=1) всё ещё цел, ни одна неудачная
# попытка выше его не тронула.
_fake_telegram_client._response = {"message_id": 555}
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
ok = client.post("/api/v1/trade-in/support/messages", json={"text": "ok"}, headers=_auth())
assert ok.status_code == 200, ok.text
def test_send_message_rate_limit_is_per_user(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
alice = client.post(
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("alice")
)
assert alice.status_code == 200
bob = client.post(
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("bob")
)
assert bob.status_code == 200 # свой ключ лимита — не затронут alice-лимитом
# ── thread isolation (own thread only, no thread_id param exists) ───────────
def test_list_messages_resolves_by_own_username_ignores_foreign_params(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Никакого способа адресовать чужой тред: даже если запрос несёт посторонний
`thread_id`/`username` в query — эндпоинт их не читает, резолвит ИСКЛЮЧИТЕЛЬНО
по X-Authenticated-User."""
seen_usernames = []
def fake_find_thread_id(db, username):
seen_usernames.append(username)
return None
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
r = client.get(
"/api/v1/trade-in/support/messages",
params={"thread_id": 999, "username": "someone-else", "since": 0},
headers=_auth("alice"),
)
assert r.status_code == 200
assert r.json() == []
assert seen_usernames == ["alice"] # username из заголовка — параметры query проигнорированы
def test_list_messages_returns_thread_scoped_rows(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
monkeypatch.setattr(
support_module.storage,
"list_messages",
lambda db, *, thread_id, since_id, limit: (
[
{
"id": 1,
"direction": "in",
"text_body": "hi",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
]
if thread_id == 7
else []
),
)
r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice"))
assert r.status_code == 200
body = r.json()
assert len(body) == 1
assert body[0]["text_body"] == "hi"
# GET строит модель из storage-строки и про флаг не знает — дефолт `True`.
assert body[0]["persisted"] is True
def test_list_messages_passes_bounded_limit_to_storage(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""review M5: без LIMIT каждое монтирование виджета отдавало бы весь тред."""
seen_limits = []
def fake_list_messages(db, *, thread_id, since_id, limit):
seen_limits.append(limit)
return []
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
monkeypatch.setattr(support_module.storage, "list_messages", fake_list_messages)
r = client.get("/api/v1/trade-in/support/messages", headers=_auth())
assert r.status_code == 200
assert seen_limits == [support_module._LIST_MESSAGES_LIMIT]
def test_two_users_get_independent_threads(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
thread_by_user = {"alice": 1, "bob": 2}
monkeypatch.setattr(
support_module.storage, "find_thread_id", lambda db, username: thread_by_user.get(username)
)
monkeypatch.setattr(
support_module.storage,
"list_messages",
lambda db, *, thread_id, since_id, limit: [
{
"id": 1,
"direction": "in",
"text_body": f"secret-of-thread-{thread_id}",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
],
)
alice_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("alice")).json()
bob_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("bob")).json()
assert alice_resp[0]["text_body"] == "secret-of-thread-1"
assert bob_resp[0]["text_body"] == "secret-of-thread-2"
assert alice_resp != bob_resp
# ── unread / read ─────────────────────────────────────────────────────────────
def test_unread_no_thread_returns_zero_without_querying_count(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
count_called = []
monkeypatch.setattr(
support_module.storage, "count_unread", lambda *a, **kw: count_called.append(1)
)
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
assert r.status_code == 200
assert r.json() == {"unread": 0}
assert count_called == []
def test_unread_delegates_to_storage(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
monkeypatch.setattr(support_module.storage, "count_unread", lambda db, *, thread_id: 3)
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
assert r.status_code == 200
assert r.json() == {"unread": 3}
def test_mark_read_noop_when_no_thread(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
mark_called = []
monkeypatch.setattr(support_module.storage, "mark_read", lambda *a, **kw: mark_called.append(1))
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
assert r.status_code == 200
assert r.json() == {"status": "ok"}
assert mark_called == []
def test_mark_read_calls_storage_when_thread_exists(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
mark_called = []
monkeypatch.setattr(
support_module.storage, "mark_read", lambda db, *, thread_id: mark_called.append(thread_id)
)
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
assert r.status_code == 200
assert mark_called == [7]
assert db.commit.called
# ── анонимная ветка: поддержка без входа (инцидент 2026-07-31) ────────────────
@pytest.fixture(autouse=True)
def _fresh_anon_ip_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Как `_fresh_rate_limiter`, но для per-IP бюджета анонимной ветки — иначе
состояние течёт между тестами в одном процессе pytest."""
monkeypatch.setattr(
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
)
def _patch_anon_storage(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Мокает storage для send-пути и возвращает список ключей тредов, с которыми
его позвали (проверяем, что аноним адресуется `anon:<token>`, а не логином)."""
seen_keys: list[str] = []
def fake_get_or_create(db: Any, username: str) -> int:
seen_keys.append(username)
return 1
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-31T00:00:00+00:00",
},
)
return seen_keys
def test_anon_send_without_any_auth_succeeds_and_sets_cookie(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Суть фичи: залогиниться нельзя, а написать в поддержку — можно."""
seen_keys = _patch_anon_storage(monkeypatch)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "не могу войти"})
assert r.status_code == 200, r.text
assert r.json()["text_body"] == "не могу войти"
assert r.json()["persisted"] is True
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
assert token is not None
assert support_module._ANON_TOKEN_RE.match(token)
# Тред адресован анонимным ключом, не голым токеном и не чьим-то логином.
assert seen_keys == [f"anon:{token}"]
def test_anon_cookie_reused_across_messages_same_thread(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
seen_keys = _patch_anon_storage(monkeypatch)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "первое"})
token_after_first = client.cookies.get(support_module._ANON_COOKIE_NAME)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "второе"})
assert client.cookies.get(support_module._ANON_COOKIE_NAME) == token_after_first
assert seen_keys == [f"anon:{token_after_first}"] * 2
def test_anon_mirror_is_labelled_and_never_leaks_token(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Оператор видит, что это НЕзалогиненный посетитель, но bearer треда в
Telegram-топик не уходит (топик читают люди и пересылают дальше)."""
_patch_anon_storage(monkeypatch)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "помогите"})
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
sent_text = _fake_telegram_client.calls[-1]["text"]
assert sent_text.startswith("[С САЙТА · БЕЗ ВХОДА] anon-")
assert "помогите" in sent_text
assert token not in sent_text
assert support_module._anon_display_id(token) in sent_text
def test_anon_read_paths_without_cookie_are_empty_not_401(client: TestClient) -> None:
"""Виджет поллит эти ручки ДО первого сообщения — 401 там был бы ложной ошибкой."""
assert client.get("/api/v1/trade-in/support/anon/messages").status_code == 200
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
assert client.get("/api/v1/trade-in/support/anon/unread").json() == {"unread": 0}
assert client.post("/api/v1/trade-in/support/anon/read").json() == {"status": "ok"}
def test_anon_malformed_cookie_ignored_and_never_reaches_storage(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Кука клиент-контролируема: мусор из браузера не должен становиться ключом
треда. Считаем куку отсутствующей и выдаём новую."""
seen_keys = _patch_anon_storage(monkeypatch)
bogus = "not-a-valid-token!@#$%^"
client.cookies.set(support_module._ANON_COOKIE_NAME, bogus)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 200
assert len(seen_keys) == 1
assert bogus not in seen_keys[0]
assert seen_keys[0].startswith("anon:")
assert support_module._ANON_TOKEN_RE.match(seen_keys[0].removeprefix("anon:"))
def test_anon_read_path_with_malformed_cookie_returns_empty(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
find_calls = []
monkeypatch.setattr(
support_module.storage,
"find_thread_id",
lambda db, username: find_calls.append(username),
)
client.cookies.set(support_module._ANON_COOKIE_NAME, "!!not-a-token!!")
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
assert find_calls == [] # до storage мусор не доехал вообще
def test_anon_per_ip_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""Главный анти-абуз: per-token бюджет обходится сбросом куки, per-IP — нет."""
_patch_anon_storage(monkeypatch)
monkeypatch.setattr(
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
)
# Ротация куки НЕ спасает — бюджет привязан к IP.
client.cookies.delete(support_module._ANON_COOKIE_NAME)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"})
assert r.status_code == 429
assert "Retry-After" in r.headers
def test_anon_per_token_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_patch_anon_storage(monkeypatch)
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}).status_code == 429
)
def test_anon_failed_send_sets_no_cookie_and_writes_nothing(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Неудачная отправка не должна закреплять за посетителем пустой тред."""
seen_keys = _patch_anon_storage(monkeypatch)
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 502
assert seen_keys == []
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
def test_anon_telegram_unreachable_returns_502_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Тот же контракт на анонимной ручке — она открыта наружу без авторизации."""
seen_keys = _patch_anon_storage(monkeypatch)
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 502, "недоступный Telegram снова отдаёт 500"
assert seen_keys == []
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
def test_anon_bot_not_configured_503(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 503
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
def test_anon_blank_text_422(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": " "})
assert r.status_code == 422
def test_anon_thread_key_cannot_collide_with_real_username() -> None:
"""Инвариант изоляции: `anon:` невозможен в реальном логине (CHECK миграции
193 + Pydantic `^[A-Za-z0-9._-]{3,64}$`), значит аноним структурно не может
попасть в тред существующего пользователя."""
from app.schemas.team import _USERNAME_RE
key = support_module._anon_thread_key(support_module.secrets.token_urlsafe(18))
assert key.startswith("anon:")
assert _USERNAME_RE.match(key) is None
# ── отказ БД ПОСЛЕ успешной доставки в топик ─────────────────────────────────
#
# Порядок «сначала Telegram, потом БД» (H1) означает, что SQLAlchemyError здесь
# = сообщение оператору уже доставлено. 500 на это — худший вариант: клиент
# повторяет, в топике дубль, а на осиротевшее зеркало оператор отвечает в пустоту.
def _patch_storage_ok(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-09-12T00:00:00+00:00",
},
)
def test_send_message_db_failure_after_delivery_returns_success_and_warns_operator(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
_patch_storage_ok(monkeypatch)
db.commit.side_effect = SQLAlchemyError("connection lost")
r = client.post(
"/api/v1/trade-in/support/messages", json={"text": "секретный вопрос"}, headers=_auth()
)
# Клиенту НЕ врём про отказ: сообщение оператору правда доставлено, повтор
# создал бы дубль в топике.
assert r.status_code == 200, r.text
body = r.json()
assert body["text_body"] == "секретный вопрос"
assert body["id"] == 0 # сентинел «не персистировано», курсор не завышаем
# Контракт для клиента — именно флаг: без него 200 неотличим от тишины
# (фронт рендерит транскрипт только из GET, где сообщения нет) и клиент
# шлёт повтор → дубль в топике, ровно то, против чего вся правка.
assert body["persisted"] is False
assert db.rollback.called
# Оператор предупреждён реплаем к только что доставленному зеркалу.
assert len(_fake_telegram_client.calls) == 2
warning = _fake_telegram_client.calls[-1]
assert warning["reply_to_message_id"] == 555
assert "НЕ записано в базу" in warning["text"]
# Оператор не должен ждать повтора: клиенту сказано, что сообщение принято.
assert "Повтора тоже не ждите" in warning["text"]
# Служебное уведомление не дублирует текст обращения (ПДн).
assert "секретный вопрос" not in warning["text"]
def test_send_message_db_failure_survives_failed_operator_warning(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Отказ уведомления оператору поверх отказа БД не должен становиться 500
поверх уже доставленного сообщения."""
_patch_storage_ok(monkeypatch)
db.commit.side_effect = SQLAlchemyError("connection lost")
class _FlakyClient:
calls = 0
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
_FlakyClient.calls += 1
if _FlakyClient.calls == 1:
return {"message_id": 555} # зеркало доставлено
raise TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
monkeypatch.setattr(support_module, "get_telegram_client", _FlakyClient)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 200, r.text
assert _FlakyClient.calls == 2
def test_anon_db_failure_after_delivery_returns_success_and_sets_cookie(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Аноним: кука обязана пережить сбой БД — иначе следующее сообщение заведёт
ВТОРОЙ тред и переписка разъедется на два."""
_patch_anon_storage(monkeypatch)
db.commit.side_effect = SQLAlchemyError("connection lost")
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "не могу войти"})
assert r.status_code == 200, r.text
assert r.json()["id"] == 0
assert r.json()["persisted"] is False
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
assert token is not None and support_module._ANON_TOKEN_RE.match(token)
warning = _fake_telegram_client.calls[-1]
assert warning["reply_to_message_id"] == 555
assert "НЕ записано в базу" in warning["text"]
assert token not in warning["text"] # bearer треда в топик не уходит
def test_send_message_db_failure_without_topic_message_id_still_warns(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Нет message_id → реплай невозможен, но уведомление всё равно уходит
отдельным сообщением в топик: хуже реплая, но лучше тишины."""
_patch_storage_ok(monkeypatch)
_fake_telegram_client._response = {} # sendMessage без message_id
db.commit.side_effect = SQLAlchemyError("connection lost")
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 200, r.text
assert _fake_telegram_client.calls[-1]["reply_to_message_id"] is None
# ── cooldown по отказам отправки ─────────────────────────────────────────────
#
# Дефект: `retry_after()` — peek, `record()` только на успехе, значит пока
# Telegram лежит, лимита нет ВООБЩЕ, и каждый повтор стоит до 4 попыток к
# api.telegram.org, не расходуя ни один бюджет.
def test_send_failures_trigger_cooldown_and_stop_reaching_telegram(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(
support_module, "_send_failure_limiter", SlidingWindowLimiter(limit=2, window_s=30.0)
)
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
for _ in range(2):
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth()
).status_code
== 502
)
assert len(_fake_telegram_client.calls) == 2
blocked = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert blocked.status_code == 429
assert int(blocked.headers["Retry-After"]) >= 1
# Главное: до Telegram запрос не дошёл вообще.
assert len(_fake_telegram_client.calls) == 2
def test_send_failure_cooldown_is_per_key(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Cooldown одного пользователя не должен задевать другого."""
monkeypatch.setattr(
support_module, "_send_failure_limiter", SlidingWindowLimiter(limit=1, window_s=30.0)
)
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth("alice")
).status_code
== 502
)
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth("alice")
).status_code
== 429
)
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth("bob")
).status_code
== 502
)
def test_send_failure_cooldown_expires_with_window(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""После окончания окна cooldown снимается сам — пользователя не наказывают
после восстановления Telegram."""
monkeypatch.setattr(
support_module, "_send_failure_limiter", SlidingWindowLimiter(limit=1, window_s=0.2)
)
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth()
).status_code
== 502
)
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth()
).status_code
== 429
)
time.sleep(0.25)
_fake_telegram_client._response = {"message_id": 555}
_patch_storage_ok(monkeypatch)
ok = client.post("/api/v1/trade-in/support/messages", json={"text": "ok"}, headers=_auth())
assert ok.status_code == 200, ok.text
def test_successful_send_resets_failure_counter(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Счётчик считает ПОДРЯД идущие отказы: успех стирает историю, иначе редкие
транзиентные отказы копились бы в cooldown на живом канале."""
monkeypatch.setattr(
support_module, "_send_failure_limiter", SlidingWindowLimiter(limit=2, window_s=30.0)
)
_patch_storage_ok(monkeypatch)
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth()
).status_code
== 502
)
_fake_telegram_client._response = {"message_id": 555}
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "ok"}, headers=_auth()
).status_code
== 200
)
# Ещё один отказ — всего второй в окне, но ПЕРВЫЙ подряд: cooldown не встаёт.
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
assert (
client.post(
"/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth()
).status_code
== 502
)
def test_anon_send_failures_trigger_cooldown_per_ip(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Обход ротацией куки: новый токен — новый per-token счётчик, но per-IP
счётчик отказов тот же."""
_patch_anon_storage(monkeypatch)
monkeypatch.setattr(
support_module, "_anon_ip_failure_limiter", SlidingWindowLimiter(limit=1, window_s=30.0)
)
_fake_telegram_client._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 502
)
client.cookies.clear() # «сбросил куку» — по токену бюджет снова пуст
calls_before = len(_fake_telegram_client.calls)
blocked = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"})
assert blocked.status_code == 429
assert len(_fake_telegram_client.calls) == calls_before # до Telegram не дошло
# ── idempotency (#3471) ──────────────────────────────────────────────────────
class _FakeIdempotentStorage:
"""In-memory stand-in для `web_support_storage`, достаточный чтобы точно
воспроизвести идемпотентный контракт (thread_id, idempotency_key) БЕЗ
настоящей БД: unique-конфликт на повторном ключе внутри `record_inbound`
(см. миграцию 301 и storage.record_inbound docstring)."""
def __init__(self) -> None:
self.threads: dict[str, int] = {}
self.messages: list[dict[str, Any]] = []
self._next_id = 1
def find_thread_id(self, db: Any, username: str) -> int | None:
return self.threads.get(username)
def get_or_create_thread(self, db: Any, username: str) -> int:
if username not in self.threads:
self.threads[username] = len(self.threads) + 1
return self.threads[username]
def find_inbound_by_idempotency_key(
self, db: Any, *, thread_id: int, idempotency_key: str
) -> dict[str, Any] | None:
for m in self.messages:
if m["_thread_id"] == thread_id and m["_idempotency_key"] == idempotency_key:
return {k: v for k, v in m.items() if not k.startswith("_")}
return None
def record_inbound(
self,
db: Any,
*,
thread_id: int,
text_body: str,
topic_message_id: int | None,
support_chat_id: int | None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
# Тот же контракт, что и настоящий `INSERT ... ON CONFLICT DO NOTHING`:
# конфликт по (thread_id, idempotency_key) отдаёт УЖЕ существующую строку.
if idempotency_key is not None:
existing = self.find_inbound_by_idempotency_key(
db, thread_id=thread_id, idempotency_key=idempotency_key
)
if existing is not None:
return existing
row = {
"id": self._next_id,
"direction": "in",
"text_body": text_body,
"operator_tg_id": None,
"created_at": "2026-09-12T00:00:00+00:00",
"_thread_id": thread_id,
"_idempotency_key": idempotency_key,
}
self._next_id += 1
self.messages.append(row)
return {k: v for k, v in row.items() if not k.startswith("_")}
def _install_fake_storage(monkeypatch: pytest.MonkeyPatch) -> _FakeIdempotentStorage:
fake = _FakeIdempotentStorage()
monkeypatch.setattr(support_module.storage, "find_thread_id", fake.find_thread_id)
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake.get_or_create_thread)
monkeypatch.setattr(
support_module.storage,
"find_inbound_by_idempotency_key",
fake.find_inbound_by_idempotency_key,
)
monkeypatch.setattr(support_module.storage, "record_inbound", fake.record_inbound)
return fake
def test_repeat_send_with_same_idempotency_header_returns_same_id_single_mirror(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Повтор с тем же `Idempotency-Key` — тот же id, ОДНО зеркало в Telegram
(не только одна строка в БД, см. #3471 требование п.3)."""
_install_fake_storage(monkeypatch)
headers = {**_auth("kopylov"), "Idempotency-Key": "retry-abc123"}
r1 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "первое сообщение"}, headers=headers
)
r2 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "первое сообщение"}, headers=headers
)
assert r1.status_code == 200, r1.text
assert r2.status_code == 200, r2.text
assert r1.json()["id"] == r2.json()["id"]
# Ключевая проверка: повтор НЕ ушёл в Telegram второй раз.
assert len(_fake_telegram_client.calls) == 1
def test_send_with_different_idempotency_keys_creates_different_messages(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
_install_fake_storage(monkeypatch)
r1 = client.post(
"/api/v1/trade-in/support/messages",
json={"text": "вопрос один"},
headers={**_auth("kopylov"), "Idempotency-Key": "key-one-111"},
)
r2 = client.post(
"/api/v1/trade-in/support/messages",
json={"text": "вопрос два"},
headers={**_auth("kopylov"), "Idempotency-Key": "key-two-222"},
)
assert r1.status_code == 200, r1.text
assert r2.status_code == 200, r2.text
assert r1.json()["id"] != r2.json()["id"]
assert len(_fake_telegram_client.calls) == 2
def test_repeat_send_without_idempotency_header_still_dedupes_via_fallback_window(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Требование п.5: отсутствие явного ключа не должно ломать старых клиентов —
сервер сам считает детерминированный fallback-отпечаток (thread+текст+минутное
окно), так что тот же повтор ТЕМ ЖЕ клиентом без заголовка тоже не создаёт
дубль. Время фиксируем, чтобы не зависеть от границы минутного окна в CI."""
_install_fake_storage(monkeypatch)
monkeypatch.setattr(support_module.time, "time", lambda: 1_800_000_000.0)
r1 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "не могу войти"}, headers=_auth("bob")
)
r2 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "не могу войти"}, headers=_auth("bob")
)
assert r1.status_code == 200, r1.text
assert r2.status_code == 200, r2.text
assert r1.json()["id"] == r2.json()["id"]
assert len(_fake_telegram_client.calls) == 1
def test_send_without_idempotency_header_first_call_still_succeeds(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Требование п.5 (не ломает старых клиентов): один запрос без заголовка
отрабатывает как раньше — 200, тред создан, зеркало отправлено ровно раз."""
_install_fake_storage(monkeypatch)
r = client.post(
"/api/v1/trade-in/support/messages", json={"text": "просто вопрос"}, headers=_auth("carol")
)
assert r.status_code == 200, r.text
assert r.json()["persisted"] is True
assert len(_fake_telegram_client.calls) == 1