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
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:
|
||||
"""_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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue