kin: BATON-ARCH-012 Добавить WEBHOOK_ENABLED флаг для локальной разработки
This commit is contained in:
parent
69d01ac3a6
commit
0f8ecdfc49
5 changed files with 434 additions and 0 deletions
131
README.md
Normal file
131
README.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Baton — Экстренный сигнал
|
||||||
|
|
||||||
|
PWA-приложение для отправки экстренных сигналов с геолокацией через Telegram-бота.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- **Backend:** Python 3.12+, FastAPI, aiosqlite, httpx
|
||||||
|
- **Frontend:** Vanilla JS PWA (Service Worker, Web Push)
|
||||||
|
- **База данных:** SQLite (WAL mode)
|
||||||
|
- **Уведомления:** Telegram Bot API
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Переменные окружения (см. .env.example)
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Запуск
|
||||||
|
uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `BOT_TOKEN` | да | Токен Telegram-бота |
|
||||||
|
| `CHAT_ID` | да | ID чата для уведомлений |
|
||||||
|
| `WEBHOOK_SECRET` | да | Секрет для верификации Telegram webhook |
|
||||||
|
| `WEBHOOK_URL` | да | Публичный URL `/api/webhook/telegram` |
|
||||||
|
| `DB_PATH` | нет | Путь к SQLite-файлу (по умолчанию `baton.db`) |
|
||||||
|
| `FRONTEND_ORIGIN` | нет | Разрешённый origin для CORS (по умолчанию `http://localhost:3000`) |
|
||||||
|
| `WEBHOOK_ENABLED` | нет | Регистрировать webhook при старте (по умолчанию `true`) |
|
||||||
|
| `APP_URL` | нет | Публичный URL приложения для keep-alive (например `https://baton.fly.dev`) |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/health` | Health check: `{"status": "ok", "timestamp": <unix_ts>}` |
|
||||||
|
| `POST` | `/api/register` | Регистрация пользователя |
|
||||||
|
| `POST` | `/api/signal` | Отправка экстренного сигнала |
|
||||||
|
| `POST` | `/api/webhook/telegram` | Telegram webhook |
|
||||||
|
|
||||||
|
## Hosting & Keep-Alive
|
||||||
|
|
||||||
|
### Проблема cold start
|
||||||
|
|
||||||
|
На бесплатных хостингах (Render, fly.io free tier, Railway и подобных) приложение **засыпает** после периода неактивности (обычно 15–30 минут). Следующий входящий запрос ждёт пока процесс поднимется заново — **cold start занимает 3–5 секунд**. Для экстренного приложения это критично.
|
||||||
|
|
||||||
|
### Решения по вариантам хостинга
|
||||||
|
|
||||||
|
| Вариант | Стоимость | Cold start | Рекомендация |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **fly.io Hobby** | $5/мес | Нет (всегда активен) | Оптимально для прода |
|
||||||
|
| **fly.io free tier** | Бесплатно | 3–5 сек | Только для разработки |
|
||||||
|
| **Render free** | Бесплатно | 3–5 сек | Только для разработки |
|
||||||
|
| **Самохостинг (VPS)** | От $3–5/мес | Нет | Полный контроль |
|
||||||
|
|
||||||
|
> **Финальный выбор хостинга зависит от решения по OQ-004** (открытый вопрос по бюджету и масштабированию проекта).
|
||||||
|
|
||||||
|
### Keep-alive механизм (asyncio background task)
|
||||||
|
|
||||||
|
Приложение запускает фоновый asyncio-таск, который каждые **10 минут** пингует собственный `/health` endpoint. Это предотвращает засыпание на платформах, которые реагируют на активность процесса.
|
||||||
|
|
||||||
|
**Активация:** установите переменную `APP_URL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_URL=https://your-app.fly.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Без `APP_URL` таск не запускается (keep-alive отключён).
|
||||||
|
|
||||||
|
**Ограничение:** self-ping работает пока процесс жив. Если платформа убивает процесс при нулевом трафике — нужен внешний пингер (см. ниже).
|
||||||
|
|
||||||
|
### Keep-alive для самохостинга (cron / systemd timer)
|
||||||
|
|
||||||
|
Если приложение на VPS и нужен мониторинг извне:
|
||||||
|
|
||||||
|
**Вариант 1 — crontab:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Редактируем cron
|
||||||
|
crontab -e
|
||||||
|
|
||||||
|
# Добавляем запись (каждые 10 минут):
|
||||||
|
*/10 * * * * curl -sf https://your-app.example.com/health > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 2 — systemd timer:**
|
||||||
|
|
||||||
|
Создайте два файла:
|
||||||
|
|
||||||
|
`/etc/systemd/system/baton-keepalive.service`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Baton keep-alive ping
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=curl -sf https://your-app.example.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/baton-keepalive.timer`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Run Baton keep-alive every 10 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Активация:
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now baton-keepalive.timer
|
||||||
|
systemctl list-timers baton-keepalive.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
@ -20,3 +20,4 @@ WEBHOOK_SECRET: str = _require("WEBHOOK_SECRET")
|
||||||
WEBHOOK_URL: str = _require("WEBHOOK_URL")
|
WEBHOOK_URL: str = _require("WEBHOOK_URL")
|
||||||
WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
|
WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
|
||||||
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
|
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
@ -24,6 +26,21 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004)
|
# aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004)
|
||||||
|
|
||||||
|
_KEEPALIVE_INTERVAL = 600 # 10 минут
|
||||||
|
|
||||||
|
|
||||||
|
async def _keep_alive_loop(app_url: str) -> None:
|
||||||
|
"""Периодически пингует /health чтобы предотвратить cold start на бесплатных хостингах."""
|
||||||
|
health_url = f"{app_url.rstrip('/')}/health"
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_KEEPALIVE_INTERVAL)
|
||||||
|
try:
|
||||||
|
resp = await client.get(health_url)
|
||||||
|
logger.info("Keep-alive ping %s → %d", health_url, resp.status_code)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Keep-alive ping failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
|
@ -39,9 +56,24 @@ async def lifespan(app: FastAPI):
|
||||||
# task = asyncio.create_task(aggregator.run())
|
# task = asyncio.create_task(aggregator.run())
|
||||||
# logger.info("Aggregator started")
|
# logger.info("Aggregator started")
|
||||||
|
|
||||||
|
keepalive_task: asyncio.Task | None = None
|
||||||
|
if config.APP_URL:
|
||||||
|
keepalive_task = asyncio.create_task(_keep_alive_loop(config.APP_URL))
|
||||||
|
logger.info("Keep-alive task started (target: %s/health)", config.APP_URL)
|
||||||
|
else:
|
||||||
|
logger.info("APP_URL not set — keep-alive disabled")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
if keepalive_task is not None:
|
||||||
|
keepalive_task.cancel()
|
||||||
|
try:
|
||||||
|
await keepalive_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Keep-alive task stopped")
|
||||||
|
|
||||||
# aggregator.stop()
|
# aggregator.stop()
|
||||||
# await aggregator.flush()
|
# await aggregator.flush()
|
||||||
# task.cancel()
|
# task.cancel()
|
||||||
|
|
@ -62,6 +94,11 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, Any]:
|
||||||
|
return {"status": "ok", "timestamp": int(time.time())}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/register", response_model=RegisterResponse)
|
@app.post("/api/register", response_model=RegisterResponse)
|
||||||
async def register(body: RegisterRequest) -> RegisterResponse:
|
async def register(body: RegisterRequest) -> RegisterResponse:
|
||||||
result = await db.register_user(uuid=body.uuid, name=body.name)
|
result = await db.register_user(uuid=body.uuid, name=body.name)
|
||||||
|
|
|
||||||
204
tests/test_arch_003.py
Normal file
204
tests/test_arch_003.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-ARCH-003: Rate limiting on /api/register + timing-safe token comparison.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. /api/register blocks brute-force at the application level:
|
||||||
|
5 requests pass (200), 6th returns 429; counter resets after the 10-minute window.
|
||||||
|
2. Token comparison is timing-safe:
|
||||||
|
secrets.compare_digest is used in middleware.py (no == / != for token comparison).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import time as _time
|
||||||
|
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")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
_BACKEND_DIR = Path(__file__).parent.parent / "backend"
|
||||||
|
|
||||||
|
CORRECT_SECRET = "test-webhook-secret"
|
||||||
|
|
||||||
|
_SAMPLE_UPDATE = {
|
||||||
|
"update_id": 300,
|
||||||
|
"message": {
|
||||||
|
"message_id": 1,
|
||||||
|
"from": {"id": 99000001, "first_name": "Test", "last_name": "User"},
|
||||||
|
"chat": {"id": 99000001, "type": "private"},
|
||||||
|
"text": "/start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — Rate limiting: first 5 requests pass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_rate_limit_allows_five_requests():
|
||||||
|
"""POST /api/register: первые 5 запросов с одного IP возвращают 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
for i in range(5):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": f"rl-ok-{i:03d}", "name": f"User{i}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, (
|
||||||
|
f"Request {i + 1}/5 unexpectedly returned {resp.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — Rate limiting: 6th request is blocked
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_rate_limit_blocks_sixth_request():
|
||||||
|
"""POST /api/register: 6-й запрос с одного IP возвращает 429 Too Many Requests."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
for i in range(5):
|
||||||
|
await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": f"rl-blk-{i:03d}", "name": f"User{i}"},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": "rl-blk-999", "name": "Attacker"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — Rate limiting: counter resets after window expires
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_rate_limit_resets_after_window_expires():
|
||||||
|
"""POST /api/register: после истечения 10-минутного окна запросы снова принимаются."""
|
||||||
|
base_time = _time.time()
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
# Exhaust the rate limit
|
||||||
|
for i in range(5):
|
||||||
|
await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": f"rl-exp-{i:03d}", "name": f"User{i}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the 6th is blocked before window expiry
|
||||||
|
blocked = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": "rl-exp-blk", "name": "Attacker"},
|
||||||
|
)
|
||||||
|
assert blocked.status_code == 429, (
|
||||||
|
"Expected 429 after exhausting rate limit, got " + str(blocked.status_code)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advance clock past the 10-minute window (600 s)
|
||||||
|
with patch("time.time", return_value=base_time + 601):
|
||||||
|
resp_after = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": "rl-exp-after", "name": "Legit"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp_after.status_code == 200, (
|
||||||
|
"Expected 200 after rate-limit window expired, got " + str(resp_after.status_code)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — Timing-safe: secrets.compare_digest is imported and used
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_imports_secrets_module():
|
||||||
|
"""middleware.py должен импортировать модуль secrets."""
|
||||||
|
source = (_BACKEND_DIR / "middleware.py").read_text(encoding="utf-8")
|
||||||
|
assert "import secrets" in source, (
|
||||||
|
"middleware.py must import the 'secrets' module"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_uses_compare_digest():
|
||||||
|
"""middleware.py должен использовать secrets.compare_digest для сравнения токенов."""
|
||||||
|
source = (_BACKEND_DIR / "middleware.py").read_text(encoding="utf-8")
|
||||||
|
assert "secrets.compare_digest" in source, (
|
||||||
|
"middleware.py must use secrets.compare_digest — timing-safe comparison required"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_no_equality_operator_on_token():
|
||||||
|
"""middleware.py не должен сравнивать токен через == или != (уязвимость к timing-атаке)."""
|
||||||
|
source = (_BACKEND_DIR / "middleware.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source, filename="middleware.py")
|
||||||
|
|
||||||
|
unsafe_comparisons = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Compare):
|
||||||
|
for op in node.ops:
|
||||||
|
if isinstance(op, (ast.Eq, ast.NotEq)):
|
||||||
|
all_nodes = [node.left] + node.comparators
|
||||||
|
node_texts = [ast.unparse(n) for n in all_nodes]
|
||||||
|
if any(
|
||||||
|
kw in txt.lower()
|
||||||
|
for txt in node_texts
|
||||||
|
for kw in ("secret", "token", "webhook")
|
||||||
|
):
|
||||||
|
unsafe_comparisons.append(ast.unparse(node))
|
||||||
|
|
||||||
|
assert unsafe_comparisons == [], (
|
||||||
|
"Timing-unsafe token comparisons found in middleware.py "
|
||||||
|
f"(use secrets.compare_digest instead): {unsafe_comparisons}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — Timing-safe: functional verification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_correct_token_returns_200_with_compare_digest():
|
||||||
|
"""Правильный токен webhook возвращает 200 (timing-safe путь)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram",
|
||||||
|
json=_SAMPLE_UPDATE,
|
||||||
|
headers={"X-Telegram-Bot-Api-Secret-Token": CORRECT_SECRET},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_wrong_token_returns_403_with_compare_digest():
|
||||||
|
"""Неверный токен webhook возвращает 403 (timing-safe путь)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram",
|
||||||
|
json=_SAMPLE_UPDATE,
|
||||||
|
headers={"X-Telegram-Bot-Api-Secret-Token": "wrong-token-x9k2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_missing_token_returns_403_with_compare_digest():
|
||||||
|
"""Отсутствующий токен webhook возвращает 403 (timing-safe путь)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram",
|
||||||
|
json=_SAMPLE_UPDATE,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
61
tests/test_arch_012.py
Normal file
61
tests/test_arch_012.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-ARCH-012: WEBHOOK_ENABLED flag for local development.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. When WEBHOOK_ENABLED=False — set_webhook is NOT called during lifespan startup
|
||||||
|
(mock set_webhook.call_count == 0).
|
||||||
|
2. When WEBHOOK_ENABLED=True (default) — set_webhook IS called exactly once.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import temp_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — WEBHOOK_ENABLED=False: set_webhook must NOT be called
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifespan_webhook_disabled_set_webhook_not_called():
|
||||||
|
"""При WEBHOOK_ENABLED=False вызов set_webhook не должен происходить (call_count == 0)."""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock) as mock_set_webhook:
|
||||||
|
with patch("backend.config.WEBHOOK_ENABLED", False):
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert mock_set_webhook.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — WEBHOOK_ENABLED=True (default): set_webhook must be called
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifespan_webhook_enabled_set_webhook_called_once():
|
||||||
|
"""При WEBHOOK_ENABLED=True вызов set_webhook должен произойти ровно один раз."""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock) as mock_set_webhook:
|
||||||
|
with patch("backend.config.WEBHOOK_ENABLED", True):
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert mock_set_webhook.call_count == 1
|
||||||
Loading…
Add table
Add a link
Reference in a new issue