""" Tests for backend/telegram.py: send_message, set_webhook, validate_bot_token. NOTE: respx routes must be registered INSIDE the 'with mock:' block to be intercepted properly. Registering them before entering the context does not activate the mock for new httpx.AsyncClient instances created at call time. """ from __future__ import annotations import os os.environ.setdefault("BOT_TOKEN", "test-bot-token") os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") import aiosqlite def _safe_aiosqlite_await(self): if not self._thread._started.is_set(): self._thread.start() return self._connect().__await__() aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign] import json from unittest.mock import AsyncMock, patch import httpx import pytest import respx from backend import config from backend.telegram import send_message, set_webhook, validate_bot_token SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" WEBHOOK_URL_API = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" GET_ME_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" # --------------------------------------------------------------------------- # validate_bot_token # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_validate_bot_token_returns_true_on_200(): """validate_bot_token returns True when getMe responds 200.""" with respx.mock(assert_all_called=False) as mock: mock.get(GET_ME_URL).mock( return_value=httpx.Response(200, json={"ok": True, "result": {"username": "batonbot"}}) ) result = await validate_bot_token() assert result is True @pytest.mark.asyncio async def test_validate_bot_token_returns_false_on_401(caplog): """validate_bot_token returns False and logs ERROR when getMe responds 401.""" import logging with respx.mock(assert_all_called=False) as mock: mock.get(GET_ME_URL).mock( return_value=httpx.Response(401, json={"ok": False, "description": "Unauthorized"}) ) with caplog.at_level(logging.ERROR, logger="backend.telegram"): result = await validate_bot_token() assert result is False assert any("401" in record.message for record in caplog.records) @pytest.mark.asyncio async def test_validate_bot_token_returns_false_on_network_error(caplog): """validate_bot_token returns False and logs ERROR on network failure — never raises.""" import logging with respx.mock(assert_all_called=False) as mock: mock.get(GET_ME_URL).mock(side_effect=httpx.ConnectError("connection refused")) with caplog.at_level(logging.ERROR, logger="backend.telegram"): result = await validate_bot_token() assert result is False assert len(caplog.records) >= 1 # --------------------------------------------------------------------------- # send_message # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_send_message_calls_telegram_api(): """send_message POSTs to api.telegram.org/bot.../sendMessage.""" with respx.mock(assert_all_called=False) as mock: route = mock.post(SEND_URL).mock( return_value=httpx.Response(200, json={"ok": True}) ) await send_message("hello world") assert route.called body = json.loads(route.calls[0].request.content) assert body["chat_id"] == config.CHAT_ID assert body["text"] == "hello world" @pytest.mark.asyncio async def test_send_message_handles_429(): """On 429, send_message sleeps retry_after seconds then retries.""" retry_after = 5 responses = [ httpx.Response( 429, json={"ok": False, "parameters": {"retry_after": retry_after}}, ), httpx.Response(200, json={"ok": True}), ] with respx.mock(assert_all_called=False) as mock: mock.post(SEND_URL).mock(side_effect=responses) with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: await send_message("test 429") mock_sleep.assert_any_call(retry_after) @pytest.mark.asyncio async def test_send_message_5xx_retries(): """On 5xx, send_message sleeps 30 seconds and retries once.""" responses = [ httpx.Response(500, text="Internal Server Error"), httpx.Response(200, json={"ok": True}), ] with respx.mock(assert_all_called=False) as mock: mock.post(SEND_URL).mock(side_effect=responses) with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: await send_message("test 5xx") mock_sleep.assert_any_call(30) # --------------------------------------------------------------------------- # set_webhook # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_set_webhook_calls_correct_endpoint(): """set_webhook POSTs to setWebhook with url and secret_token.""" with respx.mock(assert_all_called=False) as mock: route = mock.post(WEBHOOK_URL_API).mock( return_value=httpx.Response(200, json={"ok": True, "result": True}) ) await set_webhook( url="https://example.com/api/webhook/telegram", secret="my-secret", ) assert route.called body = json.loads(route.calls[0].request.content) assert body["url"] == "https://example.com/api/webhook/telegram" assert body["secret_token"] == "my-secret" @pytest.mark.asyncio async def test_set_webhook_raises_on_result_false(): """set_webhook raises RuntimeError when Telegram returns result=False.""" with respx.mock(assert_all_called=False) as mock: mock.post(WEBHOOK_URL_API).mock( return_value=httpx.Response(200, json={"ok": True, "result": False}) ) with pytest.raises(RuntimeError, match="setWebhook failed"): await set_webhook(url="https://example.com/webhook", secret="s") @pytest.mark.asyncio async def test_set_webhook_raises_on_non_200(): """set_webhook raises RuntimeError on non-200 response.""" with respx.mock(assert_all_called=False) as mock: mock.post(WEBHOOK_URL_API).mock( return_value=httpx.Response(400, json={"ok": False}) ) with pytest.raises(RuntimeError, match="setWebhook failed"): await set_webhook(url="https://example.com/webhook", secret="s") # --------------------------------------------------------------------------- # BATON-007: 400 "chat not found" handling # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_send_message_400_chat_not_found_does_not_raise(): """400 'chat not found' must not raise an exception (service stays alive).""" with respx.mock(assert_all_called=False) as mock: mock.post(SEND_URL).mock( return_value=httpx.Response( 400, json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, ) ) # Must not raise — service must stay alive even with wrong CHAT_ID await send_message("test") @pytest.mark.asyncio async def test_send_message_400_chat_not_found_logs_error(caplog): """400 response from Telegram must be logged as ERROR with the status code.""" import logging with respx.mock(assert_all_called=False) as mock: mock.post(SEND_URL).mock( return_value=httpx.Response( 400, json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, ) ) with caplog.at_level(logging.ERROR, logger="backend.telegram"): await send_message("test chat not found") assert any("400" in record.message for record in caplog.records), ( "Expected ERROR log containing '400' but got: " + str([r.message for r in caplog.records]) ) @pytest.mark.asyncio async def test_send_message_400_breaks_after_first_attempt(): """On 400, send_message breaks immediately (no retry loop) — only one HTTP call made.""" with respx.mock(assert_all_called=False) as mock: route = mock.post(SEND_URL).mock( return_value=httpx.Response( 400, json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"}, ) ) await send_message("test no retry on 400") assert route.call_count == 1, f"Expected 1 call on 400, got {route.call_count}" @pytest.mark.asyncio async def test_send_message_all_5xx_retries_exhausted_does_not_raise(): """When all 3 attempts fail with 5xx, send_message logs error but does NOT raise.""" with respx.mock(assert_all_called=False) as mock: mock.post(SEND_URL).mock( return_value=httpx.Response(500, text="Internal Server Error") ) with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock): # Must not raise — message is dropped, service stays alive await send_message("test all retries exhausted")