230 lines
9.3 KiB
Python
230 lines
9.3 KiB
Python
|
|
"""
|
||
|
|
Tests for BATON-FIX-009: Live delivery verification — automated regression guards.
|
||
|
|
|
||
|
|
Acceptance criteria mapped to unit tests:
|
||
|
|
AC#3 — BOT_TOKEN validates on startup via validate_bot_token() (getMe call)
|
||
|
|
AC#4 — CHAT_ID is negative (regression guard for decision #1212)
|
||
|
|
AC#1 — POST /api/signal returns 200 with valid auth
|
||
|
|
|
||
|
|
Physical production checks (AC#2 Telegram group message, AC#5 systemd status)
|
||
|
|
are outside unit test scope and require live production verification.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
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")
|
||
|
|
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||
|
|
|
||
|
|
import json
|
||
|
|
from unittest.mock import AsyncMock, patch
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
import pytest
|
||
|
|
import respx
|
||
|
|
|
||
|
|
from tests.conftest import make_app_client, temp_db
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# AC#3 — validate_bot_token called at startup (decision #1211)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_validate_bot_token_called_once_during_startup():
|
||
|
|
"""AC#3: validate_bot_token() must be called exactly once during app startup.
|
||
|
|
|
||
|
|
Maps to production check: curl getMe must be executed to detect invalid token
|
||
|
|
before the service starts accepting signals (decision #1211).
|
||
|
|
"""
|
||
|
|
from backend.main import app
|
||
|
|
|
||
|
|
with temp_db():
|
||
|
|
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
|
||
|
|
mock_validate.return_value = True
|
||
|
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||
|
|
async with app.router.lifespan_context(app):
|
||
|
|
pass
|
||
|
|
|
||
|
|
assert mock_validate.call_count == 1, (
|
||
|
|
f"Expected validate_bot_token to be called exactly once at startup, "
|
||
|
|
f"got {mock_validate.call_count}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_invalid_bot_token_logs_critical_error_on_startup(caplog):
|
||
|
|
"""AC#3: When BOT_TOKEN is invalid (validate_bot_token returns False),
|
||
|
|
a CRITICAL/ERROR is logged but lifespan continues — service must not crash.
|
||
|
|
|
||
|
|
Maps to: 'Check BOT_TOKEN valid via getMe — status OK/FAIL' (decision #1211).
|
||
|
|
"""
|
||
|
|
from backend.main import app
|
||
|
|
|
||
|
|
with temp_db():
|
||
|
|
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
|
||
|
|
mock_validate.return_value = False
|
||
|
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||
|
|
with caplog.at_level(logging.ERROR, logger="backend.main"):
|
||
|
|
async with app.router.lifespan_context(app):
|
||
|
|
pass # lifespan must complete without raising
|
||
|
|
|
||
|
|
critical_msgs = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
|
||
|
|
assert len(critical_msgs) >= 1, (
|
||
|
|
"Expected at least one ERROR/CRITICAL log when BOT_TOKEN is invalid. "
|
||
|
|
"Operator must be alerted on startup if Telegram delivery is broken."
|
||
|
|
)
|
||
|
|
assert any("BOT_TOKEN" in m for m in critical_msgs), (
|
||
|
|
f"Expected log mentioning 'BOT_TOKEN', got: {critical_msgs}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_invalid_bot_token_lifespan_does_not_raise():
|
||
|
|
"""AC#3: Invalid BOT_TOKEN must not crash the service — lifespan completes normally."""
|
||
|
|
from backend.main import app
|
||
|
|
|
||
|
|
with temp_db():
|
||
|
|
with patch("backend.telegram.validate_bot_token", new_callable=AsyncMock) as mock_validate:
|
||
|
|
mock_validate.return_value = False
|
||
|
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||
|
|
# Must not raise — service stays alive even with broken Telegram token
|
||
|
|
async with app.router.lifespan_context(app):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# AC#4 — CHAT_ID is negative (decision #1212 regression guard)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_message_chat_id_in_request_is_negative():
|
||
|
|
"""AC#4: The chat_id sent to Telegram API must be negative (group ID).
|
||
|
|
|
||
|
|
Root cause of BATON-007: CHAT_ID=5190015988 (positive) was set in .env
|
||
|
|
instead of -5190015988 (negative). Negative ID = Telegram group/supergroup.
|
||
|
|
Decision #1212: CHAT_ID=-5190015988 отрицательный.
|
||
|
|
"""
|
||
|
|
from backend import config
|
||
|
|
from backend.telegram import send_message
|
||
|
|
|
||
|
|
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/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("AC#4 regression guard")
|
||
|
|
|
||
|
|
assert route.called
|
||
|
|
body = json.loads(route.calls[0].request.content)
|
||
|
|
chat_id = body["chat_id"]
|
||
|
|
assert str(chat_id).startswith("-"), (
|
||
|
|
f"Regression #1212: chat_id must be negative (group ID), got {chat_id!r}. "
|
||
|
|
"Positive chat_id is a user ID — messages go to private DM, not the group."
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# AC#1 — POST /api/signal returns 200 (decision #1211)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
_UUID_FIX009 = "f0090001-0000-4000-8000-000000000001"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_signal_endpoint_returns_200_with_valid_auth():
|
||
|
|
"""AC#1: POST /api/signal with valid Bearer token must return HTTP 200.
|
||
|
|
|
||
|
|
Maps to production check: 'SSH на сервер, отправить POST /api/signal,
|
||
|
|
зафиксировать raw ответ API' (decision #1211).
|
||
|
|
"""
|
||
|
|
async with make_app_client() as client:
|
||
|
|
reg = await client.post(
|
||
|
|
"/api/register",
|
||
|
|
json={"uuid": _UUID_FIX009, "name": "Fix009User"},
|
||
|
|
)
|
||
|
|
assert reg.status_code == 200, f"Registration failed: {reg.text}"
|
||
|
|
api_key = reg.json()["api_key"]
|
||
|
|
|
||
|
|
resp = await client.post(
|
||
|
|
"/api/signal",
|
||
|
|
json={"user_id": _UUID_FIX009, "timestamp": 1742478000000},
|
||
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert resp.status_code == 200, (
|
||
|
|
f"Expected /api/signal to return 200, got {resp.status_code}: {resp.text}"
|
||
|
|
)
|
||
|
|
body = resp.json()
|
||
|
|
assert body.get("status") == "ok", f"Expected status='ok', got: {body}"
|
||
|
|
assert "signal_id" in body, f"Expected signal_id in response, got: {body}"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_signal_endpoint_returns_200_even_when_telegram_returns_400(caplog):
|
||
|
|
"""AC#1 + decision #1230: POST /api/signal must return 200 even if Telegram returns 400.
|
||
|
|
|
||
|
|
Decision #1230: 'Если Telegram возвращает 400 — зафиксировать и сообщить'.
|
||
|
|
The HTTP 400 from Telegram must be logged as ERROR (captured/reported),
|
||
|
|
but /api/signal must still return 200 — signal was saved to DB.
|
||
|
|
"""
|
||
|
|
from backend import config
|
||
|
|
from backend.main import app
|
||
|
|
|
||
|
|
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||
|
|
set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
||
|
|
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
||
|
|
|
||
|
|
_UUID_400 = "f0090002-0000-4000-8000-000000000002"
|
||
|
|
|
||
|
|
with temp_db():
|
||
|
|
with respx.mock(assert_all_called=False) as mock_tg:
|
||
|
|
mock_tg.get(get_me_url).mock(
|
||
|
|
return_value=httpx.Response(200, json={"ok": True, "result": {"username": "testbot"}})
|
||
|
|
)
|
||
|
|
mock_tg.post(set_url).mock(
|
||
|
|
return_value=httpx.Response(200, json={"ok": True, "result": True})
|
||
|
|
)
|
||
|
|
mock_tg.post(send_url).mock(
|
||
|
|
return_value=httpx.Response(
|
||
|
|
400,
|
||
|
|
json={"ok": False, "error_code": 400, "description": "Bad Request: chat not found"},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
async with app.router.lifespan_context(app):
|
||
|
|
import asyncio
|
||
|
|
from httpx import AsyncClient, ASGITransport
|
||
|
|
|
||
|
|
transport = ASGITransport(app=app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||
|
|
reg = await client.post("/api/register", json={"uuid": _UUID_400, "name": "TgErrUser"})
|
||
|
|
assert reg.status_code == 200
|
||
|
|
api_key = reg.json()["api_key"]
|
||
|
|
|
||
|
|
with caplog.at_level(logging.ERROR, logger="backend.telegram"):
|
||
|
|
resp = await client.post(
|
||
|
|
"/api/signal",
|
||
|
|
json={"user_id": _UUID_400, "timestamp": 1742478000000},
|
||
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
||
|
|
)
|
||
|
|
await asyncio.sleep(0)
|
||
|
|
|
||
|
|
assert resp.status_code == 200, (
|
||
|
|
f"Decision #1230: /api/signal must return 200 even on Telegram 400, "
|
||
|
|
f"got {resp.status_code}"
|
||
|
|
)
|
||
|
|
assert any("400" in r.message for r in caplog.records), (
|
||
|
|
"Decision #1230: Telegram 400 error must be logged (captured and reported). "
|
||
|
|
"Got logs: " + str([r.message for r in caplog.records])
|
||
|
|
)
|