"""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