kin: BATON-008-backend_dev

This commit is contained in:
Gros Frumos 2026-03-21 09:19:50 +02:00
parent e21bcb1eb4
commit 4c9fec17de
11 changed files with 651 additions and 4 deletions

1
=2.0.0 Normal file
View file

@ -0,0 +1 @@
(eval):1: command not found: pip

View file

@ -22,3 +22,7 @@ WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
ADMIN_TOKEN: str = _require("ADMIN_TOKEN") 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", "")

View file

@ -67,6 +67,23 @@ async def init_db() -> None:
count INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0,
window_start REAL 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) # Migrations for existing databases (silently ignore if columns already exist)
for stmt in [ for stmt in [
@ -284,6 +301,56 @@ async def rate_limit_increment(key: str, window: float) -> int:
return row["count"] if row else 1 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( async def save_telegram_batch(
message_text: str, message_text: str,
signals_count: int, signals_count: int,

View file

@ -15,12 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db, telegram from backend import config, db, push, telegram
from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret from backend.middleware import rate_limit_auth_register, rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
from backend.models import ( from backend.models import (
AdminBlockRequest, AdminBlockRequest,
AdminCreateUserRequest, AdminCreateUserRequest,
AdminSetPasswordRequest, AdminSetPasswordRequest,
AuthRegisterRequest,
AuthRegisterResponse,
RegisterRequest, RegisterRequest,
RegisterResponse, RegisterResponse,
SignalRequest, SignalRequest,
@ -182,6 +184,84 @@ async def signal(
return SignalResponse(status="ok", signal_id=signal_id) 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)]) @app.get("/admin/users", dependencies=[Depends(verify_admin_token)])
async def admin_list_users() -> list[dict]: async def admin_list_users() -> list[dict]:
return await db.admin_list_users() return await db.admin_list_users()
@ -226,6 +306,13 @@ async def webhook_telegram(
_: None = Depends(verify_webhook_secret), _: None = Depends(verify_webhook_secret),
) -> dict[str, Any]: ) -> dict[str, Any]:
update = await request.json() 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", {}) message = update.get("message", {})
text = message.get("text", "") text = message.get("text", "")

View file

@ -16,6 +16,9 @@ _RATE_WINDOW = 600 # 10 minutes
_SIGNAL_RATE_LIMIT = 10 _SIGNAL_RATE_LIMIT = 10
_SIGNAL_RATE_WINDOW = 60 # 1 minute _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: def _get_client_ip(request: Request) -> str:
return ( return (
@ -55,3 +58,10 @@ async def rate_limit_signal(request: Request) -> None:
count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW) count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW)
if count > _SIGNAL_RATE_LIMIT: if count > _SIGNAL_RATE_LIMIT:
raise HTTPException(status_code=429, detail="Too Many Requests") 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")

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
@ -44,3 +44,25 @@ class AdminSetPasswordRequest(BaseModel):
class AdminBlockRequest(BaseModel): class AdminBlockRequest(BaseModel):
is_blocked: bool 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

35
backend/push.py Normal file
View file

@ -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)

View file

@ -55,6 +55,68 @@ async def send_message(text: str) -> None:
logger.error("Telegram send_message: all 3 attempts failed, message dropped") 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: async def set_webhook(url: str, secret: str) -> None:
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook") api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:

View file

@ -4,3 +4,5 @@ aiosqlite>=0.20.0
httpx>=0.27.0 httpx>=0.27.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
pydantic>=2.0 pydantic>=2.0
email-validator>=2.0.0
pywebpush>=2.0.0

View file

@ -73,13 +73,15 @@ def make_app_client():
""" """
Async context manager that: Async context manager that:
1. Assigns a fresh temp-file DB path 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) 3. Runs the FastAPI lifespan (startup test shutdown)
4. Yields an httpx.AsyncClient wired to the app 4. Yields an httpx.AsyncClient wired to the app
""" """
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook" 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" 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" 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 @contextlib.asynccontextmanager
async def _ctx(): async def _ctx():
@ -96,6 +98,12 @@ def make_app_client():
mock_router.post(send_url).mock( mock_router.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True}) 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: with mock_router:
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):

349
tests/test_baton_008.py Normal file
View file

@ -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}