From 4c9fec17def9609f7d340f0e1d923911525b7d3a Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:19:50 +0200 Subject: [PATCH] kin: BATON-008-backend_dev --- =2.0.0 | 1 + backend/config.py | 4 + backend/db.py | 67 ++++++++ backend/main.py | 91 ++++++++++- backend/middleware.py | 10 ++ backend/models.py | 24 ++- backend/push.py | 35 ++++ backend/telegram.py | 62 +++++++ requirements.txt | 2 + tests/conftest.py | 10 +- tests/test_baton_008.py | 349 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 =2.0.0 create mode 100644 backend/push.py create mode 100644 tests/test_baton_008.py diff --git a/=2.0.0 b/=2.0.0 new file mode 100644 index 0000000..c8c6c93 --- /dev/null +++ b/=2.0.0 @@ -0,0 +1 @@ +(eval):1: command not found: pip diff --git a/backend/config.py b/backend/config.py index 40159b0..d9f832e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,3 +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") +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 5acc94c..e2e98b4 100644 --- a/backend/db.py +++ b/backend/db.py @@ -67,6 +67,23 @@ async def init_db() -> None: count INTEGER NOT NULL DEFAULT 0, window_start REAL NOT NULL DEFAULT 0 ); + + CREATE TABLE IF NOT EXISTS registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + login TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + push_subscription TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_registrations_status + ON registrations(status); + CREATE INDEX IF NOT EXISTS idx_registrations_email + ON registrations(email); + CREATE INDEX IF NOT EXISTS idx_registrations_login + ON registrations(login); """) # Migrations for existing databases (silently ignore if columns already exist) for stmt in [ @@ -284,6 +301,56 @@ async def rate_limit_increment(key: str, window: float) -> int: return row["count"] if row else 1 +async def create_registration( + email: str, + login: str, + password_hash: str, + push_subscription: Optional[str] = None, +) -> int: + """Insert a new registration. Raises aiosqlite.IntegrityError on email/login conflict.""" + async with _get_conn() as conn: + async with conn.execute( + """ + INSERT INTO registrations (email, login, password_hash, push_subscription) + VALUES (?, ?, ?, ?) + """, + (email, login, password_hash, push_subscription), + ) as cur: + reg_id = cur.lastrowid + await conn.commit() + return reg_id # type: ignore[return-value] + + +async def get_registration(reg_id: int) -> Optional[dict]: + async with _get_conn() as conn: + async with conn.execute( + "SELECT id, email, login, status, push_subscription, created_at FROM registrations WHERE id = ?", + (reg_id,), + ) as cur: + row = await cur.fetchone() + if row is None: + return None + return { + "id": row["id"], + "email": row["email"], + "login": row["login"], + "status": row["status"], + "push_subscription": row["push_subscription"], + "created_at": row["created_at"], + } + + +async def update_registration_status(reg_id: int, status: str) -> bool: + async with _get_conn() as conn: + async with conn.execute( + "UPDATE registrations SET status = ? WHERE id = ?", + (status, reg_id), + ) as cur: + changed = cur.rowcount > 0 + await conn.commit() + return changed + + async def save_telegram_batch( message_text: str, signals_count: int, diff --git a/backend/main.py b/backend/main.py index 7fb9d19..c4774e8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,12 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from backend import config, db, telegram -from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret +from backend import config, db, push, telegram +from backend.middleware import rate_limit_auth_register, rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret from backend.models import ( AdminBlockRequest, AdminCreateUserRequest, AdminSetPasswordRequest, + AuthRegisterRequest, + AuthRegisterResponse, RegisterRequest, RegisterResponse, SignalRequest, @@ -182,6 +184,84 @@ async def signal( return SignalResponse(status="ok", signal_id=signal_id) +@app.post("/api/auth/register", response_model=AuthRegisterResponse, status_code=201) +async def auth_register( + body: AuthRegisterRequest, + _: None = Depends(rate_limit_auth_register), +) -> AuthRegisterResponse: + password_hash = _hash_password(body.password) + push_sub_json = ( + body.push_subscription.model_dump_json() if body.push_subscription else None + ) + try: + reg_id = await db.create_registration( + email=str(body.email), + login=body.login, + password_hash=password_hash, + push_subscription=push_sub_json, + ) + except Exception as exc: + # aiosqlite.IntegrityError on email/login UNIQUE conflict + if "UNIQUE" in str(exc) or "unique" in str(exc).lower(): + raise HTTPException(status_code=409, detail="Email или логин уже существует") + raise + reg = await db.get_registration(reg_id) + asyncio.create_task( + telegram.send_registration_notification( + reg_id=reg_id, + login=body.login, + email=str(body.email), + created_at=reg["created_at"] if reg else "", + ) + ) + return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение") + + +async def _handle_callback_query(cb: dict) -> None: + """Process approve/reject callback from admin Telegram inline buttons.""" + data = cb.get("data", "") + callback_query_id = cb.get("id", "") + message = cb.get("message", {}) + chat_id = message.get("chat", {}).get("id") + message_id = message.get("message_id") + + if ":" not in data: + return + action, reg_id_str = data.split(":", 1) + try: + reg_id = int(reg_id_str) + except ValueError: + return + + reg = await db.get_registration(reg_id) + if reg is None: + await telegram.answer_callback_query(callback_query_id) + return + + if action == "approve": + await db.update_registration_status(reg_id, "approved") + if chat_id and message_id: + await telegram.edit_message_text( + chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен" + ) + if reg["push_subscription"]: + asyncio.create_task( + push.send_push( + reg["push_subscription"], + "Baton", + "Ваша регистрация одобрена!", + ) + ) + elif action == "reject": + await db.update_registration_status(reg_id, "rejected") + if chat_id and message_id: + await telegram.edit_message_text( + chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён" + ) + + await telegram.answer_callback_query(callback_query_id) + + @app.get("/admin/users", dependencies=[Depends(verify_admin_token)]) async def admin_list_users() -> list[dict]: return await db.admin_list_users() @@ -226,6 +306,13 @@ async def webhook_telegram( _: None = Depends(verify_webhook_secret), ) -> dict[str, Any]: update = await request.json() + + # Handle inline button callback queries (approve/reject registration) + callback_query = update.get("callback_query") + if callback_query: + await _handle_callback_query(callback_query) + return {"ok": True} + message = update.get("message", {}) text = message.get("text", "") diff --git a/backend/middleware.py b/backend/middleware.py index b91b83e..1d183a9 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -16,6 +16,9 @@ _RATE_WINDOW = 600 # 10 minutes _SIGNAL_RATE_LIMIT = 10 _SIGNAL_RATE_WINDOW = 60 # 1 minute +_AUTH_REGISTER_RATE_LIMIT = 3 +_AUTH_REGISTER_RATE_WINDOW = 600 # 10 minutes + def _get_client_ip(request: Request) -> str: return ( @@ -55,3 +58,10 @@ async def rate_limit_signal(request: Request) -> None: count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW) if count > _SIGNAL_RATE_LIMIT: raise HTTPException(status_code=429, detail="Too Many Requests") + + +async def rate_limit_auth_register(request: Request) -> None: + key = f"authreg:{_get_client_ip(request)}" + count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW) + if count > _AUTH_REGISTER_RATE_LIMIT: + raise HTTPException(status_code=429, detail="Too Many Requests") diff --git a/backend/models.py b/backend/models.py index 7b88b20..065d0c8 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, EmailStr, Field class RegisterRequest(BaseModel): @@ -44,3 +44,25 @@ class AdminSetPasswordRequest(BaseModel): class AdminBlockRequest(BaseModel): is_blocked: bool + + +class PushSubscriptionKeys(BaseModel): + p256dh: str + auth: str + + +class PushSubscription(BaseModel): + endpoint: str + keys: PushSubscriptionKeys + + +class AuthRegisterRequest(BaseModel): + email: EmailStr + login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$') + password: str = Field(..., min_length=8, max_length=128) + push_subscription: Optional[PushSubscription] = None + + +class AuthRegisterResponse(BaseModel): + status: str + message: str diff --git a/backend/push.py b/backend/push.py new file mode 100644 index 0000000..c86f799 --- /dev/null +++ b/backend/push.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import asyncio +import json +import logging + +from backend import config + +logger = logging.getLogger(__name__) + + +async def send_push(subscription_json: str, title: str, body: str) -> None: + """Send a Web Push notification. Swallows all errors — never raises.""" + if not config.VAPID_PRIVATE_KEY: + logger.warning("VAPID_PRIVATE_KEY not configured — push notification skipped") + return + try: + import pywebpush # type: ignore[import] + except ImportError: + logger.warning("pywebpush not installed — push notification skipped") + return + try: + subscription_info = json.loads(subscription_json) + data = json.dumps({"title": title, "body": body}) + vapid_claims = {"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL or 'admin@example.com'}"} + + await asyncio.to_thread( + pywebpush.webpush, + subscription_info=subscription_info, + data=data, + vapid_private_key=config.VAPID_PRIVATE_KEY, + vapid_claims=vapid_claims, + ) + except Exception as exc: + logger.error("Web Push failed: %s", exc) diff --git a/backend/telegram.py b/backend/telegram.py index 0633462..603b944 100644 --- a/backend/telegram.py +++ b/backend/telegram.py @@ -55,6 +55,68 @@ async def send_message(text: str) -> None: logger.error("Telegram send_message: all 3 attempts failed, message dropped") +async def send_registration_notification( + reg_id: int, login: str, email: str, created_at: str +) -> None: + """Send registration request notification to admin with approve/reject inline buttons. + Swallows all errors — never raises.""" + url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage") + text = ( + f"📋 Новая заявка на регистрацию\n\n" + f"Login: {login}\nEmail: {email}\nДата: {created_at}" + ) + reply_markup = { + "inline_keyboard": [[ + {"text": "✅ Одобрить", "callback_data": f"approve:{reg_id}"}, + {"text": "❌ Отклонить", "callback_data": f"reject:{reg_id}"}, + ]] + } + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + url, + json={ + "chat_id": config.ADMIN_CHAT_ID, + "text": text, + "reply_markup": reply_markup, + }, + ) + if resp.status_code != 200: + logger.error( + "send_registration_notification failed %s: %s", + resp.status_code, + resp.text, + ) + except Exception as exc: + logger.error("send_registration_notification error: %s", exc) + + +async def answer_callback_query(callback_query_id: str) -> None: + """Answer a Telegram callback query. Swallows all errors.""" + url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="answerCallbackQuery") + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(url, json={"callback_query_id": callback_query_id}) + if resp.status_code != 200: + logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text) + except Exception as exc: + logger.error("answerCallbackQuery error: %s", exc) + + +async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None: + """Edit a Telegram message text. Swallows all errors.""" + url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="editMessageText") + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + url, json={"chat_id": chat_id, "message_id": message_id, "text": text} + ) + if resp.status_code != 200: + logger.error("editMessageText failed %s: %s", resp.status_code, resp.text) + except Exception as exc: + logger.error("editMessageText error: %s", exc) + + async def set_webhook(url: str, secret: str) -> None: api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook") async with httpx.AsyncClient(timeout=10) as client: diff --git a/requirements.txt b/requirements.txt index c992449..9876432 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ aiosqlite>=0.20.0 httpx>=0.27.0 python-dotenv>=1.0.0 pydantic>=2.0 +email-validator>=2.0.0 +pywebpush>=2.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index 0801e32..727bf75 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,13 +73,15 @@ def make_app_client(): """ Async context manager that: 1. Assigns a fresh temp-file DB path - 2. Mocks Telegram setWebhook and sendMessage + 2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText 3. Runs the FastAPI lifespan (startup → test → shutdown) 4. Yields an httpx.AsyncClient wired to the app """ 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" + answer_cb_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/answerCallbackQuery" + edit_msg_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/editMessageText" @contextlib.asynccontextmanager async def _ctx(): @@ -96,6 +98,12 @@ def make_app_client(): 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}) + ) + mock_router.post(edit_msg_url).mock( + return_value=httpx.Response(200, json={"ok": True}) + ) with mock_router: async with app.router.lifespan_context(app): diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py new file mode 100644 index 0000000..c8f4617 --- /dev/null +++ b/tests/test_baton_008.py @@ -0,0 +1,349 @@ +""" +Tests for BATON-008: Registration flow with Telegram admin approval. + +Acceptance criteria: +1. POST /api/auth/register returns 201 with status='pending' on valid input +2. POST /api/auth/register returns 409 on email or login conflict +3. POST /api/auth/register returns 422 on invalid email/login/password +4. Telegram notification is fire-and-forget — 201 is returned even if Telegram fails +5. Webhook callback_query approve → db status='approved', push task fired if subscription present +6. Webhook callback_query reject → db status='rejected' +7. Webhook callback_query with unknown reg_id → returns {"ok": True} gracefully +""" +from __future__ import annotations + +import asyncio +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") + +from unittest.mock import AsyncMock, patch + +import pytest + +from tests.conftest import make_app_client + +_WEBHOOK_SECRET = "test-webhook-secret" +_WEBHOOK_HEADERS = {"X-Telegram-Bot-Api-Secret-Token": _WEBHOOK_SECRET} + +_VALID_PAYLOAD = { + "email": "user@example.com", + "login": "testuser", + "password": "strongpassword123", +} + + +# --------------------------------------------------------------------------- +# 1. Happy path — 201 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_returns_201_pending(): + """Valid registration request returns 201 with status='pending'.""" + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) + + assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}" + body = resp.json() + assert body["status"] == "pending" + assert "message" in body + + +@pytest.mark.asyncio +async def test_auth_register_fire_and_forget_telegram_error_still_returns_201(): + """Telegram failure must not break 201 — fire-and-forget pattern.""" + async with make_app_client() as client: + with patch( + "backend.telegram.send_registration_notification", + new_callable=AsyncMock, + side_effect=Exception("Telegram down"), + ): + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "other@example.com", "login": "otheruser"}, + ) + await asyncio.sleep(0) + + assert resp.status_code == 201, f"Telegram error must not break 201, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 2. Conflict — 409 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_409_on_duplicate_email(): + """Duplicate email returns 409.""" + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD) + assert r1.status_code == 201, f"First registration failed: {r1.text}" + + r2 = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "login": "differentlogin"}, + ) + + assert r2.status_code == 409, f"Expected 409 on duplicate email, got {r2.status_code}" + + +@pytest.mark.asyncio +async def test_auth_register_409_on_duplicate_login(): + """Duplicate login returns 409.""" + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD) + assert r1.status_code == 201, f"First registration failed: {r1.text}" + + r2 = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "different@example.com"}, + ) + + assert r2.status_code == 409, f"Expected 409 on duplicate login, got {r2.status_code}" + + +# --------------------------------------------------------------------------- +# 3. Validation — 422 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_422_invalid_email(): + """Invalid email format returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "not-an-email"}, + ) + assert resp.status_code == 422, f"Expected 422 on invalid email, got {resp.status_code}" + + +@pytest.mark.asyncio +async def test_auth_register_422_short_login(): + """Login shorter than 3 chars returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "login": "ab"}, + ) + assert resp.status_code == 422, f"Expected 422 on short login, got {resp.status_code}" + + +@pytest.mark.asyncio +async def test_auth_register_422_login_invalid_chars(): + """Login with spaces/special chars returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "login": "invalid login!"}, + ) + assert resp.status_code == 422, f"Expected 422 on login with spaces, got {resp.status_code}" + + +@pytest.mark.asyncio +async def test_auth_register_422_short_password(): + """Password shorter than 8 chars returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "password": "short"}, + ) + assert resp.status_code == 422, f"Expected 422 on short password, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 4. Telegram notification is sent to ADMIN_CHAT_ID +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_sends_notification_to_admin(): + """Registration triggers send_registration_notification with correct data.""" + calls: list[dict] = [] + + async def _capture(reg_id, login, email, created_at): + calls.append({"reg_id": reg_id, "login": login, "email": email}) + + 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"] + + +# --------------------------------------------------------------------------- +# 5. Webhook callback_query — approve +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_approve_updates_db_status(): + """approve callback updates registration status to 'approved' in DB.""" + from backend import db as _db + + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) + assert reg_resp.status_code == 201 + + # We need reg_id — get it from DB directly + reg_id = None + from tests.conftest import temp_db as _temp_db # noqa: F401 — already active + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + + assert reg_id is not None, "Registration not found in DB" + + cb_payload = { + "callback_query": { + "id": "cq_001", + "data": f"approve:{reg_id}", + "message": { + "message_id": 42, + "chat": {"id": 5694335584}, + }, + } + } + resp = await client.post( + "/api/webhook/telegram", + json=cb_payload, + headers=_WEBHOOK_HEADERS, + ) + await asyncio.sleep(0) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + # Verify DB status updated + reg = await _db.get_registration(reg_id) + assert reg is not None + assert reg["status"] == "approved", f"Expected status='approved', got {reg['status']!r}" + + +@pytest.mark.asyncio +async def test_webhook_callback_approve_fires_push_when_subscription_present(): + """approve callback triggers send_push when push_subscription is set.""" + push_sub = { + "endpoint": "https://fcm.googleapis.com/fcm/send/test", + "keys": {"p256dh": "BQABC", "auth": "xyz"}, + } + + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "push_subscription": push_sub}, + ) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + cb_payload = { + "callback_query": { + "id": "cq_002", + "data": f"approve:{reg_id}", + "message": {"message_id": 43, "chat": {"id": 5694335584}}, + } + } + push_calls: list = [] + + async def _capture_push(sub_json, title, body): + push_calls.append(sub_json) + + with patch("backend.push.send_push", side_effect=_capture_push): + await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + assert len(push_calls) == 1, f"Expected 1 push call, got {len(push_calls)}" + + +# --------------------------------------------------------------------------- +# 6. Webhook callback_query — reject +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_reject_updates_db_status(): + """reject callback updates registration status to 'rejected' in DB.""" + from backend import db as _db + + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + cb_payload = { + "callback_query": { + "id": "cq_003", + "data": f"reject:{reg_id}", + "message": {"message_id": 44, "chat": {"id": 5694335584}}, + } + } + resp = await client.post( + "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS + ) + await asyncio.sleep(0) + + assert resp.status_code == 200 + + reg = await _db.get_registration(reg_id) + assert reg is not None + assert reg["status"] == "rejected", f"Expected status='rejected', got {reg['status']!r}" + + +# --------------------------------------------------------------------------- +# 7. Unknown reg_id — graceful handling +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_unknown_reg_id_returns_ok(): + """callback_query with unknown reg_id returns ok without error.""" + async with make_app_client() as client: + cb_payload = { + "callback_query": { + "id": "cq_999", + "data": "approve:99999", + "message": {"message_id": 1, "chat": {"id": 5694335584}}, + } + } + resp = await client.post( + "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS + ) + await asyncio.sleep(0) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True}