diff --git a/backend/config.py b/backend/config.py index d9f832e..7535cc0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,7 +22,7 @@ WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true" FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping ADMIN_TOKEN: str = _require("ADMIN_TOKEN") -ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584") +ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID") VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") diff --git a/backend/db.py b/backend/db.py index e2e98b4..d733006 100644 --- a/backend/db.py +++ b/backend/db.py @@ -341,9 +341,10 @@ async def get_registration(reg_id: int) -> Optional[dict]: async def update_registration_status(reg_id: int, status: str) -> bool: + """Update registration status only if currently 'pending'. Returns False if already processed.""" async with _get_conn() as conn: async with conn.execute( - "UPDATE registrations SET status = ? WHERE id = ?", + "UPDATE registrations SET status = ? WHERE id = ? AND status = 'pending'", (status, reg_id), ) as cur: changed = cur.rowcount > 0 diff --git a/backend/main.py b/backend/main.py index 18df781..8d0d3d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -240,7 +240,11 @@ async def _handle_callback_query(cb: dict) -> None: return if action == "approve": - await db.update_registration_status(reg_id, "approved") + updated = await db.update_registration_status(reg_id, "approved") + if not updated: + # Already processed (not pending) — ack the callback and stop + await telegram.answer_callback_query(callback_query_id) + return if chat_id and message_id: await telegram.edit_message_text( chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен" @@ -254,7 +258,10 @@ async def _handle_callback_query(cb: dict) -> None: ) ) elif action == "reject": - await db.update_registration_status(reg_id, "rejected") + updated = await db.update_registration_status(reg_id, "rejected") + if not updated: + await telegram.answer_callback_query(callback_query_id) + return if chat_id and message_id: await telegram.edit_message_text( chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён" diff --git a/tests/conftest.py b/tests/conftest.py index 727bf75..7f47b12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ 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") +os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") # ── 2. aiosqlite monkey-patch ──────────────────────────────────────────────── import aiosqlite @@ -69,14 +70,20 @@ def temp_db(): # ── 5. App client factory ──────────────────────────────────────────────────── -def make_app_client(): +def make_app_client(capture_send_requests: list | None = None): """ Async context manager that: 1. Assigns a fresh temp-file DB path 2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText 3. Runs the FastAPI lifespan (startup → test → shutdown) 4. Yields an httpx.AsyncClient wired to the app + + Args: + capture_send_requests: if provided, each sendMessage request body (dict) is + appended to this list, enabling HTTP-level assertions on chat_id, text, etc. """ + import json as _json + tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe" @@ -95,9 +102,18 @@ def make_app_client(): mock_router.post(tg_set_url).mock( return_value=httpx.Response(200, json={"ok": True, "result": True}) ) - mock_router.post(send_url).mock( - return_value=httpx.Response(200, json={"ok": True}) - ) + if capture_send_requests is not None: + def _capture_send(request: httpx.Request) -> httpx.Response: + try: + capture_send_requests.append(_json.loads(request.content)) + except Exception: + pass + return httpx.Response(200, json={"ok": True}) + mock_router.post(send_url).mock(side_effect=_capture_send) + else: + mock_router.post(send_url).mock( + return_value=httpx.Response(200, json={"ok": True}) + ) mock_router.post(answer_cb_url).mock( return_value=httpx.Response(200, json={"ok": True}) ) diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py index 0a6a678..f572cd8 100644 --- a/tests/test_baton_008.py +++ b/tests/test_baton_008.py @@ -21,6 +21,7 @@ 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") +os.environ.setdefault("ADMIN_CHAT_ID", "5694335584") from unittest.mock import AsyncMock, patch @@ -167,20 +168,23 @@ async def test_auth_register_422_short_password(): @pytest.mark.asyncio async def test_auth_register_sends_notification_to_admin(): - """Registration triggers send_registration_notification with correct data.""" - calls: list[dict] = [] + """Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email.""" + from backend import config as _cfg - async def _capture(reg_id, login, email, created_at): - calls.append({"reg_id": reg_id, "login": login, "email": email}) + captured: list[dict] = [] + async with make_app_client(capture_send_requests=captured) as client: + resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) + assert resp.status_code == 201 + await asyncio.sleep(0) - async with make_app_client() as client: - with patch("backend.telegram.send_registration_notification", side_effect=_capture): - await client.post("/api/auth/register", json=_VALID_PAYLOAD) - await asyncio.sleep(0) - - assert len(calls) == 1, f"Expected 1 notification call, got {len(calls)}" - assert calls[0]["login"] == _VALID_PAYLOAD["login"] - assert calls[0]["email"] == _VALID_PAYLOAD["email"] + admin_chat_id = str(_cfg.ADMIN_CHAT_ID) + admin_msgs = [r for r in captured if str(r.get("chat_id")) == admin_chat_id] + assert len(admin_msgs) >= 1, ( + f"Expected sendMessage to ADMIN_CHAT_ID={admin_chat_id!r}, captured: {captured}" + ) + text = admin_msgs[0].get("text", "") + assert _VALID_PAYLOAD["login"] in text, f"Expected login in text: {text!r}" + assert _VALID_PAYLOAD["email"] in text, f"Expected email in text: {text!r}" # ---------------------------------------------------------------------------