From baf05b6d84d7369c46a57bd88a707c0330ce20b3 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:47:36 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-BIZ-004=20=D0=A3=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D1=8E=D1=89=D1=83=D1=8E=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D1=83=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=20telegram.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_arch_002.py | 24 +- tests/test_baton_008_frontend.py | 439 +++++++++++++++++++++++++++++++ 2 files changed, 447 insertions(+), 16 deletions(-) create mode 100644 tests/test_baton_008_frontend.py diff --git a/tests/test_arch_002.py b/tests/test_arch_002.py index c979b1d..dd737b9 100644 --- a/tests/test_arch_002.py +++ b/tests/test_arch_002.py @@ -168,25 +168,17 @@ async def test_signal_message_contains_utc_marker(): # --------------------------------------------------------------------------- -# Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static) +# Criterion 3 — SignalAggregator removed (BATON-BIZ-004: dead code cleanup) # --------------------------------------------------------------------------- -def test_signal_aggregator_class_preserved_in_telegram(): - """SignalAggregator class must still exist in telegram.py (ADR-004, reserved for v2).""" +def test_signal_aggregator_class_removed_from_telegram(): + """SignalAggregator must be removed from telegram.py (BATON-BIZ-004).""" source = (_BACKEND_DIR / "telegram.py").read_text() - assert "class SignalAggregator" in source + assert "class SignalAggregator" not in source -def test_signal_aggregator_has_v2_feature_comment(): - """The line immediately before 'class SignalAggregator' must contain '# v2.0 feature'.""" - lines = (_BACKEND_DIR / "telegram.py").read_text().splitlines() - class_line_idx = next( - (i for i, line in enumerate(lines) if "class SignalAggregator" in line), None - ) - assert class_line_idx is not None, "class SignalAggregator not found in telegram.py" - assert class_line_idx > 0, "SignalAggregator is on the first line — no preceding comment line" - preceding_line = lines[class_line_idx - 1] - assert "# v2.0 feature" in preceding_line, ( - f"Expected '# v2.0 feature' on line before class SignalAggregator, got: {preceding_line!r}" - ) +def test_signal_aggregator_not_referenced_in_telegram(): + """telegram.py must not reference SignalAggregator at all (BATON-BIZ-004).""" + source = (_BACKEND_DIR / "telegram.py").read_text() + assert "SignalAggregator" not in source diff --git a/tests/test_baton_008_frontend.py b/tests/test_baton_008_frontend.py new file mode 100644 index 0000000..c19bd96 --- /dev/null +++ b/tests/test_baton_008_frontend.py @@ -0,0 +1,439 @@ +""" +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}" + )