Merge branch 'BATON-008-backend_dev'

This commit is contained in:
Gros Frumos 2026-03-21 09:34:21 +02:00
commit c7661d7c1e
5 changed files with 48 additions and 20 deletions

View file

@ -22,7 +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") ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID")
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")

View file

@ -341,9 +341,10 @@ async def get_registration(reg_id: int) -> Optional[dict]:
async def update_registration_status(reg_id: int, status: str) -> bool: 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 _get_conn() as conn:
async with conn.execute( async with conn.execute(
"UPDATE registrations SET status = ? WHERE id = ?", "UPDATE registrations SET status = ? WHERE id = ? AND status = 'pending'",
(status, reg_id), (status, reg_id),
) as cur: ) as cur:
changed = cur.rowcount > 0 changed = cur.rowcount > 0

View file

@ -240,7 +240,11 @@ async def _handle_callback_query(cb: dict) -> None:
return return
if action == "approve": 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: if chat_id and message_id:
await telegram.edit_message_text( await telegram.edit_message_text(
chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен" chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен"
@ -254,7 +258,10 @@ async def _handle_callback_query(cb: dict) -> None:
) )
) )
elif action == "reject": 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: if chat_id and message_id:
await telegram.edit_message_text( await telegram.edit_message_text(
chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён" chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён"

View file

@ -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("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
# ── 2. aiosqlite monkey-patch ──────────────────────────────────────────────── # ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
import aiosqlite import aiosqlite
@ -69,14 +70,20 @@ def temp_db():
# ── 5. App client factory ──────────────────────────────────────────────────── # ── 5. App client factory ────────────────────────────────────────────────────
def make_app_client(): def make_app_client(capture_send_requests: list | None = None):
""" """
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, sendMessage, answerCallbackQuery, editMessageText 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
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" 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"
@ -95,6 +102,15 @@ def make_app_client():
mock_router.post(tg_set_url).mock( mock_router.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True}) return_value=httpx.Response(200, json={"ok": True, "result": 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( mock_router.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True}) return_value=httpx.Response(200, json={"ok": True})
) )

View file

@ -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("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -167,20 +168,23 @@ async def test_auth_register_422_short_password():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auth_register_sends_notification_to_admin(): async def test_auth_register_sends_notification_to_admin():
"""Registration triggers send_registration_notification with correct data.""" """Registration triggers real HTTP sendMessage to ADMIN_CHAT_ID with correct login/email."""
calls: list[dict] = [] from backend import config as _cfg
async def _capture(reg_id, login, email, created_at): captured: list[dict] = []
calls.append({"reg_id": reg_id, "login": login, "email": email}) async with make_app_client(capture_send_requests=captured) as client:
resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
async with make_app_client() as client: assert resp.status_code == 201
with patch("backend.telegram.send_registration_notification", side_effect=_capture):
await client.post("/api/auth/register", json=_VALID_PAYLOAD)
await asyncio.sleep(0) await asyncio.sleep(0)
assert len(calls) == 1, f"Expected 1 notification call, got {len(calls)}" admin_chat_id = str(_cfg.ADMIN_CHAT_ID)
assert calls[0]["login"] == _VALID_PAYLOAD["login"] admin_msgs = [r for r in captured if str(r.get("chat_id")) == admin_chat_id]
assert calls[0]["email"] == _VALID_PAYLOAD["email"] 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}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------