diff --git a/frontend/app.js b/frontend/app.js
index 241694e..de54b6f 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -92,6 +92,21 @@ function _setStatus(msg, cls) {
el.hidden = !msg;
}
+function _setRegStatus(msg, cls) {
+ const el = document.getElementById('reg-status');
+ if (!el) return;
+ el.textContent = msg;
+ el.className = 'reg-status' + (cls ? ' reg-status--' + cls : '');
+ el.hidden = !msg;
+}
+
+function _showView(id) {
+ ['view-login', 'view-register'].forEach((vid) => {
+ const el = document.getElementById(vid);
+ if (el) el.hidden = vid !== id;
+ });
+}
+
function _updateNetworkIndicator() {
const el = document.getElementById('indicator-network');
if (!el) return;
@@ -210,6 +225,7 @@ async function _handleSignal() {
function _showOnboarding() {
_showScreen('screen-onboarding');
+ _showView('view-login');
const input = document.getElementById('name-input');
const btn = document.getElementById('btn-confirm');
@@ -221,6 +237,24 @@ function _showOnboarding() {
if (e.key === 'Enter' && !btn.disabled) _handleRegister();
});
btn.addEventListener('click', _handleRegister);
+
+ const btnToRegister = document.getElementById('btn-switch-to-register');
+ if (btnToRegister) {
+ btnToRegister.addEventListener('click', () => {
+ _setRegStatus('', '');
+ _showView('view-register');
+ });
+ }
+
+ const btnToLogin = document.getElementById('btn-switch-to-login');
+ if (btnToLogin) {
+ btnToLogin.addEventListener('click', () => _showView('view-login'));
+ }
+
+ const btnRegister = document.getElementById('btn-register');
+ if (btnRegister) {
+ btnRegister.addEventListener('click', _handleSignUp);
+ }
}
function _showMain() {
@@ -295,6 +329,75 @@ async function _initPushSubscription(vapidPublicKey) {
}
}
+// ========== Registration (account sign-up) ==========
+
+async function _getPushSubscriptionForReg() {
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
+ try {
+ const vapidKey = await _fetchVapidPublicKey();
+ if (!vapidKey) return null;
+ const registration = await navigator.serviceWorker.ready;
+ const existing = await registration.pushManager.getSubscription();
+ if (existing) return existing.toJSON();
+ const applicationServerKey = _urlBase64ToUint8Array(vapidKey);
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey,
+ });
+ return subscription.toJSON();
+ } catch (err) {
+ console.warn('[baton] Push subscription for registration failed:', err);
+ return null;
+ }
+}
+
+async function _handleSignUp() {
+ const emailInput = document.getElementById('reg-email');
+ const loginInput = document.getElementById('reg-login');
+ const passwordInput = document.getElementById('reg-password');
+ const btn = document.getElementById('btn-register');
+ if (!emailInput || !loginInput || !passwordInput || !btn) return;
+
+ const email = emailInput.value.trim();
+ const login = loginInput.value.trim();
+ const password = passwordInput.value;
+
+ if (!email || !login || !password) {
+ _setRegStatus('Заполните все поля.', 'error');
+ return;
+ }
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ _setRegStatus('Введите корректный email.', 'error');
+ return;
+ }
+
+ btn.disabled = true;
+ const originalText = btn.textContent.trim();
+ btn.textContent = '...';
+ _setRegStatus('', '');
+
+ try {
+ const push_subscription = await _getPushSubscriptionForReg().catch(() => null);
+ await _apiPost('/api/auth/register', { email, login, password, push_subscription });
+ passwordInput.value = '';
+ _setRegStatus('Заявка отправлена. Ожидайте подтверждения администратора.', 'success');
+ } catch (err) {
+ let msg = 'Ошибка. Попробуйте ещё раз.';
+ if (err && err.message) {
+ const colonIdx = err.message.indexOf(': ');
+ if (colonIdx !== -1) {
+ try {
+ const parsed = JSON.parse(err.message.slice(colonIdx + 2));
+ if (parsed.detail) msg = parsed.detail;
+ } catch (_) {}
+ }
+ }
+ _setRegStatus(msg, 'error');
+ btn.disabled = false;
+ btn.textContent = originalText;
+ }
+}
+
// ========== Init ==========
function _init() {
diff --git a/frontend/index.html b/frontend/index.html
index e5fe30e..f04d171 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -36,7 +36,9 @@
-
+
+
+
Confirm
+
+
+
+
+
diff --git a/frontend/style.css b/frontend/style.css
index 487a443..36f7685 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -198,3 +198,35 @@ body {
.status[hidden] { display: none; }
.status--error { color: #f87171; }
.status--success { color: #4ade80; }
+
+/* ===== Registration form ===== */
+
+/* Override display:flex so [hidden] works on screen-content divs */
+.screen-content[hidden] { display: none; }
+
+.btn-link {
+ background: none;
+ border: none;
+ color: var(--muted);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 4px 0;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.btn-link:active { color: var(--text); }
+
+.reg-status {
+ width: 100%;
+ max-width: 320px;
+ font-size: 14px;
+ text-align: center;
+ line-height: 1.5;
+ padding: 4px 0;
+}
+
+.reg-status[hidden] { display: none; }
+.reg-status--error { color: #f87171; }
+.reg-status--success { color: #4ade80; }
diff --git a/tests/test_biz_002.py b/tests/test_biz_002.py
new file mode 100644
index 0000000..0136df7
--- /dev/null
+++ b/tests/test_biz_002.py
@@ -0,0 +1,203 @@
+"""
+Tests for BATON-BIZ-002: Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key
+
+Acceptance criteria:
+1. Meta-тег vapid-public-key полностью отсутствует в frontend/index.html (decision #1333).
+2. app.js использует canonical URL /api/push/public-key для получения VAPID ключа.
+3. Graceful fallback: endpoint недоступен → функция возвращает null, не бросает исключение.
+4. Graceful fallback: ключ пустой → _initPushSubscription не выполняется (guard на null).
+5. GET /api/push/public-key возвращает HTTP 200 с полем vapid_public_key.
+6. GET /api/push/public-key возвращает правильное значение из конфига.
+"""
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+from unittest.mock import patch
+
+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")
+os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
+
+import pytest
+
+from tests.conftest import make_app_client
+
+PROJECT_ROOT = Path(__file__).parent.parent
+INDEX_HTML = PROJECT_ROOT / "frontend" / "index.html"
+APP_JS = PROJECT_ROOT / "frontend" / "app.js"
+
+_TEST_VAPID_KEY = "BFakeVapidPublicKeyForBiz002TestingBase64UrlEncoded"
+
+
+# ---------------------------------------------------------------------------
+# Criterion 1 — AST: meta-тег vapid-public-key полностью отсутствует
+# ---------------------------------------------------------------------------
+
+
+def test_index_html_has_no_meta_tag_named_vapid_public_key() -> None:
+ """index.html не должен содержать
вообще (decision #1333)."""
+ content = INDEX_HTML.read_text(encoding="utf-8")
+ match = re.search(
+ r'
]+name\s*=\s*["\']vapid-public-key["\']',
+ content,
+ re.IGNORECASE,
+ )
+ assert match is None, (
+ f"index.html содержит удалённый тег
: {match.group(0)!r}"
+ )
+
+
+def test_index_html_has_no_vapid_meta_tag_with_empty_or_any_content() -> None:
+ """index.html не должен содержать ни пустой, ни непустой VAPID ключ в meta content."""
+ content = INDEX_HTML.read_text(encoding="utf-8")
+ match = re.search(
+ r'
]*(?:vapid|application-server-key)[^>]*content\s*=',
+ content,
+ re.IGNORECASE,
+ )
+ assert match is None, (
+ f"index.html содержит
-тег с VAPID-связанным атрибутом content: {match.group(0)!r}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 2 — AST: app.js использует canonical /api/push/public-key
+# ---------------------------------------------------------------------------
+
+
+def test_app_js_fetch_vapid_uses_canonical_push_public_key_url() -> None:
+ """_fetchVapidPublicKey в app.js должна использовать /api/push/public-key (canonical URL)."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert "/api/push/public-key" in content, (
+ "app.js не содержит canonical URL '/api/push/public-key' — "
+ "ключ не читается через правильный endpoint"
+ )
+
+
+def test_app_js_fetch_vapid_returns_vapid_public_key_field() -> None:
+ """_fetchVapidPublicKey должна читать поле vapid_public_key из JSON-ответа."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert re.search(r"data\.vapid_public_key", content), (
+ "app.js не читает поле 'data.vapid_public_key' из ответа API"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 3 — AST: graceful fallback когда endpoint недоступен
+# ---------------------------------------------------------------------------
+
+
+def test_app_js_fetch_vapid_returns_null_on_http_error() -> None:
+ """_fetchVapidPublicKey должна возвращать null при res.ok === false (HTTP-ошибка)."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert re.search(r"if\s*\(\s*!\s*res\.ok\s*\)", content), (
+ "app.js не содержит проверку 'if (!res.ok)' — "
+ "HTTP-ошибки не обрабатываются gracefully в _fetchVapidPublicKey"
+ )
+
+
+def test_app_js_fetch_vapid_catches_network_errors() -> None:
+ """_fetchVapidPublicKey должна оборачивать fetch в try/catch и возвращать null при сетевой ошибке."""
+ content = APP_JS.read_text(encoding="utf-8")
+ # Проверяем паттерн try { fetch ... } catch (err) { return null; } внутри функции
+ func_match = re.search(
+ r"async function _fetchVapidPublicKey\(\).*?(?=^(?:async )?function |\Z)",
+ content,
+ re.DOTALL | re.MULTILINE,
+ )
+ assert func_match, "Функция _fetchVapidPublicKey не найдена в app.js"
+ func_body = func_match.group(0)
+ assert "catch" in func_body, (
+ "app.js: _fetchVapidPublicKey не содержит блок catch — "
+ "сетевые ошибки при fetch не обрабатываются"
+ )
+ assert re.search(r"return\s+null", func_body), (
+ "app.js: _fetchVapidPublicKey не возвращает null при ошибке — "
+ "upstream код получит исключение вместо null"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 4 — AST: graceful fallback когда ключ пустой (decision #1332)
+# ---------------------------------------------------------------------------
+
+
+def test_app_js_fetch_vapid_returns_null_on_empty_key() -> None:
+ """_fetchVapidPublicKey должна возвращать null когда vapid_public_key пустой."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert re.search(r"data\.vapid_public_key\s*\|\|\s*null", content), (
+ "app.js не содержит 'data.vapid_public_key || null' — "
+ "пустой ключ не преобразуется в null"
+ )
+
+
+def test_app_js_init_push_subscription_guard_skips_on_null_key() -> None:
+ """_initPushSubscription должна ранним возвратом пропускать подписку при null ключе (decision #1332)."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert re.search(r"if\s*\(\s*!\s*vapidPublicKey\s*\)", content), (
+ "app.js: _initPushSubscription не содержит guard 'if (!vapidPublicKey)' — "
+ "подписка может быть создана без ключа"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 5 — HTTP: GET /api/push/public-key → 200 + vapid_public_key
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_push_public_key_endpoint_returns_200() -> None:
+ """GET /api/push/public-key должен вернуть HTTP 200."""
+ async with make_app_client() as client:
+ response = await client.get("/api/push/public-key")
+ assert response.status_code == 200, (
+ f"GET /api/push/public-key вернул {response.status_code}, ожидался 200"
+ )
+
+
+@pytest.mark.asyncio
+async def test_push_public_key_endpoint_returns_json_with_vapid_field() -> None:
+ """GET /api/push/public-key должен вернуть JSON с полем vapid_public_key."""
+ async with make_app_client() as client:
+ response = await client.get("/api/push/public-key")
+ data = response.json()
+ assert "vapid_public_key" in data, (
+ f"Ответ /api/push/public-key не содержит поле 'vapid_public_key': {data!r}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 6 — HTTP: возвращает правильное значение из конфига
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_push_public_key_endpoint_returns_configured_value() -> None:
+ """GET /api/push/public-key возвращает значение из VAPID_PUBLIC_KEY конфига."""
+ with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_KEY):
+ async with make_app_client() as client:
+ response = await client.get("/api/push/public-key")
+ data = response.json()
+ assert data.get("vapid_public_key") == _TEST_VAPID_KEY, (
+ f"vapid_public_key должен быть '{_TEST_VAPID_KEY}', "
+ f"получили: {data.get('vapid_public_key')!r}"
+ )
+
+
+@pytest.mark.asyncio
+async def test_push_public_key_endpoint_returns_empty_string_when_not_configured() -> None:
+ """GET /api/push/public-key возвращает пустую строку (не ошибку) если ключ не настроен."""
+ with patch("backend.config.VAPID_PUBLIC_KEY", ""):
+ async with make_app_client() as client:
+ response = await client.get("/api/push/public-key")
+ assert response.status_code == 200, (
+ f"Endpoint вернул {response.status_code} при пустом ключе, ожидался 200"
+ )
+ data = response.json()
+ assert "vapid_public_key" in data, "Поле vapid_public_key отсутствует при пустом конфиге"
diff --git a/tests/test_biz_004.py b/tests/test_biz_004.py
new file mode 100644
index 0000000..228f2ac
--- /dev/null
+++ b/tests/test_biz_004.py
@@ -0,0 +1,96 @@
+"""
+BATON-BIZ-004: Verify removal of dead code from backend/telegram.py.
+
+Acceptance criteria:
+1. telegram.py does NOT contain duplicate logging setLevel calls for httpx/httpcore.
+2. telegram.py does NOT contain the SignalAggregator class.
+3. httpx/httpcore logging suppression is still configured in main.py (globally).
+4. SignalAggregator is NOT importable from backend.telegram.
+"""
+from __future__ import annotations
+
+import ast
+import importlib
+import inspect
+import logging
+import os
+from pathlib import Path
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_BACKEND_DIR = Path(__file__).parent.parent / "backend"
+_TELEGRAM_SRC = (_BACKEND_DIR / "telegram.py").read_text(encoding="utf-8")
+_MAIN_SRC = (_BACKEND_DIR / "main.py").read_text(encoding="utf-8")
+
+
+# ---------------------------------------------------------------------------
+# Criteria 1 — no setLevel for httpx/httpcore in telegram.py
+# ---------------------------------------------------------------------------
+
+def test_telegram_has_no_httpx_setlevel():
+ """telegram.py must not set log level for 'httpx'."""
+ assert 'getLogger("httpx").setLevel' not in _TELEGRAM_SRC
+ assert "getLogger('httpx').setLevel" not in _TELEGRAM_SRC
+
+
+def test_telegram_has_no_httpcore_setlevel():
+ """telegram.py must not set log level for 'httpcore'."""
+ assert 'getLogger("httpcore").setLevel' not in _TELEGRAM_SRC
+ assert "getLogger('httpcore').setLevel" not in _TELEGRAM_SRC
+
+
+# ---------------------------------------------------------------------------
+# Criteria 2 — SignalAggregator absent from telegram.py source
+# ---------------------------------------------------------------------------
+
+def test_telegram_source_has_no_signal_aggregator_class():
+ """telegram.py source text must not contain the class definition."""
+ assert "class SignalAggregator" not in _TELEGRAM_SRC
+
+
+def test_telegram_source_has_no_signal_aggregator_reference():
+ """telegram.py source text must not reference SignalAggregator at all."""
+ assert "SignalAggregator" not in _TELEGRAM_SRC
+
+
+# ---------------------------------------------------------------------------
+# Criteria 3 — httpx/httpcore suppression still lives in main.py
+# ---------------------------------------------------------------------------
+
+def test_main_suppresses_httpx_logging():
+ """main.py must call getLogger('httpx').setLevel to suppress noise."""
+ assert (
+ 'getLogger("httpx").setLevel' in _MAIN_SRC
+ or "getLogger('httpx').setLevel" in _MAIN_SRC
+ )
+
+
+def test_main_suppresses_httpcore_logging():
+ """main.py must call getLogger('httpcore').setLevel to suppress noise."""
+ assert (
+ 'getLogger("httpcore").setLevel' in _MAIN_SRC
+ or "getLogger('httpcore').setLevel" in _MAIN_SRC
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criteria 4 — SignalAggregator not importable from backend.telegram
+# ---------------------------------------------------------------------------
+
+def test_signal_aggregator_not_importable_from_telegram():
+ """Importing SignalAggregator from backend.telegram must raise ImportError."""
+ import importlib
+ import sys
+
+ # Force a fresh import so changes to the module are reflected
+ mod_name = "backend.telegram"
+ if mod_name in sys.modules:
+ del sys.modules[mod_name]
+
+ import backend.telegram as tg_mod # noqa: F401
+ assert not hasattr(tg_mod, "SignalAggregator"), (
+ "SignalAggregator should not be an attribute of backend.telegram"
+ )
diff --git a/tests/test_fix_016.py b/tests/test_fix_016.py
index f91ac3e..e4748ba 100644
--- a/tests/test_fix_016.py
+++ b/tests/test_fix_016.py
@@ -78,10 +78,10 @@ def test_app_js_contains_fetch_vapid_public_key_function() -> None:
def test_app_js_fetch_vapid_calls_api_endpoint() -> None:
- """_fetchVapidPublicKey в app.js должна обращаться к /api/vapid-public-key."""
+ """_fetchVapidPublicKey в app.js должна обращаться к /api/push/public-key (canonical URL)."""
content = APP_JS.read_text(encoding="utf-8")
- assert "/api/vapid-public-key" in content, (
- "app.js не содержит URL '/api/vapid-public-key' — VAPID ключ не читается через API"
+ assert "/api/push/public-key" in content, (
+ "app.js не содержит URL '/api/push/public-key' — VAPID ключ не читается через API"
)
diff --git a/tests/test_telegram.py b/tests/test_telegram.py
index e1467a0..bd46f51 100644
--- a/tests/test_telegram.py
+++ b/tests/test_telegram.py
@@ -1,5 +1,5 @@
"""
-Tests for backend/telegram.py: send_message, set_webhook, SignalAggregator.
+Tests for backend/telegram.py: send_message, set_webhook, validate_bot_token.
NOTE: respx routes must be registered INSIDE the 'with mock:' block to be
intercepted properly. Registering them before entering the context does not
@@ -25,8 +25,6 @@ def _safe_aiosqlite_await(self):
aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign]
import json
-import os as _os
-import tempfile
from unittest.mock import AsyncMock, patch
import httpx
@@ -34,7 +32,7 @@ import pytest
import respx
from backend import config
-from backend.telegram import SignalAggregator, send_message, set_webhook, validate_bot_token
+from backend.telegram import send_message, set_webhook, validate_bot_token
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
@@ -186,127 +184,6 @@ async def test_set_webhook_raises_on_non_200():
await set_webhook(url="https://example.com/webhook", secret="s")
-# ---------------------------------------------------------------------------
-# SignalAggregator helpers
-# ---------------------------------------------------------------------------
-
-async def _init_db_with_tmp() -> str:
- """Init a temp-file DB and return its path."""
- from backend import config as _cfg, db as _db
- path = tempfile.mktemp(suffix=".db")
- _cfg.DB_PATH = path
- await _db.init_db()
- return path
-
-
-def _cleanup(path: str) -> None:
- for ext in ("", "-wal", "-shm"):
- try:
- _os.unlink(path + ext)
- except FileNotFoundError:
- pass
-
-
-# ---------------------------------------------------------------------------
-# SignalAggregator tests
-# ---------------------------------------------------------------------------
-
-@pytest.mark.asyncio
-async def test_aggregator_single_signal_calls_send_message():
- """Flushing an aggregator with one signal calls send_message once."""
- path = await _init_db_with_tmp()
- try:
- agg = SignalAggregator(interval=9999)
- await agg.add_signal(
- user_uuid="a9900001-0000-4000-8000-000000000001",
- user_name="Alice",
- timestamp=1742478000000,
- geo={"lat": 55.0, "lon": 37.0, "accuracy": 10.0},
- signal_id=1,
- )
-
- with respx.mock(assert_all_called=False) as mock:
- send_route = mock.post(SEND_URL).mock(
- return_value=httpx.Response(200, json={"ok": True})
- )
- with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
- with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
- await agg.flush()
-
- assert send_route.call_count == 1
- finally:
- _cleanup(path)
-
-
-@pytest.mark.asyncio
-async def test_aggregator_multiple_signals_one_message():
- """5 signals flushed at once produce exactly one send_message call."""
- path = await _init_db_with_tmp()
- try:
- agg = SignalAggregator(interval=9999)
- for i in range(5):
- await agg.add_signal(
- user_uuid=f"a990000{i}-0000-4000-8000-00000000000{i}",
- user_name=f"User{i}",
- timestamp=1742478000000 + i * 1000,
- geo=None,
- signal_id=i + 1,
- )
-
- with respx.mock(assert_all_called=False) as mock:
- send_route = mock.post(SEND_URL).mock(
- return_value=httpx.Response(200, json={"ok": True})
- )
- with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
- with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
- await agg.flush()
-
- assert send_route.call_count == 1
- finally:
- _cleanup(path)
-
-
-@pytest.mark.asyncio
-async def test_aggregator_empty_buffer_no_send():
- """Flushing an empty aggregator must NOT call send_message."""
- agg = SignalAggregator(interval=9999)
-
- # No routes registered — if a POST is made it will raise AllMockedAssertionError
- with respx.mock(assert_all_called=False) as mock:
- send_route = mock.post(SEND_URL).mock(
- return_value=httpx.Response(200, json={"ok": True})
- )
- await agg.flush()
-
- assert send_route.call_count == 0
-
-
-@pytest.mark.asyncio
-async def test_aggregator_buffer_cleared_after_flush():
- """After flush, the aggregator buffer is empty."""
- path = await _init_db_with_tmp()
- try:
- agg = SignalAggregator(interval=9999)
- await agg.add_signal(
- user_uuid="a9900099-0000-4000-8000-000000000099",
- user_name="Test",
- timestamp=1742478000000,
- geo=None,
- signal_id=99,
- )
- assert len(agg._buffer) == 1
-
- with respx.mock(assert_all_called=False) as mock:
- mock.post(SEND_URL).mock(return_value=httpx.Response(200, json={"ok": True}))
- with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
- with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
- await agg.flush()
-
- assert len(agg._buffer) == 0
- finally:
- _cleanup(path)
-
-
# ---------------------------------------------------------------------------
# BATON-007: 400 "chat not found" handling
# ---------------------------------------------------------------------------
@@ -371,33 +248,3 @@ async def test_send_message_all_5xx_retries_exhausted_does_not_raise():
# Must not raise — message is dropped, service stays alive
await send_message("test all retries exhausted")
-
-@pytest.mark.asyncio
-async def test_aggregator_unknown_user_shows_uuid_prefix():
- """If user_name is None, the message shows first 8 chars of uuid."""
- path = await _init_db_with_tmp()
- try:
- agg = SignalAggregator(interval=9999)
- test_uuid = "abcdef1234567890"
- await agg.add_signal(
- user_uuid=test_uuid,
- user_name=None,
- timestamp=1742478000000,
- geo=None,
- signal_id=1,
- )
-
- sent_texts: list[str] = []
-
- async def _fake_send(text: str) -> None:
- sent_texts.append(text)
-
- with patch("backend.telegram.send_message", side_effect=_fake_send):
- with patch("backend.telegram.db.save_telegram_batch", new_callable=AsyncMock):
- with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock):
- await agg.flush()
-
- assert len(sent_texts) == 1
- assert test_uuid[:8] in sent_texts[0]
- finally:
- _cleanup(path)