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