diff --git a/tests/test_baton_007.py b/tests/test_baton_007.py index 0030c7d..8738d53 100644 --- a/tests/test_baton_007.py +++ b/tests/test_baton_007.py @@ -15,6 +15,7 @@ Physical delivery to an actual Telegram group is outside unit test scope. from __future__ import annotations import asyncio +import logging import os os.environ.setdefault("BOT_TOKEN", "test-bot-token") @@ -30,9 +31,9 @@ from unittest.mock import AsyncMock, patch import httpx import pytest import respx -from httpx import AsyncClient +from httpx import AsyncClient, ASGITransport -from tests.conftest import make_app_client +from tests.conftest import make_app_client, temp_db # Valid UUID v4 constants — must not collide with UUIDs in other test files _UUID_A = "d0000001-0000-4000-8000-000000000001" @@ -40,6 +41,7 @@ _UUID_B = "d0000002-0000-4000-8000-000000000002" _UUID_C = "d0000003-0000-4000-8000-000000000003" _UUID_D = "d0000004-0000-4000-8000-000000000004" _UUID_E = "d0000005-0000-4000-8000-000000000005" +_UUID_F = "d0000006-0000-4000-8000-000000000006" async def _register(client: AsyncClient, uuid: str, name: str) -> str: @@ -260,3 +262,120 @@ async def test_repeated_signals_produce_incrementing_signal_ids(): assert r2.json()["signal_id"] > r1.json()["signal_id"], ( "Second signal must have a higher signal_id than the first" ) + + +# --------------------------------------------------------------------------- +# Director revision: regression #1214, #1226 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_message_uses_negative_chat_id_from_config(): + """Regression #1226: send_message must POST to Telegram with a negative chat_id. + + Root cause of BATON-007: CHAT_ID=5190015988 (positive = user ID) was set in .env + instead of -5190015988 (negative = group ID). This test inspects the actual + chat_id value in the HTTP request body — not just call_count. + """ + from backend import config as _cfg + from backend.telegram import send_message + + send_url = f"https://api.telegram.org/bot{_cfg.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("regression #1226") + + assert route.called + body = json.loads(route.calls[0].request.content) + chat_id = body["chat_id"] + assert chat_id == _cfg.CHAT_ID, ( + f"Expected chat_id={_cfg.CHAT_ID!r}, got {chat_id!r}" + ) + assert str(chat_id).startswith("-"), ( + f"Regression #1226: chat_id must be negative (group ID), got: {chat_id!r}. " + "Positive chat_id is a user ID, not a Telegram group." + ) + + +@pytest.mark.asyncio +async def test_send_message_4xx_does_not_trigger_retry_loop(): + """Regression #1214: on Telegram 4xx (wrong chat_id), retry loop must NOT run. + + Only one HTTP call should be made. Retrying a 4xx is pointless — it will + keep failing. send_message must break immediately on any 4xx response. + """ + from backend import config as _cfg + from backend.telegram import send_message + + send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage" + + 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("retry test #1214") + + assert route.call_count == 1, ( + f"Regression #1214: expected exactly 1 HTTP call on 4xx, got {route.call_count}. " + "send_message must break immediately on client errors — no retry loop." + ) + + +@pytest.mark.asyncio +async def test_signal_endpoint_returns_200_on_telegram_4xx(caplog): + """Regression: /api/signal must return 200 even when Telegram Bot API returns 4xx. + + When CHAT_ID is wrong (or any Telegram 4xx), the error must be logged by + send_message but the /api/signal endpoint must still return 200 — the signal + was saved to DB, only the Telegram notification failed. + """ + from backend import config as _cfg + from backend.main import app + + send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage" + tg_set_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/setWebhook" + get_me_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/getMe" + + 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(tg_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): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + reg = await client.post("/api/register", json={"uuid": _UUID_F, "name": "Tg4xxUser"}) + assert reg.status_code == 200, f"Register failed: {reg.text}" + 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_F, "timestamp": 1742478000000}, + headers={"Authorization": f"Bearer {api_key}"}, + ) + await asyncio.sleep(0) + + assert resp.status_code == 200, ( + f"Expected /api/signal to return 200 even when Telegram returns 4xx, got {resp.status_code}" + ) + assert any("400" in r.message for r in caplog.records), ( + "Expected ERROR log containing '400' when Telegram returns 4xx. " + "Error must be logged, not silently swallowed." + )