import json from typing import Any from unittest.mock import AsyncMock import pytest from fastapi import HTTPException from starlette.requests import Request from app.config import settings from app.webserver.telegram import ( TelegramWebhookProcessor, create_telegram_router, ) @pytest.fixture(autouse=True) def reset_webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "WEBHOOK_PATH", "/telegram-webhook", raising=False) monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "", raising=False) monkeypatch.setattr(settings, "WEBHOOK_URL", None, raising=False) monkeypatch.setattr(settings, "BOT_RUN_MODE", "webhook", raising=False) monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 8, raising=False) monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 1, raising=False) monkeypatch.setattr(settings, "WEBHOOK_ENQUEUE_TIMEOUT", 0.0, raising=False) monkeypatch.setattr(settings, "WEBHOOK_WORKER_SHUTDOWN_TIMEOUT", 1.0, raising=False) def _get_route(router, path: str, method: str = "POST"): for route in router.routes: if getattr(route, "path", "") == path and method in getattr(route, "methods", set()): return route raise AssertionError(f"Route {path} with method {method} not found") def _build_request(path: str, body: bytes, headers: dict[str, str] | None = None) -> Request: scope = { "type": "http", "asgi": {"version": "3.0"}, "method": "POST", "path": path, "headers": [ (k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in (headers or {}).items() ], } async def receive() -> dict[str, Any]: return {"type": "http.request", "body": body, "more_body": False} return Request(scope, receive) def _webhook_path() -> str: return settings.get_telegram_webhook_path() @pytest.mark.anyio async def test_webhook_without_secret() -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() sample_update = { "update_id": 123, "message": { "message_id": 10, "date": 1715700000, "chat": {"id": 456, "type": "private"}, "text": "ping", }, } router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) request = _build_request(path, json.dumps(sample_update).encode("utf-8")) response = await route.endpoint(request) assert response.status_code == 200 dispatcher.feed_update.assert_awaited_once() args, _kwargs = dispatcher.feed_update.await_args assert args[0] is bot @pytest.mark.anyio async def test_webhook_with_secret(monkeypatch: pytest.MonkeyPatch) -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "super-secret", raising=False) sample_update = { "update_id": 321, "message": { "message_id": 20, "date": 1715700000, "chat": {"id": 789, "type": "private"}, "text": "pong", }, } router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) request = _build_request( path, json.dumps(sample_update).encode("utf-8"), headers={"X-Telegram-Bot-Api-Secret-Token": "super-secret"}, ) response = await route.endpoint(request) assert response.status_code == 200 dispatcher.feed_update.assert_awaited_once() @pytest.mark.anyio async def test_webhook_secret_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "expected", raising=False) router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) request = _build_request( path, json.dumps({"update_id": 1}).encode("utf-8"), headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"}, ) with pytest.raises(HTTPException) as exc: await route.endpoint(request) assert exc.value.status_code == 401 dispatcher.feed_update.assert_not_called() @pytest.mark.anyio async def test_webhook_invalid_payload() -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) request = _build_request(path, b"not-json") with pytest.raises(HTTPException) as exc: await route.endpoint(request) assert exc.value.status_code == 400 dispatcher.feed_update.assert_not_called() @pytest.mark.anyio async def test_webhook_invalid_content_type() -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() sample_update = { "update_id": 123, "message": { "message_id": 10, "date": 1715700000, "chat": {"id": 456, "type": "private"}, "text": "ping", }, } router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) request = _build_request( path, json.dumps(sample_update).encode("utf-8"), headers={"Content-Type": "text/plain"}, ) with pytest.raises(HTTPException) as exc: await route.endpoint(request) assert exc.value.status_code == 415 dispatcher.feed_update.assert_not_called() @pytest.mark.anyio async def test_webhook_uses_processor() -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() processor = TelegramWebhookProcessor( bot=bot, dispatcher=dispatcher, queue_maxsize=1, worker_count=0, enqueue_timeout=0.0, shutdown_timeout=1.0, ) await processor.start() sample_update = { "update_id": 999, "message": { "message_id": 77, "date": 1715700000, "chat": {"id": 111, "type": "private"}, "text": "processor", }, } router = create_telegram_router(bot, dispatcher, processor=processor) path = _webhook_path() route = _get_route(router, path) request = _build_request(path, json.dumps(sample_update).encode("utf-8")) response = await route.endpoint(request) assert response.status_code == 200 dispatcher.feed_update.assert_not_awaited() assert processor.is_running await processor.stop() @pytest.mark.anyio async def test_webhook_processor_overloaded() -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() processor = TelegramWebhookProcessor( bot=bot, dispatcher=dispatcher, queue_maxsize=1, worker_count=0, enqueue_timeout=0.0, shutdown_timeout=1.0, ) await processor.start() router = create_telegram_router(bot, dispatcher, processor=processor) path = _webhook_path() route = _get_route(router, path) request_payload = json.dumps({"update_id": 1}).encode("utf-8") request = _build_request(path, request_payload) await route.endpoint(request) with pytest.raises(HTTPException) as exc: await route.endpoint(request) assert exc.value.status_code == 503 assert exc.value.detail == "webhook_queue_full" dispatcher.feed_update.assert_not_called() await processor.stop() @pytest.mark.anyio async def test_webhook_processor_not_running() -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() processor = TelegramWebhookProcessor( bot=bot, dispatcher=dispatcher, queue_maxsize=1, worker_count=1, enqueue_timeout=0.0, shutdown_timeout=1.0, ) router = create_telegram_router(bot, dispatcher, processor=processor) path = _webhook_path() route = _get_route(router, path) request = _build_request(path, json.dumps({"update_id": 5}).encode("utf-8")) with pytest.raises(HTTPException) as exc: await route.endpoint(request) assert exc.value.status_code == 503 assert exc.value.detail == "webhook_processor_unavailable" dispatcher.feed_update.assert_not_called() @pytest.mark.anyio async def test_webhook_path_normalization(monkeypatch: pytest.MonkeyPatch) -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() monkeypatch.setattr(settings, "WEBHOOK_PATH", " telegram/webhook ", raising=False) router = create_telegram_router(bot, dispatcher) normalized_path = settings.get_telegram_webhook_path() assert normalized_path == "/telegram/webhook" route = _get_route(router, normalized_path) request = _build_request(normalized_path, json.dumps({"update_id": 7}).encode("utf-8")) response = await route.endpoint(request) assert response.status_code == 200 dispatcher.feed_update.assert_awaited_once() @pytest.mark.anyio async def test_health_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: bot = AsyncMock() dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False) monkeypatch.setattr(settings, "WEBHOOK_PATH", "/custom", raising=False) monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 42, raising=False) monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 2, raising=False) router = create_telegram_router(bot, dispatcher) route = _get_route(router, "/health/telegram-webhook", method="GET") response = await route.endpoint() assert response.status_code == 200 payload = json.loads(response.body.decode("utf-8")) assert payload["status"] == "ok" assert payload["mode"] == settings.get_bot_run_mode() assert payload["path"] == "/custom" assert payload["webhook_configured"] is True assert payload["queue_maxsize"] == 42 assert payload["workers"] == 2