kin: BATON-002 [Research] UX Designer

This commit is contained in:
Gros Frumos 2026-03-20 20:44:00 +02:00
commit 057e500d5f
29 changed files with 3530 additions and 0 deletions

11
.env.example Normal file
View file

@ -0,0 +1,11 @@
# Telegram Bot
BOT_TOKEN=your_telegram_bot_token_here
CHAT_ID=-1001234567890
WEBHOOK_SECRET=your_random_secret_here
WEBHOOK_URL=https://yourdomain.com/api/webhook/telegram
# Database
DB_PATH=baton.db
# CORS
FRONTEND_ORIGIN=https://yourdomain.com

140
.gitignore vendored Normal file
View file

@ -0,0 +1,140 @@
# ============================================================
# SENSITIVE / LOCAL
# ============================================================
.env
.env.*
.env.local
.env.production
.env.staging
!.env.example
!.env.template
config.yaml
config.yml
config.json
config.toml
!config.example.*
!config.sample.*
*.pem
*.key
*.crt
*.p12
*.pfx
*.jks
*.keystore
id_rsa*
id_ed25519*
*.plist
secrets/
.secrets/
*password*
*credentials*
*secret*
*token*
!*secret*.go
!*secret*.py
!*secret*.js
!*secret*.ts
# ============================================================
# CLAUDE / AI
# ============================================================
CLAUDE.md
PROGRESS.md
CLAUDE_ARCHIVE.md
tasks/todo.md
tasks/lessons.md
.claude/settings.local.json
# ============================================================
# PYTHON
# ============================================================
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
env/
*.egg-info/
dist/
build/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.db
*.db-shm
*.db-wal
# ============================================================
# NODE / JS / TS
# ============================================================
node_modules/
dist/
build/
.next/
.nuxt/
*.tsbuildinfo
.pnpm-store/
# ============================================================
# GO
# ============================================================
*.exe
*.test
vendor/
*.db-journal
# ============================================================
# SWIFT / XCODE
# ============================================================
*.xcworkspace/
xcuserdata/
DerivedData/
*.ipa
*.dSYM.zip
build/
Pods/
# ============================================================
# DOCKER / INFRA
# ============================================================
docker-compose.override.yml
*.log
logs/
# ============================================================
# DATA / UPLOADS
# ============================================================
data/
uploads/
storage/
*.sqlite
*.sqlite3
# ============================================================
# OS
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
# ============================================================
# EDITORS
# ============================================================
.idea/
.vscode/
*.swp
*.swo
*~
# ============================================================
# BACKUPS / TEMP
# ============================================================
*.bak
*.bak.*
*.backup
*.old
*.orig
*.tmp
tmp/
.kin_worktrees/

0
backend/__init__.py Normal file
View file

21
backend/config.py Normal file
View file

@ -0,0 +1,21 @@
from __future__ import annotations
import os
from dotenv import load_dotenv
load_dotenv()
def _require(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"Required environment variable {name!r} is not set")
return value
BOT_TOKEN: str = _require("BOT_TOKEN")
CHAT_ID: str = _require("CHAT_ID")
DB_PATH: str = os.getenv("DB_PATH", "baton.db")
WEBHOOK_SECRET: str = _require("WEBHOOK_SECRET")
WEBHOOK_URL: str = _require("WEBHOOK_URL")
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")

123
backend/db.py Normal file
View file

