""" Tests for BATON-008: Frontend registration module. Acceptance criteria: 1. index.html — форма регистрации с полями email, login, password присутствует 2. index.html — НЕТ захардкоженных VAPID-ключей в HTML-атрибутах (decision #1333) 3. app.js — вызов /api/push/public-key (не старый /api/vapid-public-key) (decision #1331) 4. app.js — guard для PushManager (decision #1332) 5. app.js — обработчик для кнопки регистрации (#btn-register → _handleSignUp) 6. app.js — переключение между view-login и view-register 7. app.js — показ ошибок пользователю (_setRegStatus) 8. GET /api/push/public-key → 200 с vapid_public_key (API контракт) 9. POST /api/auth/register с валидными данными → 201 (API контракт) 10. POST /api/auth/register с дублирующим email → 409 11. POST /api/auth/register с дублирующим login → 409 12. POST /api/auth/register с невалидным email → 422 """ from __future__ import annotations import re from pathlib import Path from unittest.mock import AsyncMock, patch import pytest PROJECT_ROOT = Path(__file__).parent.parent INDEX_HTML = PROJECT_ROOT / "frontend" / "index.html" APP_JS = PROJECT_ROOT / "frontend" / "app.js" from tests.conftest import make_app_client _VALID_PAYLOAD = { "email": "frontend_test@example.com", "login": "frontenduser", "password": "strongpassword123", } # --------------------------------------------------------------------------- # HTML static analysis — Criterion 1: поля формы регистрации # --------------------------------------------------------------------------- def test_index_html_has_email_field() -> None: """index.html должен содержать поле email для регистрации (id=reg-email).""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="reg-email"' in content, ( "index.html не содержит поле с id='reg-email'" ) def test_index_html_has_login_field() -> None: """index.html должен содержать поле логина для регистрации (id=reg-login).""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="reg-login"' in content, ( "index.html не содержит поле с id='reg-login'" ) def test_index_html_has_password_field() -> None: """index.html должен содержать поле пароля для регистрации (id=reg-password).""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="reg-password"' in content, ( "index.html не содержит поле с id='reg-password'" ) def test_index_html_email_field_has_correct_type() -> None: """Поле email регистрации должно иметь type='email'.""" content = INDEX_HTML.read_text(encoding="utf-8") # Ищем input с id=reg-email и type=email в любом порядке атрибутов email_input_block = re.search( r']*id="reg-email"[^>]*>', content, re.DOTALL ) assert email_input_block is not None, "Не найден input с id='reg-email'" assert 'type="email"' in email_input_block.group(0), ( "Поле reg-email не имеет type='email'" ) def test_index_html_password_field_has_correct_type() -> None: """Поле пароля регистрации должно иметь type='password'.""" content = INDEX_HTML.read_text(encoding="utf-8") password_input_block = re.search( r']*id="reg-password"[^>]*>', content, re.DOTALL ) assert password_input_block is not None, "Не найден input с id='reg-password'" assert 'type="password"' in password_input_block.group(0), ( "Поле reg-password не имеет type='password'" ) def test_index_html_has_register_button() -> None: """index.html должен содержать кнопку регистрации (id=btn-register).""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="btn-register"' in content, ( "index.html не содержит кнопку с id='btn-register'" ) def test_index_html_has_switch_to_register_button() -> None: """index.html должен содержать кнопку переключения на форму регистрации (id=btn-switch-to-register).""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="btn-switch-to-register"' in content, ( "index.html не содержит кнопку с id='btn-switch-to-register'" ) def test_index_html_has_view_register_div() -> None: """index.html должен содержать блок view-register для формы регистрации.""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="view-register"' in content, ( "index.html не содержит блок с id='view-register'" ) def test_index_html_has_view_login_div() -> None: """index.html должен содержать блок view-login для онбординга.""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="view-login"' in content, ( "index.html не содержит блок с id='view-login'" ) def test_index_html_has_reg_status_element() -> None: """index.html должен содержать элемент статуса регистрации (id=reg-status).""" content = INDEX_HTML.read_text(encoding="utf-8") assert 'id="reg-status"' in content, ( "index.html не содержит элемент с id='reg-status'" ) # --------------------------------------------------------------------------- # HTML static analysis — Criterion 2: НЕТ захардкоженного VAPID в HTML (decision #1333) # --------------------------------------------------------------------------- def test_index_html_no_hardcoded_vapid_key_in_meta() -> None: """index.html НЕ должен содержать VAPID-ключ захардкоженным в meta-теге (decision #1333).""" content = INDEX_HTML.read_text(encoding="utf-8") # VAPID public key — это URL-safe base64 строка длиной 87 символов (без padding) # Ищем характерный паттерн в meta-атрибутах vapid_in_meta = re.search( r']+content\s*=\s*["\'][A-Za-z0-9_\-]{60,}["\'][^>]*>', content, ) assert vapid_in_meta is None, ( f"Найден meta-тег с длинной строкой (возможный VAPID-ключ): " f"{vapid_in_meta.group(0) if vapid_in_meta else ''}" ) def test_index_html_no_vapid_key_attribute_pattern() -> None: """index.html НЕ должен содержать data-vapid-key или аналогичные атрибуты.""" content = INDEX_HTML.read_text(encoding="utf-8") assert "vapid" not in content.lower(), ( "index.html содержит упоминание 'vapid' — VAPID ключ должен читаться через API, " "а не быть захардкожен в HTML (decision #1333)" ) # --------------------------------------------------------------------------- # app.js static analysis — Criterion 3: /api/push/public-key endpoint (decision #1331) # --------------------------------------------------------------------------- def test_app_js_uses_new_vapid_endpoint() -> None: """app.js должен обращаться к /api/push/public-key (decision #1331).""" content = APP_JS.read_text(encoding="utf-8") assert "/api/push/public-key" in content, ( "app.js не содержит endpoint '/api/push/public-key'" ) def test_app_js_does_not_use_old_vapid_endpoint() -> None: """app.js НЕ должен использовать устаревший /api/vapid-public-key (decision #1331).""" content = APP_JS.read_text(encoding="utf-8") assert "/api/vapid-public-key" not in content, ( "app.js содержит устаревший endpoint '/api/vapid-public-key' — " "нарушение decision #1331, должен использоваться '/api/push/public-key'" ) # --------------------------------------------------------------------------- # app.js static analysis — Criterion 4: PushManager guard (decision #1332) # --------------------------------------------------------------------------- def test_app_js_has_push_manager_guard_in_registration_flow() -> None: """app.js должен содержать guard 'PushManager' in window (decision #1332).""" content = APP_JS.read_text(encoding="utf-8") assert "'PushManager' in window" in content, ( "app.js не содержит guard \"'PushManager' in window\" — " "нарушение decision #1332" ) def test_app_js_push_manager_guard_combined_with_service_worker_check() -> None: """Guard PushManager должен сочетаться с проверкой serviceWorker.""" content = APP_JS.read_text(encoding="utf-8") # Ищем паттерн совместной проверки serviceWorker + PushManager assert re.search( r"serviceWorker.*PushManager|PushManager.*serviceWorker", content, re.DOTALL, ), ( "app.js не содержит совместной проверки 'serviceWorker' и 'PushManager' — " "guard неполный (decision #1332)" ) # --------------------------------------------------------------------------- # app.js static analysis — Criterion 5: обработчик кнопки регистрации # --------------------------------------------------------------------------- def test_app_js_has_handle_sign_up_function() -> None: """app.js должен содержать функцию _handleSignUp.""" content = APP_JS.read_text(encoding="utf-8") assert "_handleSignUp" in content, ( "app.js не содержит функцию '_handleSignUp'" ) def test_app_js_registers_click_handler_for_btn_register() -> None: """app.js должен добавлять click-обработчик на btn-register → _handleSignUp.""" content = APP_JS.read_text(encoding="utf-8") # Ищем addEventListener на элементе btn-register с вызовом _handleSignUp assert re.search( r'btn-register.*addEventListener|addEventListener.*btn-register', content, re.DOTALL, ), ( "app.js не содержит addEventListener для кнопки 'btn-register'" ) # Проверяем что именно _handleSignUp привязан к кнопке assert re.search( r'btn[Rr]egister.*_handleSignUp|_handleSignUp.*btn[Rr]egister', content, re.DOTALL, ), ( "app.js не связывает кнопку 'btn-register' с функцией '_handleSignUp'" ) # --------------------------------------------------------------------------- # app.js static analysis — Criterion 6: переключение view-login / view-register # --------------------------------------------------------------------------- def test_app_js_has_show_view_function() -> None: """app.js должен содержать функцию _showView для переключения видов.""" content = APP_JS.read_text(encoding="utf-8") assert "_showView" in content, ( "app.js не содержит функцию '_showView'" ) def test_app_js_show_view_handles_view_login() -> None: """_showView в app.js должна обрабатывать view-login.""" content = APP_JS.read_text(encoding="utf-8") assert "view-login" in content, ( "app.js не содержит id 'view-login' — нет переключения на вид логина" ) def test_app_js_show_view_handles_view_register() -> None: """_showView в app.js должна обрабатывать view-register.""" content = APP_JS.read_text(encoding="utf-8") assert "view-register" in content, ( "app.js не содержит id 'view-register' — нет переключения на вид регистрации" ) def test_app_js_has_btn_switch_to_register_handler() -> None: """app.js должен содержать обработчик для btn-switch-to-register.""" content = APP_JS.read_text(encoding="utf-8") assert "btn-switch-to-register" in content, ( "app.js не содержит ссылку на 'btn-switch-to-register'" ) def test_app_js_has_btn_switch_to_login_handler() -> None: """app.js должен содержать обработчик для btn-switch-to-login (назад).""" content = APP_JS.read_text(encoding="utf-8") assert "btn-switch-to-login" in content, ( "app.js не содержит ссылку на 'btn-switch-to-login'" ) # --------------------------------------------------------------------------- # app.js static analysis — Criterion 7: обработка ошибок / показ сообщения пользователю # --------------------------------------------------------------------------- def test_app_js_has_set_reg_status_function() -> None: """app.js должен содержать _setRegStatus для показа статуса в форме регистрации.""" content = APP_JS.read_text(encoding="utf-8") assert "_setRegStatus" in content, ( "app.js не содержит функцию '_setRegStatus'" ) def test_app_js_handle_sign_up_shows_error_on_empty_fields() -> None: """_handleSignUp должна вызывать _setRegStatus с ошибкой при пустых полях.""" content = APP_JS.read_text(encoding="utf-8") # Проверяем наличие валидации пустых полей внутри _handleSignUp-подобного блока assert re.search( r"_setRegStatus\s*\([^)]*error", content, ), ( "app.js не содержит вызов _setRegStatus с классом 'error' " "— ошибки не отображаются пользователю" ) def test_app_js_handle_sign_up_shows_success_on_ok() -> None: """_handleSignUp должна вызывать _setRegStatus с success при успешной регистрации.""" content = APP_JS.read_text(encoding="utf-8") assert re.search( r"_setRegStatus\s*\([^)]*success", content, ), ( "app.js не содержит вызов _setRegStatus с классом 'success' " "— пользователь не уведомляется об успехе регистрации" ) def test_app_js_clears_password_after_successful_signup() -> None: """_handleSignUp должна очищать поле пароля после успешной отправки.""" content = APP_JS.read_text(encoding="utf-8") # Ищем сброс значения пароля assert re.search( r"passwordInput\.value\s*=\s*['\"][\s]*['\"]", content, ), ( "app.js не очищает поле пароля после успешной регистрации — " "пароль остаётся в DOM (security concern)" ) def test_app_js_uses_api_auth_register_endpoint() -> None: """app.js должен отправлять форму на /api/auth/register.""" content = APP_JS.read_text(encoding="utf-8") assert "/api/auth/register" in content, ( "app.js не содержит endpoint '/api/auth/register'" ) # --------------------------------------------------------------------------- # Integration tests — API контракты (Criteria 8–12) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_vapid_public_key_endpoint_returns_200_with_key(): """GET /api/push/public-key → 200 с полем vapid_public_key.""" async with make_app_client() as client: resp = await client.get("/api/push/public-key") assert resp.status_code == 200, ( f"GET /api/push/public-key вернул {resp.status_code}, ожидался 200" ) body = resp.json() assert "vapid_public_key" in body, ( f"Ответ /api/push/public-key не содержит 'vapid_public_key': {body}" ) assert isinstance(body["vapid_public_key"], str), ( "vapid_public_key должен быть строкой" ) @pytest.mark.asyncio async def test_register_valid_payload_returns_201_pending(): """POST /api/auth/register с валидными данными → 201 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"POST /api/auth/register вернул {resp.status_code}: {resp.text}" ) body = resp.json() assert body.get("status") == "pending", ( f"Ожидался status='pending', получено: {body}" ) assert "message" in body, ( f"Ответ не содержит поле 'message': {body}" ) @pytest.mark.asyncio async def test_register_duplicate_email_returns_409(): """POST /api/auth/register с дублирующим email → 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"Первая регистрация не прошла: {r1.text}" r2 = await client.post( "/api/auth/register", json={**_VALID_PAYLOAD, "login": "anotherlogin"}, ) assert r2.status_code == 409, ( f"Дублирующий email должен вернуть 409, получено {r2.status_code}" ) @pytest.mark.asyncio async def test_register_duplicate_login_returns_409(): """POST /api/auth/register с дублирующим login → 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"Первая регистрация не прошла: {r1.text}" r2 = await client.post( "/api/auth/register", json={**_VALID_PAYLOAD, "email": "another@example.com"}, ) assert r2.status_code == 409, ( f"Дублирующий login должен вернуть 409, получено {r2.status_code}" ) @pytest.mark.asyncio async def test_register_invalid_email_returns_422(): """POST /api/auth/register с невалидным email → 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"Невалидный email должен вернуть 422, получено {resp.status_code}" )