gendesign/tradein-mvp/backend/tests/test_support.py
bot-backend 7377fb61e5 feat(tradein/support): веб-чат поддержки поверх Telegram support-моста
Сайт закрыт Caddy basic_auth, тред привязывается к X-Authenticated-User (нет
анонимов). Новые web_support_threads/web_support_messages (миграция 187) —
отдельно от tg_support_* (186): у веб-клиента нет Telegram chat_id, смешение
identity-схем в одной таблице потребовало бы NULLABLE chat_id/username и XOR
CHECK-ограничений без реальной выгоды (обоснование в самой миграции).

API (app/api/v1/support.py, /api/v1/trade-in/support/*):
  POST /messages  — отправка (sendMessage-зеркало в топик, "[С САЙТА] user: ...")
  GET  /messages   — polling своего треда (?since=id)
  GET  /unread     — счётчик непрочитанного
  POST /read       — отметить прочитанным
Тред резолвится ИСКЛЮЧИТЕЛЬНО по username — нет параметра, которым можно
адресовать чужой тред (структурная защита от IDOR, не только access-check).

bridge.py: _handle_group_reply получил ветку резолва reply в web-тред (после
существующего tg-резолва, без изменения Telegram-пути) — оператор отвечает
одинаково, вне зависимости от канала клиента.

Rate-limit: новый SlidingWindowLimiter (ratelimit.py) — 12 msg/60s per user,
жёстче общего RateLimitMiddleware (общий бот-токен, флуд одного клиента иначе
бьёт по доставке всем).

Security: этот процесс (app/main.py) теперь тоже зовёт Telegram Bot API
напрямую (раньше — только изолированный tgbot_main.py) — реплицированы обе
защиты токена: httpx-INFO подавлен, include_local_variables=False +
redact_telegram_bot_token в sentry before_send.

Бот не сконфигурирован (пустой TELEGRAM_BOT_TOKEN/chat_id) → 503, не 500.
2026-07-26 22:58:54 +03:00

433 lines
17 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
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 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
@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)
)
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 _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
_FakeTelegramClient.calls = []
_FakeTelegramClient._response = {"message_id": 555}
monkeypatch.setattr(support_module, "TelegramClient", _FakeTelegramClient)
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
return TestClient(app)
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
# ── 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:
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
recorded = {}
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id):
recorded.update(thread_id=thread_id, text_body=text_body, topic_message_id=topic_message_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"
# Зеркало помечено "С САЙТА" + 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
assert recorded["thread_id"] == 7
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
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")
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
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 == [] # неотправленное сообщение не персистится
# ── 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_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: [
{
"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"
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: [
{
"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