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