kin: BATON-BIZ-002 Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key

This commit is contained in:
Gros Frumos 2026-03-21 12:43:35 +02:00
parent ea06309a6e
commit 6444b30d17
7 changed files with 488 additions and 159 deletions

View file

@ -92,6 +92,21 @@ function _setStatus(msg, cls) {
el.hidden = !msg; 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() { function _updateNetworkIndicator() {
const el = document.getElementById('indicator-network'); const el = document.getElementById('indicator-network');
if (!el) return; if (!el) return;
@ -210,6 +225,7 @@ async function _handleSignal() {
function _showOnboarding() { function _showOnboarding() {
_showScreen('screen-onboarding'); _showScreen('screen-onboarding');
_showView('view-login');
const input = document.getElementById('name-input'); const input = document.getElementById('name-input');
const btn = document.getElementById('btn-confirm'); const btn = document.getElementById('btn-confirm');
@ -221,6 +237,24 @@ function _showOnboarding() {
if (e.key === 'Enter' && !btn.disabled) _handleRegister(); if (e.key === 'Enter' && !btn.disabled) _handleRegister();
}); });
btn.addEventListener('click', _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() { 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 ========== // ========== Init ==========
function _init() { function _init() {

View file

@ -36,7 +36,9 @@
<!-- Onboarding screen: shown on first visit (no UUID registered yet) --> <!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
<div id="screen-onboarding" class="screen" role="main" hidden> <div id="screen-onboarding" class="screen" role="main" hidden>
<div class="screen-content">
<!-- View: name entry (existing onboarding) -->
<div class="screen-content" id="view-login">
<input <input
type="text" type="text"
id="name-input" id="name-input"
@ -52,7 +54,53 @@
<button type="button" id="btn-confirm" class="btn-confirm" disabled> <button type="button" id="btn-confirm" class="btn-confirm" disabled>
Confirm Confirm
</button> </button>
<button type="button" id="btn-switch-to-register" class="btn-link">
Зарегистрироваться
</button>
</div> </div>
<!-- View: account registration -->
<div class="screen-content" id="view-register" hidden>
<input
type="email"
id="reg-email"
class="name-input"
placeholder="Email"
autocomplete="email"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
aria-label="Email"
>
<input
type="text"
id="reg-login"
class="name-input"
placeholder="Логин"
maxlength="64"
autocomplete="username"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
aria-label="Логин"
>
<input
type="password"
id="reg-password"
class="name-input"
placeholder="Пароль"
autocomplete="new-password"
aria-label="Пароль"
>
<button type="button" id="btn-register" class="btn-confirm">
Зарегистрироваться
</button>
<button type="button" id="btn-switch-to-login" class="btn-link">
← Назад
</button>
<div id="reg-status" class="reg-status" hidden></div>
</div>
</div> </div>
<!-- Main screen: SOS button --> <!-- Main screen: SOS button -->

View file

@ -198,3 +198,35 @@ body {
.status[hidden] { display: none; } .status[hidden] { display: none; }
.status--error { color: #f87171; } .status--error { color: #f87171; }
.status--success { color: #4ade80; } .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; }

203
tests/test_biz_002.py Normal file
View file

@ -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 не должен содержать <meta name='vapid-public-key'> вообще (decision #1333)."""
content = INDEX_HTML.read_text(encoding="utf-8")
match = re.search(
r'<meta[^>]+name\s*=\s*["\']vapid-public-key["\']',
content,
re.IGNORECASE,
)
assert match is None, (
f"index.html содержит удалённый тег <meta name='vapid-public-key'>: {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'<meta[^>]*(?:vapid|application-server-key)[^>]*content\s*=',
content,
re.IGNORECASE,
)
assert match is None, (
f"index.html содержит <meta>-тег с 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 отсутствует при пустом конфиге"

96
tests/test_biz_004.py Normal file
View file

@ -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"
)

View file

@ -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: 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") content = APP_JS.read_text(encoding="utf-8")
assert "/api/vapid-public-key" in content, ( assert "/api/push/public-key" in content, (
"app.js не содержит URL '/api/vapid-public-key' — VAPID ключ не читается через API" "app.js не содержит URL '/api/push/public-key' — VAPID ключ не читается через API"
) )

View file

@ -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 NOTE: respx routes must be registered INSIDE the 'with mock:' block to be
intercepted properly. Registering them before entering the context does not 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] aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign]
import json import json
import os as _os
import tempfile
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx import httpx
@ -34,7 +32,7 @@ import pytest
import respx import respx
from backend import config 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" 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") 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 # 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 # Must not raise — message is dropped, service stays alive
await send_message("test all retries exhausted") 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)