"""Тесты фоновой пересылки GlitchTip-алерта в Telegram после отказа синхронной попытки — app/api/v1/glitchtip.py (BackgroundTasks) + app/tasks/glitchtip_alert_retry.py. Контекст (#3471, #3157): GlitchTip вебхуки не ретраит, поэтому отказ синхронной попытки не должен терять текст алерта. 502 на отказ Telegram остаётся как есть (#3456) — проверяем, что он остаётся ОДНОВРЕМЕННО с постановкой фоновой доставки. 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") import asyncio from typing import Any, ClassVar import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from app.api.v1 import glitchtip as glitchtip_module from app.services.tgbot.client import TelegramNetworkError from app.tasks import glitchtip_alert_retry as retry_module _SECRET = "test-shared-secret" _ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook" _ISSUE_PAYLOAD = { "text": "GlitchTip Alert", "attachments": [{"title": "ValueError: something broke", "text": "app/services/foo.py"}], } @pytest.fixture(autouse=True) def _configured(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", _SECRET) monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "fake-token") monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", -1004443088679) monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_topic_id", 158) class _FakeTelegramClient: """Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети. `responses` — очередь: каждый вызов `send_message` берёт следующий элемент (dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не удалась, повторная фоном прошла». """ calls: ClassVar[list[dict[str, Any]]] = [] responses: ClassVar[list[dict[str, Any] | Exception]] = [] def __init__(self, _token: str = "fake-token") -> None: pass async def send_message(self, **kwargs: Any) -> dict[str, Any]: _FakeTelegramClient.calls.append(kwargs) outcome = _FakeTelegramClient.responses.pop(0) if isinstance(outcome, Exception): raise outcome return outcome @pytest.fixture(autouse=True) def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any: _FakeTelegramClient.calls = [] _FakeTelegramClient.responses = [{"message_id": 1}] monkeypatch.setattr(glitchtip_module, "get_telegram_client", lambda: _FakeTelegramClient()) # Ретрай-модуль спит между попытками (_RETRY_DELAY_S=30s) — в тестах не ждём. monkeypatch.setattr(retry_module, "_RETRY_DELAY_S", 0.0) return _FakeTelegramClient @pytest.fixture def client() -> TestClient: app = FastAPI() app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in") return TestClient(app) def _network_error() -> TelegramNetworkError: return TelegramNetworkError("sendMessage", "ConnectTimeout", 4) # ── отказ синхронной попытки → фон + 502 ──────────────────────────────────── def test_sync_failure_queues_background_retry_and_still_returns_502( client: TestClient, _fake_telegram_client: Any ) -> None: """Синхронная попытка не удалась → задача уходит в фон, ответ ОСТАЁТСЯ 502 (#3456 — 502 задуман осознанно, не подменяется молчаливым 200).""" _fake_telegram_client.responses = [_network_error(), {"message_id": 2}] r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) assert r.status_code == 502 # TestClient прогоняет BackgroundTasks синхронно перед возвратом ответа — # к этому моменту фоновая попытка уже отработала: 2 вызова (sync + retry). assert len(_fake_telegram_client.calls) == 2 for call in _fake_telegram_client.calls: assert call["chat_id"] == -1004443088679 assert call["message_thread_id"] == 158 assert "ValueError: something broke" in call["text"] # Переиспользован тот же уже отформатированный текст — не пересобран заново. assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"] def test_sync_success_does_not_queue_background_retry( client: TestClient, _fake_telegram_client: Any ) -> None: """Успешная синхронная отправка НЕ ставит фоновую задачу — ровно один вызов.""" _fake_telegram_client.responses = [{"message_id": 1}] r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) assert r.status_code == 200, r.text assert len(_fake_telegram_client.calls) == 1 # ── retry_forward_alert напрямую: потолок ретраев ─────────────────────────── async def _run_retry(fake_client_cls: Any, responses: list[Any]) -> None: fake_client_cls.responses = list(responses) await retry_module.retry_forward_alert( fake_client_cls(), chat_id=-1, text="алерт", message_thread_id=158 ) def test_retry_succeeds_after_transient_failure(_fake_telegram_client: Any) -> None: asyncio.run(_run_retry(_fake_telegram_client, [_network_error(), {"message_id": 9}])) assert len(_fake_telegram_client.calls) == 2 def test_retry_gives_up_after_max_attempts_and_logs( _fake_telegram_client: Any, caplog: pytest.LogCaptureFixture ) -> None: """На потолке ретраев — сдаётся с ERROR-логом, а не молча и не бесконечно.""" responses = [_network_error() for _ in range(retry_module._MAX_ATTEMPTS)] with caplog.at_level("ERROR", logger=retry_module.logger.name): asyncio.run(_run_retry(_fake_telegram_client, responses)) assert len(_fake_telegram_client.calls) == retry_module._MAX_ATTEMPTS assert any("не удалось доставить" in rec.message for rec in caplog.records)