All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот доставляет ответ клиенту. Полный лог переписки в Postgres. Отдельный контейнер на long-polling, а не webhook в tradein-backend: не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API. Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage. Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате, поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор. Безопасность (найдено ревью, воспроизведено эмпирически): - токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет; утекал в GlitchTip через locals стек-фреймов (include_local_variables по умолчанию True) и через span data HttpxIntegration. Закрыто include_local_variables=False + regex-редактор в before_send (обе формы: /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub. - httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в docker logs каждые 30с. Приглушён до WARNING. Надёжность: - kill-switch при пустом токене — idle-блокировка, не exit(0): при restart: unless-stopped выход с любым кодом даёт рестарт-луп. unless-stopped выбран сознательно — только он гарантирует автозапуск после ребута VPS. - stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше, чем докрутится long-poll (30с) и отработает drain (100с). - сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset: иначе сессия в failed-transaction не давала сохранить offset, апдейт переигрывался и зеркалился в топик по кругу. 152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента одним DELETE. Ретенция — follow-up. Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS, без них воркер штатно висит в idle. Порядок — в DEPLOY.md. Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked, throttle-окно шапки, redaction токена во всех формах event).
164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""Unit tests for `app.services.tgbot.client.TelegramClient` retry/backoff logic.
|
||
|
||
NEVER calls real Telegram API — httpx.MockTransport only (consistent с
|
||
tests/services/test_dadata.py). `asyncio.sleep` is patched to a no-op so retry
|
||
tests run instantly regardless of configured backoff/retry_after durations.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
from unittest import mock
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||
|
||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||
|
||
|
||
def _install_transport(handler) -> None:
|
||
transport = httpx.MockTransport(handler)
|
||
|
||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||
|
||
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _stop_patches_and_noop_sleep():
|
||
sleep_patcher = mock.patch("app.services.tgbot.client.asyncio.sleep", return_value=None)
|
||
sleep_patcher.start()
|
||
yield
|
||
mock.patch.stopall()
|
||
|
||
|
||
async def test_get_updates_happy_path_returns_list() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/getUpdates")
|
||
return httpx.Response(200, json={"ok": True, "result": [{"update_id": 1}]})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
updates = await client.get_updates(offset=1)
|
||
assert updates == [{"update_id": 1}]
|
||
|
||
|
||
async def test_never_logs_or_leaks_token_in_request_url_host() -> None:
|
||
"""Sanity: token lives only in the path, base host stays api.telegram.org."""
|
||
captured: dict[str, str] = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["url"] = str(request.url)
|
||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="super-secret-token")
|
||
await client.send_message(chat_id=1, text="hi")
|
||
assert "bot" + "super-secret-token" in captured["url"] # goes over the wire, not logged
|
||
|
||
|
||
async def test_copy_message_retries_on_429_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
return httpx.Response(
|
||
429,
|
||
json={
|
||
"ok": False,
|
||
"error_code": 429,
|
||
"description": "Too Many Requests",
|
||
"parameters": {"retry_after": 3},
|
||
},
|
||
)
|
||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 5}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
result = await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||
|
||
assert result == {"message_id": 5}
|
||
assert calls["n"] == 2
|
||
|
||
|
||
async def test_send_message_retries_on_5xx_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 3:
|
||
return httpx.Response(
|
||
502, json={"ok": False, "error_code": 502, "description": "bad gw"}
|
||
)
|
||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 9}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
result = await client.send_message(chat_id=1, text="retrying")
|
||
|
||
assert result == {"message_id": 9}
|
||
assert calls["n"] == 3
|
||
|
||
|
||
async def test_send_message_raises_immediately_on_non_retryable_4xx() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
return httpx.Response(
|
||
403, json={"ok": False, "error_code": 403, "description": "Forbidden: bot blocked"}
|
||
)
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
|
||
with pytest.raises(TelegramApiError) as exc_info:
|
||
await client.send_message(chat_id=1, text="hi")
|
||
|
||
assert exc_info.value.error_code == 403
|
||
assert calls["n"] == 1 # НЕ ретраится
|
||
|
||
|
||
async def test_copy_message_gives_up_after_max_retries_on_persistent_5xx() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(500, json={"ok": False, "error_code": 500, "description": "boom"})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
|
||
with pytest.raises(TelegramApiError) as exc_info:
|
||
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||
|
||
assert exc_info.value.error_code == 500
|
||
|
||
|
||
async def test_get_updates_returns_empty_list_on_malformed_result() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(200, json={"ok": True, "result": "not-a-list"})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
assert await client.get_updates(offset=1) == []
|
||
|
||
|
||
async def test_optional_thread_and_reply_params_omitted_when_falsy() -> None:
|
||
captured: dict[str, Any] = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json as _json
|
||
|
||
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3, message_thread_id=0)
|
||
|
||
assert "message_thread_id" not in captured["body"]
|