From 6444b30d17c481506e43effb34d6db385449dd58 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:43:35 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-BIZ-002=20=D0=A3=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20hardcoded=20VAPID=20key=20=D0=B8=D0=B7=20meta-?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=B0,=20=D1=87=D0=B8=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=20/api/push/public-key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app.js | 103 +++++++++++++++++++++ frontend/index.html | 50 +++++++++- frontend/style.css | 32 +++++++ tests/test_biz_002.py | 203 +++++++++++++++++++++++++++++++++++++++++ tests/test_biz_004.py | 96 +++++++++++++++++++ tests/test_fix_016.py | 6 +- tests/test_telegram.py | 157 +------------------------------ 7 files changed, 488 insertions(+), 159 deletions(-) create mode 100644 tests/test_biz_002.py create mode 100644 tests/test_biz_004.py 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)