gendesign/tradein-mvp/backend/tests/test_support.py
bot-backend 5eadae1e95 fix(tradein/support): address deep-review H1/M1/M2/M3/M5/L1-L5 (web chat backend)
H1 (critical): reorder send_support_message — get_or_create_thread now runs
AFTER a successful Telegram send, not before. Prod runs a single uvicorn
process with no --workers on a sync SQLAlchemy engine sharing one event
loop; holding a row-lock across the Telegram call (which can legally take
minutes on 429/5xx worker-grade retries) risked freezing the entire API on
a second concurrent request from the same user. send_message now also
accepts explicit timeout/max_retries so the interactive endpoint uses a
bounded budget instead of inheriting the long-polling worker's retry policy.

M1: web_support_messages and tg_support_messages both gain a support_chat_id
column (188 migration for the already-applied tg_support_messages table;
187 edited in place since it hasn't shipped yet). bridge._handle_group_reply
now resolves BOTH the Telegram and web candidate under the CURRENT
support_chat_id and refuses delivery loudly (logger.error) if both match,
instead of silently preferring the Telegram path — closing a cross-table
misroute risk that would surface if the support group is ever recreated.

M2: a media reply to a web-mirrored message (including photos with a
caption) is now refused in full with an explicit notice back in the topic,
instead of silently delivering just the caption text or logging a WARNING
nobody sees.

M3: list_support_messages/get_support_unread/mark_support_read switched
from async def to def — their bodies are pure sync psycopg calls; running
them as async def executed blocking DB work directly on the event loop.

M5: list_messages now takes a bounded LIMIT (last N, chronological) so a
long-lived thread doesn't return its entire history on every poll.

L1-L4: warn when Telegram doesn't return a message_id (routing dead-end),
rate limit is peeked before send and only recorded on success (failed
attempts no longer burn the budget), SlidingWindowLimiter gained the same
empty-bucket cleanup as RateLimitMiddleware, and _require_username now
strips whitespace so a proxy-injected space can't fork a second thread.

L5: removed 187 from _manifest_applied.txt — the deploy pipeline tracks
applied migrations via _schema_migrations, not this file, and keeping an
unmerged migration name out of it preserves the option to rename before
merge without tripping the "can't rename applied migrations" test.
2026-07-26 23:33:09 +03: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