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

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}