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}"
+ )