baton/tests/test_telegram.py

251 lines
9.2 KiB
Python
Raw Permalink Normal View History

2026-03-20 20:44:00 +02:00
"""
Tests for backend/telegram.py: send_message, set_webhook, validate_bot_token.
2026-03-20 20:44:00 +02:00
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
2026-03-20 20:44:00 +02:00
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
2026-03-20 20:44:00 +02:00
# ---------------------------------------------------------------------------
# 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")