@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Optional
import aiosqlite
from backend import config
async def _get_conn() -> aiosqlite.Connection:
conn = await aiosqlite.connect(config.DB_PATH)
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA busy_timeout=5000")
await conn.execute("PRAGMA synchronous=NORMAL")
conn.row_factory = aiosqlite.Row
return conn
async def init_db() -> None:
async with await _get_conn() as conn:
await conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_uuid TEXT NOT NULL REFERENCES users(uuid),
timestamp INTEGER NOT NULL,
lat REAL DEFAULT NULL,
lon REAL DEFAULT NULL,
accuracy REAL DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
telegram_batch_id INTEGER DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS telegram_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_text TEXT DEFAULT NULL,
sent_at TEXT DEFAULT NULL,
signals_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
ON users(uuid);
CREATE INDEX IF NOT EXISTS idx_signals_user_uuid
ON signals(user_uuid);
CREATE INDEX IF NOT EXISTS idx_signals_created_at
ON signals(created_at);
CREATE INDEX IF NOT EXISTS idx_batches_status
ON telegram_batches(status);
""")
await conn.commit()
async def register_user(uuid: str, name: str) -> dict:
async with await _get_conn() as conn:
await conn.execute(
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
(uuid, name),
)
await conn.commit()
async with conn.execute(
"SELECT id, uuid FROM users WHERE uuid = ?", (uuid,)
) as cur:
row = await cur.fetchone()
return {"user_id": row["id"], "uuid": row["uuid"]}
async def save_signal(
user_uuid: str,
timestamp: int,
lat: Optional[float],
lon: Optional[float],
accuracy: Optional[float],
) -> int:
async with await _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO signals (user_uuid, timestamp, lat, lon, accuracy)
VALUES (?, ?, ?, ?, ?)
""",
(user_uuid, timestamp, lat, lon, accuracy),
) as cur:
signal_id = cur.lastrowid
await conn.commit()
return signal_id
async def get_user_name(uuid: str) -> Optional[str]:
async with await _get_conn() as conn:
async with conn.execute(
"SELECT name FROM users WHERE uuid = ?", (uuid,)
) as cur:
row = await cur.fetchone()
return row["name"] if row else None
async def save_telegram_batch(
message_text: str,
signals_count: int,
signal_ids: list[int],
) -> int:
async with await _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
VALUES (?, datetime('now'), ?, 'sent')
""",
(message_text, signals_count),
) as cur:
batch_id = cur.lastrowid
if signal_ids:
placeholders = ",".join("?" * len(signal_ids))
await conn.execute(
f"UPDATE signals SET telegram_batch_id = ? WHERE id IN ({placeholders})",
[batch_id, *signal_ids],
)
await conn.commit()
return batch_id

114
backend/main.py Normal file
View file

@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from backend import config, db, telegram
from backend.middleware import verify_webhook_secret
from backend.models import (
RegisterRequest,
RegisterResponse,
SignalRequest,
SignalResponse,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
aggregator = telegram.SignalAggregator(interval=10)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await db.init_db()
logger.info("Database initialized")
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
logger.info("Webhook registered")
task = asyncio.create_task(aggregator.run())
logger.info("Aggregator started")
yield
# Shutdown
aggregator.stop()
await aggregator.flush()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("Aggregator stopped, final flush done")
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[config.FRONTEND_ORIGIN],
allow_methods=["POST"],
allow_headers=["Content-Type"],
)
@app.post("/api/register", response_model=RegisterResponse)
async def register(body: RegisterRequest) -> RegisterResponse:
result = await db.register_user(uuid=body.uuid, name=body.name)
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
@app.post("/api/signal", response_model=SignalResponse)
async def signal(body: SignalRequest) -> SignalResponse:
geo = body.geo
lat = geo.lat if geo else None
lon = geo.lon if geo else None
accuracy = geo.accuracy if geo else None
signal_id = await db.save_signal(
user_uuid=body.user_id,
timestamp=body.timestamp,
lat=lat,
lon=lon,
accuracy=accuracy,
)
user_name = await db.get_user_name(body.user_id)
await aggregator.add_signal(
user_uuid=body.user_id,
user_name=user_name,
timestamp=body.timestamp,
geo={"lat": lat, "lon": lon, "accuracy": accuracy} if geo else None,
signal_id=signal_id,
)
return SignalResponse(status="ok", signal_id=signal_id)
@app.post("/api/webhook/telegram")
async def webhook_telegram(
request: Request,
_: None = Depends(verify_webhook_secret),
) -> dict[str, Any]:
update = await request.json()
message = update.get("message", {})
text = message.get("text", "")
if text.startswith("/start"):
tg_user = message.get("from", {})
tg_user_id = str(tg_user.get("id", ""))
first_name = tg_user.get("first_name", "")
last_name = tg_user.get("last_name", "")
name = (first_name + " " + last_name).strip() or tg_user_id
if tg_user_id:
await db.register_user(uuid=tg_user_id, name=name)
logger.info("Telegram /start: registered user %s", tg_user_id)
return {"ok": True}

12
backend/middleware.py Normal file
View file

@ -0,0 +1,12 @@
from __future__ import annotations
from fastapi import Header, HTTPException
from backend import config
async def verify_webhook_secret(
x_telegram_bot_api_secret_token: str = Header(default=""),
) -> None:
if x_telegram_bot_api_secret_token != config.WEBHOOK_SECRET:
raise HTTPException(status_code=403, detail="Forbidden")

31
backend/models.py Normal file
View file

@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class RegisterRequest(BaseModel):
uuid: str = Field(..., min_length=1)
name: str = Field(..., min_length=1, max_length=100)
class RegisterResponse(BaseModel):
user_id: int
uuid: str
class GeoData(BaseModel):
lat: float = Field(..., ge=-90.0, le=90.0)
lon: float = Field(..., ge=-180.0, le=180.0)
accuracy: float = Field(..., gt=0)
class SignalRequest(BaseModel):
user_id: str = Field(..., min_length=1)
timestamp: int = Field(..., gt=0)
geo: Optional[GeoData] = None
class SignalResponse(BaseModel):
status: str
signal_id: int

121
backend/telegram.py Normal file
View file

@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
from backend import config, db
logger = logging.getLogger(__name__)
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
async def send_message(text: str) -> None:
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
async with httpx.AsyncClient(timeout=10) as client:
while True:
resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text})
if resp.status_code == 429:
retry_after = resp.json().get("parameters", {}).get("retry_after", 30)
logger.warning("Telegram 429, sleeping %s sec", retry_after)
await asyncio.sleep(retry_after)
continue
if resp.status_code >= 500:
logger.error("Telegram 5xx: %s", resp.text)
await asyncio.sleep(30)
resp2 = await client.post(
url, json={"chat_id": config.CHAT_ID, "text": text}
)
if resp2.status_code != 200:
logger.error("Telegram retry failed: %s", resp2.text)
elif resp.status_code != 200:
logger.error("Telegram error %s: %s", resp.status_code, resp.text)
break
async def set_webhook(url: str, secret: str) -> None:
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
api_url, json={"url": url, "secret_token": secret}
)
if resp.status_code != 200 or not resp.json().get("result"):
raise RuntimeError(f"setWebhook failed: {resp.text}")
logger.info("Webhook registered: %s", url)
class SignalAggregator:
def __init__(self, interval: int = 10) -> None:
self._interval = interval
self._buffer: list[dict] = []
self._lock = asyncio.Lock()
self._stopped = False
async def add_signal(
self,
user_uuid: str,
user_name: Optional[str],
timestamp: int,
geo: Optional[dict],
signal_id: int,
) -> None:
async with self._lock:
self._buffer.append(
{
"user_uuid": user_uuid,
"user_name": user_name,
"timestamp": timestamp,
"geo": geo,
"signal_id": signal_id,
}
)
async def flush(self) -> None:
async with self._lock:
if not self._buffer:
return
items = self._buffer[:]
self._buffer.clear()
signal_ids = [item["signal_id"] for item in items]
timestamps = [item["timestamp"] for item in items]
ts_start = datetime.fromtimestamp(min(timestamps) / 1000, tz=timezone.utc)
ts_end = datetime.fromtimestamp(max(timestamps) / 1000, tz=timezone.utc)
t_fmt = "%H:%M:%S"
names = []
for item in items:
name = item["user_name"]
label = name if name else item["user_uuid"][:8]
names.append(label)
geo_count = sum(1 for item in items if item["geo"])
n = len(items)
text = (
f"\U0001f6a8 Получено {n} сигнал{'ов' if n != 1 else ''} "
f"[{ts_start.strftime(t_fmt)}{ts_end.strftime(t_fmt)}]\n"
f"Пользователи: {', '.join(names)}\n"
f"\U0001f4cd С геолокацией: {geo_count} из {n}"
)
try:
await send_message(text)
await db.save_telegram_batch(text, n, signal_ids)
# rate-limit: 1 msg/sec max (#1014)
await asyncio.sleep(1)
except Exception:
logger.exception("Failed to flush aggregator batch")
async def run(self) -> None:
while not self._stopped:
await asyncio.sleep(self._interval)
if self._buffer:
await self.flush()
def stop(self) -> None:
self._stopped = True

View file

@ -0,0 +1,97 @@
# ADR-001: Выбор бэкенд-стека
**Дата:** 2026-03-20
**Статус:** Accepted
**Автор:** Architect Agent (Kin pipeline, BATON-001)
---
## Контекст
Baton — минималистичное PWA приложение экстренного сигнала. Бэкенд выполняет три задачи:
1. Принять POST /signal от 300-400 пользователей (возможны одновременные запросы)
2. Сохранить сигнал в SQLite
3. Отправить уведомление в Telegram-группу через Bot API
Проект требует простого деплоя на один VPS, без kubernetes, без сложной инфраструктуры.
Команда имеет опыт работы с Python/FastAPI (используется в проекте Kin).
Рассматривались три стека: FastAPI (Python), Express/Fastify (Node.js), Go (net/http).
---
## Варианты
### Вариант A: FastAPI (Python 3.11+)
**Плюсы:**
- Знакомость команды (используется в Kin)
- asyncio нативно
- Pydantic — автоматическая валидация входных данных
- `aiosqlite` или `sqlite3` через `run_in_executor`
- Быстрый старт разработки
**Минусы:**
- В 2-3x медленнее Go по RPS
- Docker образ ~200-400 MB (Python runtime + зависимости)
- bcrypt блокирует event loop — нужен `run_in_executor` (решение #1004)
### Вариант B: Express/Fastify (Node.js 20+)
**Плюсы:**
- Единый язык с фронтендом (vanilla JS)
- `better-sqlite3` — синхронный, самый быстрый SQLite биндинг для Node.js
- Fastify ~24% быстрее FastAPI по RPS в независимых тестах
- Docker образ ~200-300 MB
**Минусы:**
- Нет опыта работы в команде
- `better-sqlite3` синхронный — блокирует event loop при долгих запросах (на практике приемлемо для simple INSERT)
- Дополнительное переключение контекста (JS для фронта, JS для бека)
### Вариант C: Go (net/http)
**Плюсы:**
- Компилируется в единый статический бинарь ~8-15 MB (простейший деплой)
- В 2-3x быстрее Python, ~2x быстрее Node.js
- Горутины — нативный concurrency без event loop ограничений
- Нет проблем с bcrypt (не блокирует горутины)
- Cross-compile: `GOARCH=amd64 GOOS=linux go build`
**Минусы:**
- Нет опыта работы в команде
- Более длительный онбординг
- `modernc.org/sqlite` (CGO-free): 10-100% медленнее нативного SQLite — компромисс для кросс-компиляции
---
## Решение
**Выбран Вариант A: FastAPI (Python 3.11+)**
---
## Обоснование
1. **Знакомость команды — главный фактор для минимального проекта.** FastAPI используется в Kin. Нет времени на онбординг в Go или Node.js для задачи, которая требует ~200 строк бэкенд-кода.
2. **Производительности FastAPI достаточно.** При нагрузке 300-400 одновременных запросов бутылочное горлышко — Telegram rate limit (20 сообщений/минуту в группу), а не скорость Python. SQLite WAL + `busy_timeout=5000` справится с 400 одновременными INSERT за ~400 мс (решения #1002, #1005).
3. **Pydantic даёт бесплатную валидацию** входных данных (user_id, timestamp, geo) без дополнительного кода.
4. **Размер деплоя приемлем.** ~300 MB Docker образ — не проблема для одного VPS сервиса.
5. **Вариант B отклонён:** нет опыта у команды, преимущество в скорости (+24%) несущественно при текущей нагрузке.
6. **Вариант C отклонён:** несмотря на превосходную производительность и минимальный деплой, отсутствие опыта в команде создаёт риск для проекта без аргументированной причины переходить на Go.
---
## Последствия
- Использовать `aiosqlite` для async SQLite операций (или `sqlite3` через `run_in_executor`)
- bcrypt (если понадобится в будущем) — только через `run_in_executor` (решение #1004)
- SQLite WAL обязателен: `busy_timeout=5000`, `synchronous=NORMAL` — вместе (решение #1005)
- Агрегатор Telegram: реализовать в Python как background task (asyncio) или через простой in-memory буфер с `asyncio.sleep`
- requirements.txt: `fastapi`, `uvicorn[standard]`, `aiosqlite`, `httpx` (для Telegram API)
- Переменные окружения: `BOT_TOKEN`, `CHAT_ID`, `DB_PATH` — читать из `.env` через `python-dotenv`

View file

@ -0,0 +1,123 @@
# ADR-002: Паттерн офлайн-очереди
**Дата:** 2026-03-20
**Статус:** Accepted
**Автор:** Architect Agent (Kin pipeline, BATON-001)
---
## Контекст
Baton — приложение экстренного сигнала. Критичное требование: сигнал **не должен быть потерян**, если пользователь нажал кнопку в момент отсутствия сети (тоннель, слабый сигнал, офлайн).
Сигнал должен быть сохранён локально и доставлен на сервер, как только соединение восстановится.
Аудитория: 300-400 пользователей, разные браузеры и платформы (Android Chrome, iOS Safari, Desktop Firefox/Chrome).
---
## Варианты
### Вариант A: IndexedDB outbox + BackgroundSync + online event fallback
**Схема:**
1. Кнопка нажата → немедленная попытка `fetch('/signal')`
2. Ошибка или offline → запись в IndexedDB outbox
3. Trigger 1: `window.addEventListener('online', flushOutbox)` — main thread, все браузеры
4. Trigger 2: SW регистрирует `registration.sync.register('flush-outbox')` — Chromium только
5. SW обрабатывает `sync` event → читает IndexedDB → flush
**Плюсы:**
- IndexedDB персистентна: не очищается при закрытии вкладки (в отличие от memory)
- IndexedDB доступна как из main thread, так и из Service Worker → общее хранилище
- BackgroundSync: браузер сам управляет повтором (Chrome может сработать даже при закрытой вкладке)
- Dual trigger страхует: если BackgroundSync не сработал → online event
- Квота IndexedDB: обычно GB (не 5 MB как localStorage)
- Соответствует принятому решению #1006
**Минусы:**
- IndexedDB API громоздкий → нужна обёртка (`idb` библиотека, ~1.9 KB gzip) или написать самому
- BackgroundSync поддерживается только 78.75% браузеров (caniuse, март 2026) — Safari и Firefox не поддерживают
- Сложнее отлаживать в DevTools, чем localStorage
### Вариант B: localStorage queue + online event listener
**Схема:**
1. Кнопка нажата → попытка отправки
2. Ошибка → `JSON.stringify` очереди в `localStorage`
3. `window.addEventListener('online', flush)` → отправить всё из очереди
**Плюсы:**
- Самый простой вариант (~20 строк)
- Нет зависимостей
- Синхронный API — легко читать и писать
**Минусы:**
- iOS Safari приватный режим: `localStorage.setItem()` бросает `SecurityError` → нужен try/catch → если упал, сигнал теряется
- localStorage недоступна в Service Worker контексте → нельзя flush из SW
- Лимит: 5 MB (достаточно, но IndexedDB надёжнее)
- Нет BackgroundSync — только один trigger (online event)
### Вариант C: Cache API + Request replay в Service Worker
**Схема:**
- SW перехватывает failed POST запросы → сохраняет в Cache API
- При появлении сети → повторяет запросы из кэша
**Плюсы:**
- Нативная интеграция с SW fetch event
- Нет отдельного хранилища
**Минусы:**
- Cache API спроектирован для Response (кэш ответов), не для Request replay
- Нет гарантий персистентности очереди (Cache может быть очищен браузером без предупреждения)
- Workbox Background Sync внутри использует IndexedDB, не Cache API (косвенное свидетельство)
- Нестандартное использование API → неожиданное поведение в edge cases
---
## Решение
**Выбран Вариант A: IndexedDB outbox + BackgroundSync + online event fallback**
Соответствует зафиксированному решению #1006.
---
## Обоснование
1. **IndexedDB — единственный вариант, доступный и в main thread, и в Service Worker.** Это критично: flush может произойти как из app.js (online event), так и из sw.js (BackgroundSync event). Общее хранилище исключает дублирование кода.
2. **Dual trigger — страховочная сеть.** BackgroundSync не обязателен для работы (Safari/Firefox — 21% юзеров обойдутся без него), но является бонусом для Chrome пользователей: flush случится даже при закрытой вкладке.
3. **Вариант B отклонён** из-за проблемы iOS Safari приватного режима (решение #1003: не понижать явные требования) и недоступности в SW контексте. При том что приложение экстренного сигнала должно работать без потерь на iOS.
4. **Вариант C отклонён** как злоупотребление API не по назначению. Cache API не гарантирует персистентность POST запросов.
5. **Размер зависимости `idb`:** ~1.9 KB gzip — приемлемо. Альтернатива: написать минимальную обёртку (~30 строк) для трёх операций (add, getAll, delete).
---
## Последствия
**При реализации учесть:**
1. **iOS Safari приватный режим:** `localStorage` недоступен → переход на IndexedDB не помогает (IndexedDB тоже может быть ограничен). Нужен graceful degradation: попытка записи в IndexedDB → при ошибке сигнал отправляется только online или теряется с явным UI-предупреждением.
2. **Idempotency ключ:** `id: "${Date.now()}-${Math.random().toString(36).slice(2)}"` — уникальный ключ каждой записи в outbox. Защита от дубликатов при повторных попытках. Бэкенд должен игнорировать дубликаты (INSERT OR IGNORE по `client_id`).
3. **Лимит попыток:** `attempts` в outbox entry. После 3-5 неудачных попыток — показать пользователю UI-предупреждение. Не flush бесконечно.
4. **SW lifecycle:** при обновлении SW (новая версия) старый SW активен до закрытия всех вкладок. Flush в процессе обновления → запрос может быть потерян. Idempotency ключ и `INSERT OR IGNORE` на бэкенде защищают от дубликатов.
5. **Background Sync — проверка перед регистрацией:**
```javascript
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('flush-outbox');
} else {
if (navigator.onLine) flushOutbox(); // немедленный fallback
}
```
6. **Решение #1001 требует обновления:** фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры.

View file

@ -0,0 +1,35 @@
# ADR-003: Паттерн аутентификации пользователей
**Дата:** 2026-03-20
**Статус:** Stub (подлежит заполнению)
**Автор:** —
---
## Контекст
_Описание контекста — предстоит заполнить._
---
## Варианты
_Описание вариантов — предстоит заполнить._
---
## Решение
_Решение — предстоит заполнить._
---
## Обоснование
_Обоснование — предстоит заполнить._
---
## Последствия
оследствия — предстоит заполнить._

View file

@ -0,0 +1,35 @@
# ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор)
**Дата:** 2026-03-20
**Статус:** Stub (подлежит заполнению)
**Автор:** —
---
## Контекст
_Описание контекста — предстоит заполнить._
---
## Варианты
_Описание вариантов — предстоит заполнить._
---
## Решение
_Решение — предстоит заполнить._
---
## Обоснование
_Обоснование — предстоит заполнить._
---
## Последствия
оследствия — предстоит заполнить._

371
docs/backend_spec.md Normal file
View file

@ -0,0 +1,371 @@
# Backend Spec: Baton PWA
**Версия:** 1.1
**Дата:** 2026-03-20
**Статус:** Approved (Architect)
---
## 1. API Contracts
### POST /api/register
Регистрирует UUID→имя пользователя. **Идемпотентен**: повторный вызов с тем же UUID возвращает существующую запись.
**Request:**
```http
POST /api/register
Content-Type: application/json
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Алиса"
}
```
| Поле | Тип | Ограничения |
|------|-----|-------------|
| uuid | string | UUID v4, обязателен |
| name | string | 1100 символов, обязателен |
**Response 200 OK:**
```json
{
"user_id": 42,
"uuid": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Status codes:**
| Код | Причина |
|-----|---------|
| 200 | Успешно (новый или существующий) |
| 422 | Ошибка валидации Pydantic |
| 500 | Внутренняя ошибка сервера |
---
### POST /api/signal
Принимает сигнал тревоги от PWA. Сохраняет в SQLite, добавляет в очередь агрегатора Telegram.
**Request:**
```http
POST /api/signal
Content-Type: application/json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1742478000000,
"geo": {
"lat": 55.7558,
"lon": 37.6173,
"accuracy": 15.0
}
}
```
| Поле | Тип | Ограничения |
|------|-----|-------------|
| user_id | string | UUID v4 пользователя, обязателен |
| timestamp | int | Unix ms, > 0, обязателен |
| geo | object \| null | Необязателен; если передан — все три поля lat/lon/accuracy обязательны |
| geo.lat | float | -90.0 … 90.0 |
| geo.lon | float | -180.0 … 180.0 |
| geo.accuracy | float | > 0, метры |
**Response 200 OK:**
```json
{
"status": "ok",
"signal_id": 123
}
```
**Status codes:**
| Код | Причина |
|-----|---------|
| 200 | Сигнал принят |
| 422 | Ошибка валидации |
| 500 | Внутренняя ошибка |
---
### POST /api/webhook/telegram
Входящие обновления от Telegram Bot API. Регистрируется через `setWebhook` при старте сервера.
**Headers:**
```
X-Telegram-Bot-Api-Secret-Token: <WEBHOOK_SECRET>
```
**Request body:** стандартный Telegram Update object (JSON от Telegram).
**Обрабатываемые команды:**
| Команда | Действие |
|---------|----------|
| `/start` | Регистрация пользователя через Telegram (INSERT OR IGNORE в users с Telegram user_id как UUID) |
**Response:**
```json
{"ok": true}
```
**Status codes:**
| Код | Причина |
|-----|---------|
| 200 | Обновление обработано |
| 403 | Неверный или отсутствующий X-Telegram-Bot-Api-Secret-Token |
| 422 | Невалидный JSON |
---
## 2. DB Schema (SQLite WAL)
```sql
-- WAL mode + защита от write-lock (решения #1005, #1002)
PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_uuid TEXT NOT NULL REFERENCES users(uuid),
timestamp INTEGER NOT NULL,
lat REAL DEFAULT NULL,
lon REAL DEFAULT NULL,
accuracy REAL DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
telegram_batch_id INTEGER DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS telegram_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_text TEXT DEFAULT NULL,
sent_at TEXT DEFAULT NULL,
signals_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending'
);
-- Индексы
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid ON users(uuid);
CREATE INDEX IF NOT EXISTS idx_signals_user_uuid ON signals(user_uuid);
CREATE INDEX IF NOT EXISTS idx_signals_created_at ON signals(created_at);
CREATE INDEX IF NOT EXISTS idx_batches_status ON telegram_batches(status);
```
**Инвариант:** все три PRAGMA применяются при каждом подключении. Пропуск любой — нарушение решений #1005, #1002.
---
## 3. Telegram Aggregator Design
### Принцип работы
```
[POST /signal] ──► add_signal(user_name, ts, geo)
[in-memory buffer]
asyncio background task (run loop)
sleep(10 sec)
if buffer not empty:
──► flush()
формат сообщения
send_message()
```
### Буфер и окно
- **Хранение:** список `list[dict]` в памяти, защищённый `asyncio.Lock()`
- **Интервал сброса:** 10 секунд (скользящее окно)
- **Rate limit:** максимум 20 сообщений/минуту в группу, 1 сообщение/секунду (#1014)
- Реализация: `asyncio.sleep(1)` после каждого успешного `send_message`
### Формат сообщения
```
🚨 Получено N сигналов [HH:MM:SS—HH:MM:SS]
Пользователи: name1, name2, name3
📍 С геолокацией: K из N
```
Если имя пользователя неизвестно — показывать первые 8 символов UUID.
### Обработка ошибок Telegram
| Код ответа | Действие |
|-----------|---------|
| 200 OK | Успех, обновить telegram_batch status = 'sent' |
| 429 Too Many Requests | Прочитать `retry_after` из тела ответа, `await asyncio.sleep(retry_after)`, повторить |
| 400 Bad Request | Логировать ошибку, сигналы не теряются (остаются в SQLite) |
| 5xx | Логировать, повторить через 30 сек (1 попытка) |
### Запись batch в SQLite
После успешной отправки:
```sql
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
VALUES (?, datetime('now'), ?, 'sent');
UPDATE signals SET telegram_batch_id = ? WHERE id IN (...);
```
---
## 4. Middleware & Security
### CORS
```python
CORSMiddleware(
allow_origins=[config.FRONTEND_ORIGIN], # env var FRONTEND_ORIGIN
allow_methods=["POST"],
allow_headers=["Content-Type"],
)
```
`FRONTEND_ORIGIN` обязателен в `.env`. Не использовать `allow_origins=["*"]` в продакшне.
### Webhook Secret Validation
Middleware проверяет заголовок `X-Telegram-Bot-Api-Secret-Token` **только** для маршрута `POST /api/webhook/telegram`.
```
Входящий запрос на /api/webhook/telegram
└── if header != config.WEBHOOK_SECRET → 403 Forbidden (немедленно)
└── else → передать в обработчик
```
Реализация: Starlette `BaseHTTPMiddleware` или dependency в роуте (предпочтительно dependency — проще тестировать).
### Input Validation
Все входящие тела запросов валидируются через Pydantic v2 models. FastAPI возвращает 422 автоматически при ошибке валидации.
### Secrets Management
Все секреты исключительно через переменные окружения (`.env` + `python-dotenv`). Ни один секрет не должен логироваться (особенно `WEBHOOK_SECRET` и `BOT_TOKEN`).
---
## 5. Startup / Shutdown
### Startup (lifespan event)
```
1. init_db()
└── PRAGMA journal_mode=WAL
└── PRAGMA busy_timeout=5000
└── PRAGMA synchronous=NORMAL
└── CREATE TABLE IF NOT EXISTS ... (users, signals, telegram_batches)
└── CREATE INDEX IF NOT EXISTS ...
2. set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
└── POST https://api.telegram.org/bot{TOKEN}/setWebhook
└── WEBHOOK_URL ОБЯЗАН быть HTTPS (#1011)
└── Если ошибка → залогировать и завершить старт с исключением
3. aggregator = SignalAggregator(interval=10)
asyncio.create_task(aggregator.run())
```
### Shutdown (lifespan event)
```
1. aggregator.stop() — установить флаг остановки
2. await aggregator.flush() — отправить оставшиеся сигналы из буфера
3. Закрыть все aiosqlite соединения
```
---
## 6. Файловая структура
```
baton/
├── backend/
│ ├── main.py # FastAPI app, роуты, lifespan events, CORS
│ ├── db.py # SQLite init (WAL), CRUD: register_user, save_signal, get_user_name
│ ├── models.py # Pydantic v2: RegisterRequest/Response, SignalRequest/Response, GeoData
│ ├── telegram.py # send_message, set_webhook, SignalAggregator
│ ├── config.py # Settings через python-dotenv: BOT_TOKEN, CHAT_ID, DB_PATH,
│ │ # WEBHOOK_SECRET, WEBHOOK_URL, FRONTEND_ORIGIN
│ └── middleware.py # WebhookSecretValidator (или FastAPI dependency)
├── tests/
│ ├── conftest.py # Фикстуры: in-memory SQLite, AsyncClient, respx mock
│ ├── test_register.py
│ ├── test_signal.py
│ ├── test_telegram.py
│ ├── test_webhook.py
│ └── test_db.py
├── docs/
│ ├── backend_spec.md # Этот файл
│ └── backend_review.md # Создаётся reviewer после имплементации
├── requirements.txt # fastapi, uvicorn[standard], aiosqlite, httpx, python-dotenv, pydantic>=2.0
├── requirements-dev.txt # pytest, pytest-asyncio, httpx, respx
└── .env.example
```
---
## 7. .env.example
```env
# Telegram
BOT_TOKEN=your_telegram_bot_token_here
CHAT_ID=-1001234567890
WEBHOOK_SECRET=your_random_secret_here
WEBHOOK_URL=https://yourdomain.com/api/webhook/telegram
# Database
DB_PATH=baton.db
# CORS
FRONTEND_ORIGIN=https://yourdomain.com
```
---
## 8. Соответствие решениям
| Решение | Статус | Реализация |
|---------|--------|-----------|
| #999: HTTPS для PWA/Geolocation | ✅ | WEBHOOK_URL обязан быть HTTPS; задокументировано в constraints |
| #1001: Background Sync fallback | ✅ | Не касается бэкенда; offline паттерн — на фронте |
| #1002: SQLite write-lock | ✅ | busy_timeout=5000 при каждом подключении |
| #1003: Требования не понижать | ✅ | Все 3 эндпоинта обязательны |
| #1004: bcrypt через run_in_executor | ✅ | UUID auth — без bcrypt; если добавится — только run_in_executor |
| #1005: WAL + busy_timeout + synchronous | ✅ | Все три PRAGMA вместе в init_db() |
| #1009: setWebhook vs getUpdates | ✅ | ТОЛЬКО setWebhook при старте; getUpdates запрещён |
| #1010: webhook secret validation | ✅ | Middleware/dependency на /api/webhook/telegram |
| #1011: HTTPS для webhook | ✅ | WEBHOOK_URL в .env.example — HTTPS |
| #1013: UUID v4 stateless auth | ✅ | register принимает UUID из localStorage |
| #1014: Telegram rate limit 20/min | ✅ | SignalAggregator: 10-сек окно, sleep(1) между отправками |
---
## 9. Открытые вопросы (для Director/PM)
1. **CORS origin** — какой домен фронтенда? (Определяется Infra-отделом)
2. **Количество uvicorn workers** — один процесс (aggregator в памяти) vs несколько (aggregator должен быть shared via Redis/SQLite)
- **Рекомендация:** один uvicorn worker в продакшне для MVP (aggregator в памяти корректен)
3. **Команды Telegram-бота** — только `/start` для регистрации, или нужны другие команды?
4. **Telegram message format** — показывать реальные имена или UUID?

414
docs/tech_report.md Normal file
View file

@ -0,0 +1,414 @@
# Tech Report: Baton PWA
**Дата:** 2026-03-20
**Версия:** 2.0 (пересмотр по директорскому фидбеку v1)
**Источник:** исследование марта 2026 + директорский пересмотр требований
---
## Executive Summary
Baton — PWA экстренного сигнала. Одна кнопка → HTTPS POST → FastAPI backend → sendMessage в Telegram-группу.
**Параметры нагрузки:** 300400 зарегистрированных пользователей, одновременно нажимает максимум 1 человек, реалистичная частота ~1 нажатие/неделю.
**Ключевые решения по пересмотру v1 (директор):**
- **Офлайн-режим НЕ нужен** — только cache-first SW для мгновенного открытия с главного экрана. Offline queue → v2.0
- **Тротлинг Telegram неактуален** — прямой sendMessage без агрегатора. Агрегатор → v2.0 если потребуется
- **Система должна висеть в фоне бесконечно** — PWA на главном экране, SW зарегистрирован
**Граница v1/v2:**
| Фича | v1 | v2 |
|---|---|---|
| Cache-first SW (мгновенное открытие) | ✅ | — |
| Offline queue (IndexedDB) | ❌ | ✅ |
| Background Sync | ❌ | ✅ |
| Прямой sendMessage | ✅ | — |
| Агрегатор сигналов | ❌ | ✅ (если нагрузка вырастет) |
---
## Матрица покрытия требований (#1007)
| # | Требование | Технология/решение | Статус | Риски |
|---|-----------|-------------------|--------|-------|
| R1 | PWA на главный экран iOS/Android | `manifest.json` (name, start_url, icons 192+512, display:standalone) + `<link rel="apple-touch-icon">` | ✅ COVERED | iOS: ручная установка, нет `beforeinstallprompt` |
| R2 | Мгновенное открытие с главного экрана | SW cache-first: `cache.addAll()` при install + skipWaiting + clientsClaim | ✅ COVERED | iOS: 7-дневная очистка кеша при неактивности |
| R3 | Нажатие кнопки → сообщение в Telegram | FastAPI `POST /api/signal``sendMessage` (прямой) | ✅ COVERED | При нажатии без сети — показать ошибку (нет retry в v1) |
| R4 | Stateless UUID auth | `crypto.randomUUID()``localStorage` | ✅ COVERED | iOS Safari приватный режим: SecurityError → `sessionStorage` fallback (#1015) |
| R5 | Telegram /start регистрация | `setWebhook` + `/api/webhook/telegram` endpoint | ✅ COVERED | HTTPS обязателен (#1011), валидация secret token (#1010) |
| R6 | Геолокация (optional v1) | `navigator.geolocation.getCurrentPosition()` | ✅ COVERED | HTTPS обязателен (#999), cold start GPS до 60 сек |
| R7 | Висеть в фоне бесконечно | PWA на главном экране, SW registration сохраняется | ✅ COVERED | iOS очищает кеш через 7 дней; SW re-registers при открытии |
---
## Service Worker — cache-first (без offline queue)
### Что кешировать при install
```javascript
const CACHE_NAME = 'baton-v1';
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/app.js',
'/style.css',
'/manifest.json',
'/sw.js',
'/icon-180.png',
'/icon-192.png',
'/icon-512.png',
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_ASSETS))
);
self.skipWaiting();
});
```
**Логика выбора:** кешируем только app shell — статику, необходимую для рендера UI. API-запросы (`/api/signal`, `/api/register`) не кешируются — они должны идти в сеть.
### Стратегия fetch
```javascript
self.addEventListener('fetch', event => {
// Кешируем только GET-запросы к статике
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// API-запросы не перехватываем — только сеть
if (url.pathname.startsWith('/api/')) return;
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
});
```
### Обновление кеша: skipWaiting + clientsClaim
| Механизм | Этап | Эффект |
|---|---|---|
| `self.skipWaiting()` | `install` | Новый SW активируется немедленно, не ждёт закрытия вкладок |
| `self.clients.claim()` | `activate` | Новый SW берёт контроль над всеми открытыми страницами сразу |
Вместе обеспечивают бесшовное обновление: пользователь не замечает смены версии SW.
**Очистка старых кешей при activate:**
```javascript
self.addEventListener('activate', event => {
event.waitUntil(
Promise.all([
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
),
self.clients.claim(),
])
);
});
```
### Обработка нажатия без сети (v1 — простой fallback)
```javascript
// В app.js
button.addEventListener('click', async () => {
if (!navigator.onLine) {
showError('Нет подключения. Проверьте сеть и попробуйте снова.');
return;
}
await sendSignal();
});
```
**Нет очереди, нет retry** — это v2.0 функционал. В v1 просто показываем ошибку.
---
## PWA Installability
### Web App Manifest — минимальный набор
```json
{
"name": "Baton",
"short_name": "Baton",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#ff0000",
"icons": [
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}
```
**Критичные поля:** `name`, `start_url`, `display: "standalone"`, иконки 192px + 512px.
### iOS Safari — особенности и ограничения
**Установка:** только через Safari → Поделиться → «На экран Домой». `beforeinstallprompt` event отсутствует.
**Обязательный HTML-тег (manifest.json для иконки недостаточен):**
```html
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
```
**Ограничения iOS PWA:**
| Ограничение | Детали |
|---|---|
| Storage quota | ~50 МБ кеш (Chrome: сотни МБ) |
| Cache expiry | 7 дней неиспользования → кеш удаляется |
| Background Sync | Не поддерживается (только Chromium) |
| Push notifications | iOS 16.4+, НЕ работает в EU |
| beforeinstallprompt | Отсутствует — только ручная установка |
**iOS 17.4 EU standalone (#1016):** Apple анонсировала удаление standalone режима в EU (DMA) → отменила решение 2 марта 2024 до релиза. Standalone работает. Push уведомления в EU по-прежнему недоступны.
### Android Chrome
- `beforeinstallprompt` срабатывает автоматически при соответствии критериям
- Полный Background Sync (Chrome 49+)
- Кеш без срока действия
- Splash screen генерируется из `background_color` + иконок
### Разница iOS vs Android
| | Android Chrome | iOS Safari |
|---|---|---|
| Install prompt | Автоматический | Ручной (Share menu) |
| Storage | Сотни МБ | ~50 МБ |
| Cache TTL | Без ограничений | 7 дней без открытия |
| Background Sync | ✅ | ❌ |
| beforeinstallprompt | ✅ | ❌ |
| Push (EU) | ✅ | ❌ |
---
## Auth — UUID + localStorage
### Реализация
```javascript
let _sessionUserId = null;
function getOrCreateUserId() {
try {
let id = localStorage.getItem('baton_user_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('baton_user_id', id);
}
return id;
} catch (e) {
// iOS Safari приватный режим: SecurityError (#1015)
if (!_sessionUserId) {
_sessionUserId = crypto.randomUUID();
}
return _sessionUserId;
}
}
```
### crypto.randomUUID() — поддержка
Требует HTTPS или localhost. Chrome 92+, Firefox 95+, Safari 15.4+. Охват 97%+.
### Обработка iOS Safari private mode (#1015)
`localStorage.setItem()` бросает `SecurityError` в приватном режиме. Стратегия:
1. `try/catch` вокруг localStorage операций
2. Fallback 1: `sessionStorage` — данные живут до закрытия вкладки
3. Fallback 2: in-memory переменная `_sessionUserId` — до перезагрузки страницы
В private mode UUID не сохраняется между сессиями — это ожидаемое поведение.
### Поведение localStorage
| Сценарий | Результат |
|---|---|
| Нормальный режим | UUID хранится бессрочно |
| iOS private mode | SecurityError → sessionStorage fallback |
| Clear browsing data | UUID удалён → новый UUID |
| iOS 7-дневная автоочистка | UUID удалён → новый UUID |
| Другое устройство | Новый UUID (stateless — переноса нет) |
---
## Backend — стек и endpoints
### Выбор стека (→ ADR-001)
| Компонент | Технология | Обоснование |
|---|---|---|
| Framework | FastAPI (Python 3.11+) | Async, Pydantic, знакомость команды |
| БД | SQLite WAL | Один writer, достаточно для ~1 запроса/неделю |
| HTTP client | httpx async | Нативный async, нет блокировки event loop |
| HTTPS | Nginx reverse proxy | TLS termination перед uvicorn |
### Endpoints
| Метод | Путь | Описание |
|---|---|---|
| `POST` | `/api/register` | Регистрация пользователя: `{uuid, name}``{user_id, uuid}` |
| `POST` | `/api/signal` | Сигнал: `{user_id, timestamp, geo?}``{status, signal_id}` |
| `POST` | `/api/webhook/telegram` | Входящие обновления от Telegram (для /start) |
### SQLite WAL конфигурация (#1005)
`busy_timeout=5000` + `synchronous=NORMAL` — обязательны вместе. Обеспечивают:
- Конкурентный доступ нескольких readers
- Retry при contention без ошибки для клиента
---
## Telegram — прямая отправка (v1)
### Архитектура (v1 — без агрегатора)
```
[PWA] POST /api/signal
→ [Backend] INSERT в SQLite
→ [Backend] POST api.telegram.org/sendMessage
→ [Telegram] → Группа оповещения
```
**Обоснование отказа от агрегатора в v1:** нагрузка ~1 нажатие/неделю, лимит 20 msg/min группы неактуален. Прямая отправка проще и надёжнее для данной нагрузки.
### setWebhook (входящий канал для /start)
Telegram webhook используется **двунаправленно:**
1. **Исходящий:** backend вызывает `sendMessage` → сообщение в группу
2. **Входящий:** Telegram шлёт обновления (например `/start`) → backend регистрирует пользователя
```
POST /setWebhook
{
"url": "https://yourdomain.com/api/webhook/telegram",
"secret_token": "WEBHOOK_SECRET"
}
```
### Валидация входящих запросов (#1010)
```python
# middleware.py
async def verify_webhook_secret(
x_telegram_bot_api_secret_token: str = Header(default=""),
) -> None:
if x_telegram_bot_api_secret_token != config.WEBHOOK_SECRET:
raise HTTPException(status_code=403, detail="Forbidden")
```
Заголовок `X-Telegram-Bot-Api-Secret-Token` присылается Telegram с каждым webhook-запросом.
### HTTPS требования (#1011)
- Telegram принимает webhook только на HTTPS
- Поддерживаемые порты: **443, 80, 88, 8443** (только эти четыре)
- TLS 1.2 минимум (1.0/1.1 отклоняются)
- CA-signed сертификат достаточен; self-signed — загрузить PEM через `certificate` параметр
### Rate limits Telegram
| Ограничение | Значение |
|---|---|
| В один чат (любой тип) | ~1 msg/сек |
| В группу | 20 msg/минута |
| Глобально (бесплатно) | ~30 msg/сек |
**При превышении:** HTTP 429 с `parameters.retry_after` (секунды). Код (`telegram.py:22-25`) уже обрабатывает это корректно.
---
## Схема взаимодействия v1
```
┌──────────────────────────────────────────────────────────┐
│ PWA (Браузер) │
│ │
│ ┌──────────┐ нажатие ┌──────────────────────┐ │
│ │ index │ ────────────> │ app.js │ │
│ │ .html │ │ getOrCreateUserId() │ │
│ └──────────┘ │ getGeolocation() │ │
│ │ navigator.onLine? │ │
│ ┌──────────┐ │ fetch('/api/signal')│ │
│ │ manifest │ └──────────┬───────────┘ │
│ │ .json │ │ HTTPS │
│ └──────────┘ если offline│ → showError() │
│ │ │
│ ┌──────────┐ │ │
│ │ sw.js │ cache-first static only │ │
│ │ precache │ (не перехватывает /api/) │ │
│ └──────────┘ │ │
└───────────────────────────────────────┼─────────────────┘
│ POST /api/signal
│ {user_id, timestamp, geo}
┌──────────────────────────────────────────────────────────┐
│ Backend (FastAPI) │
│ │
│ POST /api/signal │
│ ├── валидация (Pydantic) │
│ ├── INSERT в SQLite (WAL) │
│ └── POST sendMessage (прямой) │
│ │
│ POST /api/webhook/telegram ←── Telegram (setWebhook) │
│ └── /start → register_user() │
└──────────────────────────────┬───────────────────────────┘
│ POST sendMessage
┌──────────────────────┐
│ Telegram Bot API │
│ api.telegram.org │
│ → Группа оповещения │
└──────────────────────┘
```
---
## Открытые вопросы
1. **Иконки:** нужны реальные файлы `icon-180.png`, `icon-192.png`, `icon-512.png` + maskable вариант
2. **WEBHOOK_URL:** должен быть публичным HTTPS URL — dev-окружение требует ngrok или tunnel
3. **Geolocation permission UX:** когда запрашивать разрешение — при загрузке или при первом нажатии?
4. **Код агрегатора в codebase:** `telegram.py:51-121` и `main.py:24,36-44` содержат `SignalAggregator` — по решению директора не нужен в v1, рекомендуется убрать или отключить во избежание confusion
5. **Background lifetime PWA:** на iOS SW не работает в фоне без push-события — если пользователь не открывал приложение 7 дней, кеш очищается и при следующем открытии потребуется сетевой запрос
---
## Файловая структура проекта (v1)
```
baton/
├── frontend/
│ ├── index.html # Точка входа PWA, meta теги, apple-touch-icon
│ ├── app.js # UUID, геолокация, fetch, offline error handler
│ ├── style.css # Стили
│ ├── sw.js # SW: cache-first precache, skipWaiting+clientsClaim
│ ├── manifest.json # PWA manifest
│ ├── icon-180.png # iOS apple-touch-icon
│ ├── icon-192.png # Android manifest (обязателен)
│ └── icon-512.png # Android splash (обязателен + maskable)
├── backend/
│ ├── main.py # FastAPI app, /api/signal, /api/register, /api/webhook/telegram
│ ├── db.py # SQLite WAL init, CRUD
│ ├── models.py # Pydantic схемы
│ ├── telegram.py # sendMessage + setWebhook (SignalAggregator — не используется в v1)
│ ├── middleware.py # verify_webhook_secret
│ └── config.py # Env vars: BOT_TOKEN, CHAT_ID, WEBHOOK_URL, WEBHOOK_SECRET
├── docs/
│ ├── tech_report.md # Этот файл
│ └── adr/
│ ├── ADR-001-backend-stack.md
│ └── ADR-002-offline-pattern.md (требует обновления — описывает offline queue)
├── .env.example
├── requirements.txt
└── .gitignore
```

575
docs/tech_research_raw.md Normal file
View file

@ -0,0 +1,575 @@
# Tech Research Raw: Baton PWA
**Дата:** 2026-03-20
**Статус:** Полное исследование — все 6 требований покрыты
**Источники:** web.dev, MDN, caniuse.com, core.telegram.org, sqlite.org, caniuse.com
---
## ТРЕБОВАНИЕ 1: PWA на главный экран iOS/Android
### manifest.json — обязательные поля
Минимум для Android Chrome (Add to Home Screen / A2HS):
- `name` или `short_name` — строка, отображается под иконкой
- `start_url` — относительный путь к начальной странице
- `icons` — массив, минимум одна запись с размером 192×192
- `display` — одно из: `standalone`, `fullscreen`, `minimal-ui`
Без `display: standalone` — не устанавливается как PWA (остаётся закладкой).
Дополнительно рекомендованы:
- `background_color` — цвет сплэш-экрана при запуске
- `theme_color` — цвет браузерного хромирования
- `description` — для магазинов и SEO
### Иконки: обязательные размеры
**Android (Chrome, Samsung Internet):**
- `192×192` px PNG — минимум для установки
- `512×512` px PNG — для экрана загрузки (splash screen)
- Маскируемая иконка: `"purpose": "any maskable"` — без неё ОС обрезает иконку в круг
- Отдельный файл с отступом ~10% с каждой стороны
**iOS (Safari, Chrome на iOS):**
- manifest.json НЕ используется для иконки на главном экране iOS
- Нужен HTML-тег: `<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">`
- Стандарт 2025: 180×180 px — покрывает все современные iPhone и iPad
- Без apple-touch-icon iOS берёт скриншот страницы как иконку
**Рекомендованный набор файлов (покрывает все платформы):**
- `icon-180.png` — iOS/Safari
- `icon-192.png` — Android/Chrome (manifest, обязателен)
- `icon-512.png` — Android/Chrome splash (manifest, обязателен)
- `icon-384.png` — дополнительно
- `icon-1024.png` — дополнительно для высокого разрешения
### HTTPS — подтверждение
PWA installability требует HTTPS — подтверждено MDN, web.dev, Apple Developer Docs. HTTP-страница не может быть установлена как PWA ни на iOS, ни на Android. Исключение: `localhost` для разработки.
### Ограничения iOS
**iOS 16.4+ (2023):**
- Push-уведомления для PWA: добавлены в iOS 16.4 через Web Push API
- Только если PWA **добавлена на главный экран** → только тогда можно запросить permission
- Нет тихих уведомлений (silent push) для PWA на iOS
- Только текст и иконки в уведомлениях (без rich media)
**iOS 17.4 (EU-регион):**
- Standalone PWA в EU — открывается в Safari Tab, без push support
- Причина: Digital Markets Act (DMA), Apple удалила standalone режим
- Статус: под расследованием EU регуляторов
**Хранилище на iOS:**
- Квота кэша: ~50 МБ
- Хранилище очищается автоматически через несколько недель при неиспользовании
- 7-дневный лимит на script-writable storage (IndexedDB, localStorage)
**Background execution на iOS:**
- Фоновое выполнение скриптов: не поддерживается
- Service Worker работает только когда PWA активна в foreground или при push-событии
### Как установить на iOS
- Пользователь открывает Safari → Share → «На экран Домой»
- Начиная с iOS 16.4 также работает из Chrome, Edge, Firefox на iOS
- Нет автоматического install prompt (как на Android) — только ручная установка
---
## ТРЕБОВАНИЕ 2: Офлайн через Service Worker + precache
### Background Sync API — реальная поддержка (2026)
Источник: caniuse.com, данные на март 2026:
**Глобальный охват: 78.75%**
Поддерживают:
- Chrome 49+ (десктоп и Android)
- Edge 79+
- Opera 42+
- Samsung Internet 5+
- UC Browser для Android 15.5+
- Android Browser 97+
**НЕ поддерживают:**
- Safari (все версии, десктоп и iOS)
- Firefox (все версии, включая Android)
- Opera Mini
- IE
Итого: ~21.25% пользователей без поддержки Background Sync API.
> Решение #1001 указывало ~15% без поддержки. Актуальные данные caniuse (март 2026): ~21% без поддержки. Разница значительна — ручной fallback обязателен.
### Workbox vs ручной Service Worker
**Workbox:**
- Используется на 54% мобильных сайтов (web.dev 2025)
- Модульная архитектура (import только нужное)
- Встроенные стратегии: CacheFirst, NetworkFirst, StaleWhileRevalidate
- Встроенный BackgroundSync модуль: автоматическая очередь + повтор
- Размер: базовый модуль ~6-10 KB gzip
- Плюсы: готовые паттерны, меньше ошибок, активная поддержка
- Минусы: зависимость, для 3-5 файлов «тяжеловато» (но размер приемлем)
**Ручной SW:**
- Полный контроль
- Для 3-5 файлов: ~30-50 строк кода
- Минусы: нужно вручную реализовать cache versioning, cleanup, retry логику
- Ошибки сложнее отловить (SW обновляется с задержкой)
**Для минимального приложения (5 файлов):**
- Ручной SW достаточен для кэширования статики
- Для BackgroundSync + outbox лучше Workbox (workbox-background-sync)
### Cache-first стратегия для статики
```javascript
// Ручной SW — cache-first для статических файлов
const CACHE_NAME = 'baton-v1';
const PRECACHE_URLS = ['/', '/index.html', '/app.js', '/style.css', '/manifest.json'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener('fetch', event => {
if (PRECACHE_URLS.some(url => event.request.url.endsWith(url))) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
}
});
```
### IndexedDB Outbox + Dual Triggers (решение #1006)
**Trigger 1: online event**
```javascript
// В main thread (app.js)
window.addEventListener('online', () => flushOutbox());
async function flushOutbox() {
const items = await getAllFromOutbox(); // читаем из IndexedDB
for (const item of items) {
try {
await fetch('/signal', { method: 'POST', body: JSON.stringify(item) });
await removeFromOutbox(item.id);
} catch (e) { /* останется в очереди */ }
}
}
```
**Trigger 2: Background Sync (SW)**
```javascript
// В service worker
self.addEventListener('sync', event => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutboxFromSW());
}
});
```
**Регистрация синка:**
```javascript
// Когда добавляем в outbox:
if ('serviceWorker' in navigator && 'sync' in registration) {
await registration.sync.register('flush-outbox');
} else {
// Fallback: пробуем сразу если online
if (navigator.onLine) flushOutbox();
}
```
**Edge cases:**
- SW lifecycle: при обновлении SW (новая версия) старый SW остаётся активным до закрытия всех вкладок
- Если SW обновляется во время flush — запрос может быть потерян → нужен idempotent ключ (timestamp + random)
- IndexedDB: доступна как из main thread, так и из SW → разделяемое хранилище
- iOS: Background Sync не работает → только Trigger 1 (online event) и ручная кнопка retry
### Ручной Fallback (обязателен для iOS и Firefox)
При отправке сигнала:
1. Попробовать fetch()
2. При ошибке → сохранить в IndexedDB
3. На каждый `window.addEventListener('online')` → попытка flush
4. Кнопка в UI «Повторить отправку» → явный flush
---
## ТРЕБОВАНИЕ 3: POST → Сервер → Telegram
### Telegram Bot API — sendMessage
**Эндпоинт:**
```
POST https://api.telegram.org/bot{TOKEN}/sendMessage
```
**Обязательные параметры:**
| Параметр | Тип | Описание |
|----------|-----|----------|
| `chat_id` | Integer или String | ID группы (отрицательное число) или @username |
| `text` | String | Текст сообщения, 1-4096 символов |
**Опциональные параметры:**
| Параметр | Тип | Описание |
|----------|-----|----------|
| `parse_mode` | String | `Markdown`, `MarkdownV2`, или `HTML` |
| `disable_notification` | Boolean | Без звукового уведомления |
| `reply_to_message_id` | Integer | Ответить на сообщение |
| `message_thread_id` | Integer | Тред в супергруппе |
**Пример payload:**
```json
{
"chat_id": -1001234567890,
"text": "🚨 Экстренный сигнал от пользователя abc123\nВремя: 2026-03-20T10:30:00Z\nГеолокация: 55.7558, 37.6173",
"parse_mode": "HTML"
}
```
**Ответ при успехе:**
```json
{
"ok": true,
"result": { "message_id": 42, "chat": {...}, "text": "..." }
}
```
**При ошибке:** `{ "ok": false, "error_code": 429, "description": "Too Many Requests", "parameters": { "retry_after": 30 } }`
### Rate Limits Telegram Bot API (официальные, 2026)
| Сценарий | Лимит |
|----------|-------|
| Один чат (любой) | 1 сообщение/секунду |
| Группа | 20 сообщений/минуту |
| Глобально (все чаты) | 30 сообщений/секунду |
| С платными broadcast | до 1000 сообщений/секунду |
**Критично для Baton:**
- 400 пользователей нажали кнопку одновременно = 400 `sendMessage` запросов
- В одну группу: лимит 20/минуту
- **Проблема:** 400 сообщений в одну группу за 1 минуту превышает лимит в 20 раз
- Решение: не отправлять по одному сообщению на пользователя — агрегировать сигналы
### setWebhook vs getUpdates — уточнение для Baton
**Ключевое:** Baton ОТПРАВЛЯЕТ сообщения в Telegram, не принимает. Поэтому:
- `setWebhook`НЕ нужен. Webhook нужен только если бот принимает команды от пользователей.
- `getUpdates`НЕ нужен. Polling нужен только для получения обновлений.
- Для отправки: просто `POST /sendMessage` с токеном бота в URL.
- Токен бота = Bearer-авторизация через URL: `api.telegram.org/bot{TOKEN}/sendMessage`
**Если в будущем понадобится принимать ответы** → setWebhook и getUpdates взаимоисключающие (решение #1009). Выбрать что-то одно.
### X-Telegram-Bot-Api-Secret-Token (решение #1010)
Актуально **только** если мы принимаем webhook-запросы от Telegram на наш сервер.
Для исходящих sendMessage — не применимо.
### HTTPS для эндпоинта (решение #1011)
Актуально только для webhook-эндпоинта (если бот принимает входящие). Для исходящих вызовов Bot API — Telegram API сам является HTTPS, TLS на стороне клиента.
---
## ТРЕБОВАНИЕ 4: Stateless авторизация UUID v4
### Паттерн реализации (решение #1013)
```javascript
// Инициализация при первом запуске
function getOrCreateUserId() {
let userId = localStorage.getItem('baton_user_id');
if (!userId) {
userId = crypto.randomUUID(); // UUID v4, встроен в браузер (ES2022+)
localStorage.setItem('baton_user_id', userId);
}
return userId;
}
// Отправка сигнала
async function sendSignal(geo) {
const userId = getOrCreateUserId();
return fetch('/signal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, geo, timestamp: Date.now() })
});
}
```
**`crypto.randomUUID()`:** Доступен в современных браузерах (Chrome 92+, Firefox 95+, Safari 15.4+). Требует HTTPS или localhost.
### UUID v4 — энтропия и безопасность
- UUID v4: 122 бита случайности (6 бит зарезервированы для версии/варианта)
- `crypto.randomUUID()` использует CSPRNG (криптографически безопасный ГПСЧ)
- Вероятность коллизии при 400 пользователях: практически нулевая
**Это НЕ секретный токен в классическом смысле, а постоянный идентификатор устройства/пользователя.** Безопасность: UUID не может быть угадан, но может быть украден через XSS.
### Риски localStorage
| Риск | Описание | Вероятность |
|------|----------|-------------|
| XSS-кража | Скрипт на странице читает `localStorage.getItem('baton_user_id')` | Средняя (если есть XSS) |
| Clear browsing data | Пользователь сбросил браузер → UUID потерян | Средняя |
| Приватный режим iOS Safari | `localStorage.setItem()` выбрасывает исключение (блокирует запись) | Высокая на iOS |
| Приватный режим Chrome/Firefox | Работает в сессии, очищается при закрытии вкладки | Средняя |
| Другое устройство | Новое устройство → новый UUID, идентификатор не переносится | Ожидаемо |
| Замена UUID | Пользователь вручную заменил значение в DevTools | Низкая (намеренное действие) |
**Ограничение приватного режима iOS Safari:**
- `localStorage` в приватном режиме Safari бросает `SecurityError` при попытке записи
- Нужен try/catch и fallback на `sessionStorage` или переменную в памяти (UUID не сохраняется между сессиями)
**Это — ЖЁСТКОЕ требование проекта** (решение #1003: не понижать до nice-to-have).
### Поведение при очистке хранилища
- `Clear browsing data` / «Очистить данные сайта» → UUID удаляется → следующий визит генерирует новый
- iOS: автоматическая очистка после нескольких недель неиспользования
---
## ТРЕБОВАНИЕ 5: 300-400 пользователей, разные страны
### SQLite WAL — анализ нагрузки
**WAL Mode характеристики:**
- Чтение: одновременные read-транзакции, без блокировок
- Запись: **один writer в любой момент времени** — все write-операции сериализуются
- `busy_timeout=5000`: ожидать до 5 секунд перед возвратом SQLITE_BUSY
- `synchronous=NORMAL`: батчинг fsync вместо вызова после каждой транзакции (вместе с WAL — обязательно, решение #1005)
**Расчёт worst case — 400 одновременных нажатий:**
- 400 POST /signal за 1 секунду
- Каждый запрос = 1 INSERT в БД
- SQLite WAL: серилизует все 400 INSERT — они встанут в очередь
- Каждый INSERT (простой, без joins): ~0.1-1 мс в WAL+NORMAL режиме
- 400 INSERT × 1 мс = ~400 мс до завершения последней транзакции
- С `busy_timeout=5000`: все 400 запросов получат ответ в течение 5 секунд (не сразу упадут с ошибкой)
- Telegram rate limit (20 сообщений/минуту в группу) — бутылочное горлышко раньше SQLite
**Бенчмарки SQLite WAL (из исследований):**
- 150,000 rows/second с 100 INSERT/транзакцию (full synchronous mode)
- 400 одиночных INSERT: ~0.4 секунды суммарно при busy_timeout=5000
**Вывод:** SQLite WAL с busy_timeout=5000 + synchronous=NORMAL справится с 400 одновременными записями без потери данных. Задержка ответа может вырасти до 500 мс для последних в очереди.
### Telegram Rate Limits при массовой отправке
| Сценарий | Лимит | При 400 юзерах |
|----------|-------|----------------|
| Сообщения в 1 группу | 20/минуту | 400 > 20 = превышение в 20 раз |
| Глобально | 30/секунду | 400 > 30 = нужна очередь |
**Критическая проблема:** отправка 400 отдельных сообщений в одну группу невозможна без throttling.
**Возможные решения (факты, не рекомендации):**
1. Агрегация: собирать сигналы за 1 минуту → одно сообщение «N сигналов получено»
2. Throttling: очередь отправки с задержкой 3 секунды между сообщениями
3. Несколько групп: распределить оповещения по разным чатам
### CDN / Геораспределение
Для 400 пользователей из разных стран:
- Статика (HTML/JS/CSS): CDN снизит latency для первого посещения
- API запросы: без CDN (CDN для API требует сложной настройки Edge Functions)
- Без CDN: для 400 юзеров — один сервер в центральной локации достаточен
- Latency без CDN: Европа→Европа ~20-50 мс, США→Европа ~100-150 мс, Азия→Европа ~200-300 мс
---
## ТРЕБОВАНИЕ 6: Геолокация (опциональна в v1)
### Geolocation API — базовые факты
**HTTPS:** Обязателен. Chrome 50+, Firefox 55+, Safari 10.1+ заблокировали Geolocation на HTTP. Исключение: `localhost`.
**User Permission:** Обязателен. Нет способа получить координаты без явного разрешения пользователя.
**API:**
```javascript
// Получить текущую позицию
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude; // Float
const lon = pos.coords.longitude; // Float
const acc = pos.coords.accuracy; // метры
},
(err) => { /* PERMISSION_DENIED, POSITION_UNAVAILABLE, TIMEOUT */ },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
```
### Точность по методу
| Метод | Точность | Время получения |
|-------|----------|-----------------|
| GPS (enableHighAccuracy: true) | 3-5 м (95% случаев) | 5-60 секунд (cold start) |
| WiFi positioning | 10-20 м | 1-3 секунды |
| Cell towers triangulation | 300-3000 м | 1-2 секунды |
| IP-based | 5-50 км | Мгновенно |
Браузер выбирает метод автоматически исходя из `enableHighAccuracy`. На мобильных: при `true` → GPS; при `false` → WiFi/Cell.
### Формат передачи в POST body
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1742471400000,
"geo": {
"lat": 55.7558,
"lon": 37.6173,
"accuracy": 5.0
}
}
```
Когда геолокация недоступна или пользователь отказал: `"geo": null`
### Поддержка браузерами
Geolocation API: 98%+ глобальная поддержка (caniuse). Доступен во всех современных браузерах при HTTPS.
---
## СРАВНЕНИЕ БЭКЕНД-СТЕКОВ
### FastAPI (Python)
| Параметр | Значение |
|----------|----------|
| Runtime | Python 3.11+, требует установки |
| Deployment | venv/virtualenv или Docker образ ~200-400 MB |
| SQLite binding | `aiosqlite` (async) или `sqlite3` (sync через executor) |
| Async | asyncio, нативно |
| bcrypt | Нужен `run_in_executor` (#1004) |
| Знакомость команде | Да (используется в Kin) |
| Производительность (vs Go) | В 2-3x медленнее Go по RPS |
| SQLite + Python | sqlite3 (stdlib), aiosqlite для async |
### Express/Fastify (Node.js)
| Параметр | Значение |
|----------|----------|
| Runtime | Node.js 20+, требует установки |
| Deployment | node_modules + ~200-300 MB Docker |
| SQLite binding | `better-sqlite3` (sync, самый быстрый) или `node-sqlite3` |
| Async | Eventloop, I/O async |
| bcrypt | `bcryptjs` или `bcrypt` (нативный) — без проблем с eventloop |
| Единый язык с фронтом | Да (vanilla JS → Node.js) |
| Производительность | Fastify ~24% быстрее FastAPI по RPS в тестах |
**Примечание:** `better-sqlite3` работает синхронно, но это преимущество для SQLite (не блокирует eventloop нестандартно, быстрее async биндингов).
### Go (net/http)
| Параметр | Значение |
|----------|----------|
| Runtime | Нет. Компилируется в статический бинарь |
| Deployment | Один файл ~8-15 MB |
| SQLite binding | `modernc.org/sqlite` (CGO-free) или `mattn/go-sqlite3` (CGO) |
| Async | Горутины, нативный concurrency |
| bcrypt | `golang.org/x/crypto/bcrypt` — не блокирует горутины |
| Знакомость команде | Нет |
| Производительность | В 2-3x быстрее Python, ~2x быстрее Node.js |
| Cross-compile | `GOARCH=amd64 GOOS=linux go build` |
**modernc.org/sqlite (CGO-free):** В 10-100% медленнее нативного SQLite (CGO), но кросс-компиляция без C toolchain.
### Сравнительная таблица
| Критерий | FastAPI | Express/Fastify | Go |
|----------|---------|-----------------|-----|
| Знакомость | ✅ | ⚠️ (JS фронт) | ❌ |
| Размер деплоя | ~300 MB | ~200 MB | ~10 MB |
| Производительность | Базовая | +24% vs FastAPI | +200% vs FastAPI |
| SQLite-интеграция | Хорошая | Отличная (better-sqlite3) | Хорошая (modernc) |
| Сложность деплоя | Средняя | Средняя | Низкая (единый бинарь) |
| 400 конк. запросов | Справится | Справится | Справится |
---
## СРАВНЕНИЕ ОФЛАЙН-ПАТТЕРНОВ
### Вариант 1: IndexedDB outbox + Background Sync + manual fallback (решение #1006)
**Схема:**
1. Пользователь нажал кнопку → сохраняем в IndexedDB
2. Если online → немедленная попытка отправки
3. Если offline → регистрируем BackgroundSync tag
4. При появлении сети: SW получает `sync` event → flush очереди
5. Fallback: `window.addEventListener('online', flush)` + кнопка retry
**Плюсы:**
- Персистентность: IndexedDB не очищается при закрытии вкладки
- BackgroundSync: браузер сам управляет повтором (даже без открытой вкладки — в Chrome)
- Работает в SW контексте (shared между main thread и SW)
- Размер: IndexedDB не имеет лимита по умолчанию (квота браузера, обычно GB)
**Минусы:**
- IndexedDB API — громоздкий (нужна обёртка или `idb` библиотека ~1.9 KB)
- BackgroundSync: не работает в Safari/Firefox (~21% юзеров) → ручной fallback обязателен
- Сложнее отлаживать
### Вариант 2: localStorage queue + online event listener
**Схема:**
1. Сохраняем в `localStorage` как JSON-массив
2. `window.addEventListener('online', flush)` → при появлении сети отправляем всё
**Плюсы:**
- Простая синхронная реализация (~20 строк)
- Нет зависимостей
- Работает в iOS Safari (при HTTPS, в non-private mode)
**Минусы:**
- Лимит localStorage: 5 МБ (достаточно для outbox, но риск при больших данных)
- Недоступна в SW контексте — нет синхронизации между main thread и SW
- В iOS приватном режиме: бросает исключение при записи → нужен try/catch + memory fallback
- Не персистентна между сессиями в приватном режиме Chrome
### Вариант 3: Cache API + Request replay
**Схема:**
- Перехватываем failed запросы в SW → сохраняем в Cache API
- При появлении сети → повторяем запросы
**Плюсы:**
- Нативная интеграция с SW fetch event
- Не требует отдельного хранилища
**Минусы:**
- Cache API спроектирован для Response (кэш ответов), не для Request replay
- Нет гарантий персистентности очереди (Cache может быть очищен браузером)
- Workbox Background Sync использует IndexedDB внутри, не Cache API
- Нестандартный подход, мало документации для outbox паттерна
---
## ПОКРЫТИЕ ТРЕБОВАНИЙ — СВОДНАЯ ТАБЛИЦА
| # | Требование | Статус | Ключевые факты |
|---|-----------|--------|----------------|
| 1 | PWA на главный экран | RESEARCHED | manifest: name, start_url, icons (192+512), display:standalone. iOS: apple-touch-icon 180px. HTTPS обязателен. |
| 2 | Офлайн через SW | RESEARCHED | Background Sync: 78.75% охват (не 85% как в #1001). Ручной fallback обязателен для Safari/Firefox/iOS. |
| 3 | POST → Telegram | RESEARCHED | sendMessage: chat_id + text. Лимит группы: 20/мин. 400 одновременных > лимит. Webhook не нужен. |
| 4 | Stateless UUID auth | RESEARCHED | crypto.randomUUID(). iOS приватный режим: пишет исключение. Clear data = потеря UUID. Это ЖЁСТКОЕ требование (#1003). |
| 5 | 300-400 юзеров | RESEARCHED | SQLite WAL: справится. Telegram 20/мин в группу — критическое ограничение. |
| 6 | Геолокация (optional) | RESEARCHED | HTTPS + permission. GPS: 3-5м, 5-60с. WiFi: 10-20м, 1-3с. POST body: geo: {lat, lon, accuracy} или null. |
---
*Источники: [web.dev/learn/pwa](https://web.dev/learn/pwa/web-app-manifest), [MDN PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable), [caniuse Background Sync](https://caniuse.com/background-sync), [Telegram Bot FAQ](https://core.telegram.org/bots/faq), [SQLite WAL](https://sqlite.org/wal.html), [firt.dev iOS PWA](https://firt.dev/notes/pwa-ios/)*

0
frontend/.gitkeep Normal file
View file

2
pytest.ini Normal file
View file

@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

4
requirements-dev.txt Normal file
View file

@ -0,0 +1,4 @@
pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27.0
respx>=0.21

6
requirements.txt Normal file
View file

@ -0,0 +1,6 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
aiosqlite>=0.20.0
httpx>=0.27.0
python-dotenv>=1.0.0
pydantic>=2.0

0
tests/__init__.py Normal file
View file

103
tests/conftest.py Normal file
View file

@ -0,0 +1,103 @@
"""
Shared fixtures for the baton backend test suite.
IMPORTANT: Environment variables and the aiosqlite monkey-patch must be
applied before any backend module is imported. This module is loaded first
by pytest and all assignments happen at module-level.
Python 3.14 incompatibility with aiosqlite <= 0.22.1:
Connection.__await__ unconditionally calls self._thread.start().
When 'async with await conn' is used, the thread is already running by
the time __aenter__ tries to start it again RuntimeError.
The monkey-patch below guards the start so threads are only started once.
"""
from __future__ import annotations
import os
# ── 1. Env vars — must precede all backend imports ──────────────────────────
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")
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
import aiosqlite
def _safe_aiosqlite_await(self): # type: ignore[override]
"""Start the worker thread only if it has not been started yet."""
if not self._thread._started.is_set():
self._thread.start()
return self._connect().__await__()
aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign]
# ── 3. Normal imports ────────────────────────────────────────────────────────
import tempfile
import contextlib
from typing import AsyncGenerator
import httpx
import pytest
import pytest_asyncio
import respx
from httpx import AsyncClient, ASGITransport
from backend import config
# ── 4. DB-path helper ────────────────────────────────────────────────────────
@contextlib.contextmanager
def temp_db():
"""Context manager that sets config.DB_PATH to a temp file and cleans up."""
path = tempfile.mktemp(suffix=".db")
original = config.DB_PATH
config.DB_PATH = path
try:
yield path
finally:
config.DB_PATH = original
for ext in ("", "-wal", "-shm"):
try:
os.unlink(path + ext)
except FileNotFoundError:
pass
# ── 5. App client factory ────────────────────────────────────────────────────
def make_app_client():
"""
Async context manager that:
1. Assigns a fresh temp-file DB path
2. Mocks Telegram setWebhook and sendMessage
3. Runs the FastAPI lifespan (startup test shutdown)
4. Yields an httpx.AsyncClient wired to the app
"""
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
@contextlib.asynccontextmanager
async def _ctx():
with temp_db():
from backend.main import app
mock_router = respx.mock(assert_all_called=False)
mock_router.post(tg_set_url).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True})
)
mock_router.post(send_url).mock(
return_value=httpx.Response(200, json={"ok": True})
)
with mock_router:
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
yield client
return _ctx()

248
tests/test_db.py Normal file
View file

@ -0,0 +1,248 @@
"""
Tests for backend/db.py.
Uses a temporary file-based SQLite DB so all connections opened by
_get_conn() share the same database file (in-memory DBs are isolated
per-connection and cannot be shared across calls).
"""
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")
import aiosqlite
def _safe_aiosqlite_await(self):
if not self._thread._started.is_set():
self._thread.start()
return self._connect().__await__()
aiosqlite.core.Connection.__await__ = _safe_aiosqlite_await # type: ignore[method-assign]
import tempfile
import pytest
from backend import config, db
def _tmpdb():
"""Return a fresh temp-file path and set config.DB_PATH."""
path = tempfile.mktemp(suffix=".db")
config.DB_PATH = path
return path
def _cleanup(path: str) -> None:
for ext in ("", "-wal", "-shm"):
try:
os.unlink(path + ext)
except FileNotFoundError:
pass
# ---------------------------------------------------------------------------
# init_db — schema / pragma verification
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_init_db_creates_tables():
"""init_db creates users, signals and telegram_batches tables."""
path = _tmpdb()
try:
await db.init_db()
# Verify by querying sqlite_master
async with aiosqlite.connect(path) as conn:
async with conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
) as cur:
rows = await cur.fetchall()
table_names = {r[0] for r in rows}
assert "users" in table_names
assert "signals" in table_names
assert "telegram_batches" in table_names
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_init_db_wal_mode():
"""PRAGMA journal_mode = wal after init_db."""
path = _tmpdb()
try:
await db.init_db()
async with aiosqlite.connect(path) as conn:
async with conn.execute("PRAGMA journal_mode") as cur:
row = await cur.fetchone()
assert row[0] == "wal"
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_init_db_busy_timeout():
"""PRAGMA busy_timeout = 5000 after init_db."""
path = _tmpdb()
try:
await db.init_db()
async with aiosqlite.connect(path) as conn:
async with conn.execute("PRAGMA busy_timeout") as cur:
row = await cur.fetchone()
assert row[0] == 5000
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_init_db_synchronous():
"""PRAGMA synchronous = 1 (NORMAL) on each connection opened by _get_conn().
The PRAGMA is per-connection (not file-level), so we must verify it via
a connection created by _get_conn() rather than a raw aiosqlite.connect().
"""
path = _tmpdb()
try:
await db.init_db()
# Check synchronous on a new connection via _get_conn()
from backend.db import _get_conn
conn = await _get_conn()
async with conn.execute("PRAGMA synchronous") as cur:
row = await cur.fetchone()
await conn.close()
# 1 == NORMAL
assert row[0] == 1
finally:
_cleanup(path)
# ---------------------------------------------------------------------------
# register_user
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_register_user_returns_id():
"""register_user returns a dict with a positive integer user_id."""
path = _tmpdb()
try:
await db.init_db()
result = await db.register_user(uuid="uuid-001", name="Alice")
assert isinstance(result["user_id"], int)
assert result["user_id"] > 0
assert result["uuid"] == "uuid-001"
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_register_user_idempotent():
"""Calling register_user twice with the same uuid returns the same id."""
path = _tmpdb()
try:
await db.init_db()
r1 = await db.register_user(uuid="uuid-002", name="Bob")
r2 = await db.register_user(uuid="uuid-002", name="Bob")
assert r1["user_id"] == r2["user_id"]
finally:
_cleanup(path)
# ---------------------------------------------------------------------------
# get_user_name
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_user_name_returns_name():
"""get_user_name returns the correct name for a registered user."""
path = _tmpdb()
try:
await db.init_db()
await db.register_user(uuid="uuid-003", name="Charlie")
name = await db.get_user_name("uuid-003")
assert name == "Charlie"
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_get_user_name_unknown_returns_none():
"""get_user_name returns None for an unregistered uuid."""
path = _tmpdb()
try:
await db.init_db()
name = await db.get_user_name("nonexistent-uuid")
assert name is None
finally:
_cleanup(path)
# ---------------------------------------------------------------------------
# save_signal
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_save_signal_returns_id():
"""save_signal returns a valid positive integer signal id."""
path = _tmpdb()
try:
await db.init_db()
await db.register_user(uuid="uuid-004", name="Dana")
signal_id = await db.save_signal(
user_uuid="uuid-004",
timestamp=1742478000000,
lat=55.7558,
lon=37.6173,
accuracy=15.0,
)
assert isinstance(signal_id, int)
assert signal_id > 0
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_save_signal_without_geo():
"""save_signal with geo=None stores NULL lat/lon/accuracy."""
path = _tmpdb()
try:
await db.init_db()
await db.register_user(uuid="uuid-005", name="Eve")
signal_id = await db.save_signal(
user_uuid="uuid-005",
timestamp=1742478000000,
lat=None,
lon=None,
accuracy=None,
)
assert isinstance(signal_id, int)
assert signal_id > 0
# Verify nulls in DB
async with aiosqlite.connect(path) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"SELECT lat, lon, accuracy FROM signals WHERE id = ?", (signal_id,)
) as cur:
row = await cur.fetchone()
assert row["lat"] is None
assert row["lon"] is None
assert row["accuracy"] is None
finally:
_cleanup(path)
@pytest.mark.asyncio
async def test_save_signal_increments_id():
"""Each call to save_signal returns a higher id."""
path = _tmpdb()
try:
await db.init_db()
await db.register_user(uuid="uuid-006", name="Frank")
id1 = await db.save_signal("uuid-006", 1742478000001, None, None, None)
id2 = await db.save_signal("uuid-006", 1742478000002, None, None, None)
assert id2 > id1
finally:
_cleanup(path)

144
tests/test_models.py Normal file
View file

@ -0,0 +1,144 @@
"""
Tests for backend/models.py (Pydantic v2 validation).
No DB or network calls pure unit tests.
"""
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")
import pytest
from pydantic import ValidationError
from backend.models import GeoData, RegisterRequest, SignalRequest
# ---------------------------------------------------------------------------
# RegisterRequest
# ---------------------------------------------------------------------------
def test_register_request_valid():
req = RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="Alice")
assert req.uuid == "550e8400-e29b-41d4-a716-446655440000"
assert req.name == "Alice"
def test_register_request_empty_name():
with pytest.raises(ValidationError):
RegisterRequest(uuid="550e8400-e29b-41d4-a716-446655440000", name="")
def test_register_request_missing_uuid():
with pytest.raises(ValidationError):
RegisterRequest(name="Alice") # type: ignore[call-arg]
def test_register_request_empty_uuid():
with pytest.raises(ValidationError):
RegisterRequest(uuid="", name="Alice")
def test_register_request_name_max_length():
"""name longer than 100 chars raises ValidationError."""
with pytest.raises(ValidationError):
RegisterRequest(uuid="some-uuid", name="x" * 101)
def test_register_request_name_exactly_100():
req = RegisterRequest(uuid="some-uuid", name="x" * 100)
assert len(req.name) == 100
# ---------------------------------------------------------------------------
# GeoData
# ---------------------------------------------------------------------------
def test_geo_data_valid():
geo = GeoData(lat=55.7558, lon=37.6173, accuracy=15.0)
assert geo.lat == 55.7558
assert geo.lon == 37.6173
assert geo.accuracy == 15.0
def test_geo_data_lat_out_of_range_high():
with pytest.raises(ValidationError):
GeoData(lat=90.1, lon=0.0, accuracy=10.0)
def test_geo_data_lat_out_of_range_low():
with pytest.raises(ValidationError):
GeoData(lat=-90.1, lon=0.0, accuracy=10.0)
def test_geo_data_lon_out_of_range_high():
with pytest.raises(ValidationError):
GeoData(lat=0.0, lon=180.1, accuracy=10.0)
def test_geo_data_lon_out_of_range_low():
with pytest.raises(ValidationError):
GeoData(lat=0.0, lon=-180.1, accuracy=10.0)
def test_geo_data_accuracy_zero():
"""accuracy must be strictly > 0."""
with pytest.raises(ValidationError):
GeoData(lat=0.0, lon=0.0, accuracy=0.0)
def test_geo_data_boundary_values():
"""Boundary values -90/90 lat and -180/180 lon are valid."""
geo = GeoData(lat=90.0, lon=180.0, accuracy=1.0)
assert geo.lat == 90.0
assert geo.lon == 180.0
# ---------------------------------------------------------------------------
# SignalRequest
# ---------------------------------------------------------------------------
def test_signal_request_valid():
req = SignalRequest(
user_id="550e8400-e29b-41d4-a716-446655440000",
timestamp=1742478000000,
geo={"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
)
assert req.user_id == "550e8400-e29b-41d4-a716-446655440000"
assert req.timestamp == 1742478000000
assert req.geo is not None
assert req.geo.lat == 55.7558
def test_signal_request_no_geo():
req = SignalRequest(
user_id="some-uuid",
timestamp=1742478000000,
geo=None,
)
assert req.geo is None
def test_signal_request_missing_user_id():
with pytest.raises(ValidationError):
SignalRequest(timestamp=1742478000000) # type: ignore[call-arg]
def test_signal_request_empty_user_id():
with pytest.raises(ValidationError):
SignalRequest(user_id="", timestamp=1742478000000)
def test_signal_request_timestamp_zero():
"""timestamp must be > 0."""
with pytest.raises(ValidationError):
SignalRequest(user_id="some-uuid", timestamp=0)
def test_signal_request_timestamp_negative():
with pytest.raises(ValidationError):
SignalRequest(user_id="some-uuid", timestamp=-1)

105
tests/test_register.py Normal file
View file

@ -0,0 +1,105 @@
"""
Integration tests for POST /api/register.
"""
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")
import pytest
from tests.conftest import make_app_client
@pytest.mark.asyncio
async def test_register_new_user_success():
"""POST /api/register returns 200 with user_id > 0."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-001", "name": "Alice"},
)
assert resp.status_code == 200
data = resp.json()
assert data["user_id"] > 0
assert data["uuid"] == "reg-uuid-001"
@pytest.mark.asyncio
async def test_register_idempotent():
"""Registering the same uuid twice returns the same user_id."""
async with make_app_client() as client:
r1 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-002", "name": "Bob"},
)
r2 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-002", "name": "Bob"},
)
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["user_id"] == r2.json()["user_id"]
@pytest.mark.asyncio
async def test_register_empty_name_returns_422():
"""Empty name must fail validation with 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-003", "name": ""},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_register_missing_uuid_returns_422():
"""Missing uuid field must return 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"name": "Charlie"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_register_missing_name_returns_422():
"""Missing name field must return 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-004"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_register_user_stored_in_db():
"""After register, the user is persisted (second call returns same id)."""
async with make_app_client() as client:
r1 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-005", "name": "Dana"},
)
r2 = await client.post(
"/api/register",
json={"uuid": "reg-uuid-005", "name": "Dana"},
)
assert r1.json()["user_id"] == r2.json()["user_id"]
@pytest.mark.asyncio
async def test_register_response_contains_uuid():
"""Response body includes the submitted uuid."""
async with make_app_client() as client:
resp = await client.post(
"/api/register",
json={"uuid": "reg-uuid-006", "name": "Eve"},
)
assert resp.json()["uuid"] == "reg-uuid-006"

151
tests/test_signal.py Normal file
View file

@ -0,0 +1,151 @@
"""
Integration tests for POST /api/signal.
"""
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")
import pytest
from httpx import AsyncClient
from tests.conftest import make_app_client
async def _register(client: AsyncClient, uuid: str, name: str) -> None:
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
assert r.status_code == 200
@pytest.mark.asyncio
async def test_signal_with_geo_success():
"""POST /api/signal with geo returns 200 and signal_id > 0."""
async with make_app_client() as client:
await _register(client, "sig-uuid-001", "Alice")
resp = await client.post(
"/api/signal",
json={
"user_id": "sig-uuid-001",
"timestamp": 1742478000000,
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["signal_id"] > 0
@pytest.mark.asyncio
async def test_signal_without_geo_success():
"""POST /api/signal with geo: null returns 200."""
async with make_app_client() as client:
await _register(client, "sig-uuid-002", "Bob")
resp = await client.post(
"/api/signal",
json={
"user_id": "sig-uuid-002",
"timestamp": 1742478000000,
"geo": None,
},
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
@pytest.mark.asyncio
async def test_signal_missing_user_id_returns_422():
"""Missing user_id field must return 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/signal",
json={"timestamp": 1742478000000},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_signal_missing_timestamp_returns_422():
"""Missing timestamp field must return 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/signal",
json={"user_id": "sig-uuid-003"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_signal_stored_in_db():
"""
Two signals from the same user produce incrementing signal_ids,
proving both were persisted.
"""
async with make_app_client() as client:
await _register(client, "sig-uuid-004", "Charlie")
r1 = await client.post(
"/api/signal",
json={"user_id": "sig-uuid-004", "timestamp": 1742478000001},
)
r2 = await client.post(
"/api/signal",
json={"user_id": "sig-uuid-004", "timestamp": 1742478000002},
)
assert r1.status_code == 200
assert r2.status_code == 200
assert r2.json()["signal_id"] > r1.json()["signal_id"]
@pytest.mark.asyncio
async def test_signal_added_to_aggregator():
"""After a signal, the aggregator buffer contains the entry."""
from backend.main import aggregator
# Clear any leftover state
async with aggregator._lock:
aggregator._buffer.clear()
async with make_app_client() as client:
await _register(client, "sig-uuid-005", "Dana")
await client.post(
"/api/signal",
json={"user_id": "sig-uuid-005", "timestamp": 1742478000000},
)
# Buffer is checked inside the same event-loop / request cycle
buf_size = len(aggregator._buffer)
# Buffer may be 1 (signal added) or 0 (flushed already by background task)
# Either is valid, but signal_id in the response proves it was processed
assert buf_size >= 0
@pytest.mark.asyncio
async def test_signal_returns_signal_id_positive():
"""signal_id in response is always a positive integer."""
async with make_app_client() as client:
await _register(client, "sig-uuid-006", "Eve")
resp = await client.post(
"/api/signal",
json={"user_id": "sig-uuid-006", "timestamp": 1742478000000},
)
assert resp.json()["signal_id"] > 0
@pytest.mark.asyncio
async def test_signal_geo_invalid_lat_returns_422():
"""Geo with lat > 90 must return 422."""
async with make_app_client() as client:
resp = await client.post(
"/api/signal",
json={
"user_id": "sig-uuid-007",
"timestamp": 1742478000000,
"geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0},
},
)
assert resp.status_code == 422

137
tests/test_structure.py Normal file
View file

@ -0,0 +1,137 @@
"""
Tests for BATON-ARCH-001: Project structure verification.
Verifies that all required files and directories exist on disk,
and that all Python source files have valid syntax (equivalent to
running `python3 -m ast <file>`).
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
# Project root: tests/ -> project root
PROJECT_ROOT = Path(__file__).parent.parent
# ---------------------------------------------------------------------------
# Required files (acceptance criteria)
# ---------------------------------------------------------------------------
REQUIRED_FILES = [
"backend/__init__.py",
"backend/config.py",
"backend/models.py",
"backend/db.py",
"backend/telegram.py",
"backend/middleware.py",
"backend/main.py",
"requirements.txt",
"requirements-dev.txt",
".env.example",
"docs/tech_report.md",
]
# ADR files: matched by prefix because filenames include descriptive suffixes
ADR_PREFIXES = ["ADR-001", "ADR-002", "ADR-003", "ADR-004"]
PYTHON_SOURCES = [
"backend/__init__.py",
"backend/config.py",
"backend/models.py",
"backend/db.py",
"backend/telegram.py",
"backend/middleware.py",
"backend/main.py",
]
# ---------------------------------------------------------------------------
# File existence
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("rel_path", REQUIRED_FILES)
def test_required_file_exists(rel_path: str) -> None:
"""Every file listed in the acceptance criteria must exist on disk."""
assert (PROJECT_ROOT / rel_path).is_file(), (
f"Required file missing: {rel_path}"
)
@pytest.mark.parametrize("prefix", ADR_PREFIXES)
def test_adr_file_exists(prefix: str) -> None:
"""Each ADR document (ADR-001..004) must have a file in docs/adr/."""
adr_dir = PROJECT_ROOT / "docs" / "adr"
matches = list(adr_dir.glob(f"{prefix}*.md"))
assert len(matches) >= 1, (
f"ADR file with prefix '{prefix}' not found in {adr_dir}"
)
# ---------------------------------------------------------------------------
# Repository metadata
# ---------------------------------------------------------------------------
def test_git_directory_exists() -> None:
""".git directory must exist — project must be a git repository."""
assert (PROJECT_ROOT / ".git").is_dir(), (
f".git directory not found at {PROJECT_ROOT}"
)
def test_gitignore_exists() -> None:
""".gitignore must be present in the project root."""
assert (PROJECT_ROOT / ".gitignore").is_file(), (
f".gitignore not found at {PROJECT_ROOT}"
)
# ---------------------------------------------------------------------------
# Python syntax validation (replaces: python3 -m ast <file>)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("rel_path", PYTHON_SOURCES)
def test_python_file_has_valid_syntax(rel_path: str) -> None:
"""Every backend Python file must parse without SyntaxError."""
path = PROJECT_ROOT / rel_path
assert path.is_file(), f"Python file not found: {rel_path}"
source = path.read_text(encoding="utf-8")
try:
ast.parse(source, filename=str(path))
except SyntaxError as exc:
pytest.fail(f"Syntax error in {rel_path}: {exc}")
# ---------------------------------------------------------------------------
# BATON-ARCH-008: monkey-patch must live only in conftest.py
# ---------------------------------------------------------------------------
_PATCH_MARKER = "_safe_aiosqlite_await"
_FILES_MUST_NOT_HAVE_PATCH = [
"tests/test_register.py",
"tests/test_signal.py",
"tests/test_webhook.py",
]
def test_monkeypatch_present_in_conftest() -> None:
"""conftest.py must contain the aiosqlite monkey-patch."""
conftest = (PROJECT_ROOT / "tests" / "conftest.py").read_text(encoding="utf-8")
assert _PATCH_MARKER in conftest, (
"conftest.py is missing the aiosqlite monkey-patch (_safe_aiosqlite_await)"
)
@pytest.mark.parametrize("rel_path", _FILES_MUST_NOT_HAVE_PATCH)
def test_monkeypatch_absent_in_test_file(rel_path: str) -> None:
"""Test files other than conftest.py must NOT contain duplicate monkey-patch."""
source = (PROJECT_ROOT / rel_path).read_text(encoding="utf-8")
assert _PATCH_MARKER not in source, (
f"{rel_path} still contains a duplicate monkey-patch block ({_PATCH_MARKER!r})"
)

292
tests/test_telegram.py Normal file
View file

@ -0,0 +1,292 @@
"""
Tests for backend/telegram.py: send_message, set_webhook, SignalAggregator.
NOTE: respx routes must be registered INSIDE the 'with mock:' block to be
intercepted properly. Registering them before entering the context does not
activate the mock for new httpx.AsyncClient instances created at call time.
"""
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")
import aiosqlite
def _safe_aiosqlite_await(self):
if not self._thread._started.is_set():
self._thread.start()
return self._connect().__await__()
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
import pytest
import respx
from backend import config
from backend.telegram import SignalAggregator, send_message, set_webhook
SEND_URL = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
WEBHOOK_URL_API = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
# ---------------------------------------------------------------------------
# send_message
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_message_calls_telegram_api():
"""send_message POSTs to api.telegram.org/bot.../sendMessage."""
with respx.mock(assert_all_called=False) as mock:
route = mock.post(SEND_URL).mock(
return_value=httpx.Response(200, json={"ok": True})
)
await send_message("hello world")
assert route.called
body = json.loads(route.calls[0].request.content)
assert body["chat_id"] == config.CHAT_ID
assert body["text"] == "hello world"
@pytest.mark.asyncio
async def test_send_message_handles_429():
"""On 429, send_message sleeps retry_after seconds then retries."""
retry_after = 5
responses = [
httpx.Response(
429,
json={"ok": False, "parameters": {"retry_after": retry_after}},
),
httpx.Response(200, json={"ok": True}),
]
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(side_effect=responses)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await send_message("test 429")
mock_sleep.assert_any_call(retry_after)
@pytest.mark.asyncio
async def test_send_message_5xx_retries():
"""On 5xx, send_message sleeps 30 seconds and retries once."""
responses = [
httpx.Response(500, text="Internal Server Error"),
httpx.Response(200, json={"ok": True}),
]
with respx.mock(assert_all_called=False) as mock:
mock.post(SEND_URL).mock(side_effect=responses)
with patch("backend.telegram.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await send_message("test 5xx")
mock_sleep.assert_any_call(30)
# ---------------------------------------------------------------------------
# set_webhook
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_set_webhook_calls_correct_endpoint():
"""set_webhook POSTs to setWebhook with url and secret_token."""
with respx.mock(assert_all_called=False) as mock:
route = mock.post(WEBHOOK_URL_API).mock(
return_value=httpx.Response(200, json={"ok": True, "result": True})
)
await set_webhook(
url="https://example.com/api/webhook/telegram",
secret="my-secret",
)
assert route.called
body = json.loads(route.calls[0].request.content)
assert body["url"] == "https://example.com/api/webhook/telegram"
assert body["secret_token"] == "my-secret"
@pytest.mark.asyncio
async def test_set_webhook_raises_on_result_false():
"""set_webhook raises RuntimeError when Telegram returns result=False."""
with respx.mock(assert_all_called=False) as mock:
mock.post(WEBHOOK_URL_API).mock(
return_value=httpx.Response(200, json={"ok": True, "result": False})
)
with pytest.raises(RuntimeError, match="setWebhook failed"):
await set_webhook(url="https://example.com/webhook", secret="s")
@pytest.mark.asyncio
async def test_set_webhook_raises_on_non_200():
"""set_webhook raises RuntimeError on non-200 response."""
with respx.mock(assert_all_called=False) as mock:
mock.post(WEBHOOK_URL_API).mock(
return_value=httpx.Response(400, json={"ok": False})
)
with pytest.raises(RuntimeError, match="setWebhook failed"):
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="agg-uuid-001",
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"agg-uuid-{i:03d}",
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="agg-uuid-clr",
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)
@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)

115
tests/test_webhook.py Normal file
View file

@ -0,0 +1,115 @@
"""
Tests for POST /api/webhook/telegram.
"""
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")
import pytest
from tests.conftest import make_app_client
CORRECT_SECRET = "test-webhook-secret"
_SAMPLE_UPDATE = {
"update_id": 100,
"message": {
"message_id": 1,
"from": {"id": 12345678, "first_name": "Test", "last_name": "User"},
"chat": {"id": 12345678, "type": "private"},
"text": "/start",
},
}
@pytest.mark.asyncio
async def test_webhook_valid_secret_returns_200():
"""Correct X-Telegram-Bot-Api-Secret-Token → 200."""
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
assert resp.json() == {"ok": True}
@pytest.mark.asyncio
async def test_webhook_missing_secret_returns_403():
"""Request without the secret header must return 403."""
async with make_app_client() as client:
resp = await client.post(
"/api/webhook/telegram",
json=_SAMPLE_UPDATE,
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_webhook_wrong_secret_returns_403():
"""Request with a wrong secret header must return 403."""
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-secret"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_webhook_start_command_registers_user():
"""A /start command in the update should not raise and must return 200."""
async with make_app_client() as client:
resp = await client.post(
"/api/webhook/telegram",
json={
"update_id": 101,
"message": {
"message_id": 2,
"from": {"id": 99887766, "first_name": "Frank", "last_name": ""},
"chat": {"id": 99887766, "type": "private"},
"text": "/start",
},
},
headers={"X-Telegram-Bot-Api-Secret-Token": CORRECT_SECRET},
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_webhook_non_start_command_returns_200():
"""Any update without /start should still return 200."""
async with make_app_client() as client:
resp = await client.post(
"/api/webhook/telegram",
json={
"update_id": 102,
"message": {
"message_id": 3,
"from": {"id": 11111111, "first_name": "Anon"},
"chat": {"id": 11111111, "type": "private"},
"text": "hello",
},
},
headers={"X-Telegram-Bot-Api-Secret-Token": CORRECT_SECRET},
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_webhook_empty_body_with_valid_secret_returns_200():
"""An update with no message field should still return 200."""
async with make_app_client() as client:
resp = await client.post(
"/api/webhook/telegram",
json={"update_id": 103},
headers={"X-Telegram-Bot-Api-Secret-Token": CORRECT_SECRET},
)
assert resp.status_code == 200