gendesign/tradein-mvp/backend/tests/test_support.py
lekss361 ca1015bd4e
All checks were successful
Deploy Trade-In / test (push) Successful in 4m59s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 1m17s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
feat(tradein/support): веб-чат поддержки — серверная часть поверх Telegram-моста (#2532)
2026-07-26 20:43:47 +00:00

536 lines
22 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
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):
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"
# Зеркало помечено "С САЙТА" + 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 == []
# ── 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"
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