kin: BATON-BIZ-002 Убрать hardcoded VAPID key из meta-тега, читать с /api/push/public-key
This commit is contained in:
parent
ea06309a6e
commit
6444b30d17
7 changed files with 488 additions and 159 deletions
103
frontend/app.js
103
frontend/app.js
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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
203
tests/test_biz_002.py
Normal 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
96
tests/test_biz_004.py
Normal 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"
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue