kin: BATON-002 [Research] UX Designer
This commit is contained in:
commit
057e500d5f
29 changed files with 3530 additions and 0 deletions
11
.env.example
Normal file
11
.env.example
Normal 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
140
.gitignore
vendored
Normal 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
0
backend/__init__.py
Normal file
21
backend/config.py
Normal file
21
backend/config.py
Normal 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
123
backend/db.py
Normal 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
114
backend/main.py
Normal 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
12
backend/middleware.py
Normal 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
31
backend/models.py
Normal 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
121
backend/telegram.py
Normal 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
|
||||||
97
docs/adr/ADR-001-backend-stack.md
Normal file
97
docs/adr/ADR-001-backend-stack.md
Normal 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`
|
||||||
123
docs/adr/ADR-002-offline-pattern.md
Normal file
123
docs/adr/ADR-002-offline-pattern.md
Normal 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 — не «опциональный», а обязательный элемент архитектуры.
|
||||||
35
docs/adr/ADR-003-auth-pattern.md
Normal file
35
docs/adr/ADR-003-auth-pattern.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# ADR-003: Паттерн аутентификации пользователей
|
||||||
|
|
||||||
|
**Дата:** 2026-03-20
|
||||||
|
**Статус:** Stub (подлежит заполнению)
|
||||||
|
**Автор:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
_Описание контекста — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Варианты
|
||||||
|
|
||||||
|
_Описание вариантов — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
_Решение — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обоснование
|
||||||
|
|
||||||
|
_Обоснование — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
_Последствия — предстоит заполнить._
|
||||||
35
docs/adr/ADR-004-telegram-strategy.md
Normal file
35
docs/adr/ADR-004-telegram-strategy.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор)
|
||||||
|
|
||||||
|
**Дата:** 2026-03-20
|
||||||
|
**Статус:** Stub (подлежит заполнению)
|
||||||
|
**Автор:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
_Описание контекста — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Варианты
|
||||||
|
|
||||||
|
_Описание вариантов — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
_Решение — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обоснование
|
||||||
|
|
||||||
|
_Обоснование — предстоит заполнить._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
_Последствия — предстоит заполнить._
|
||||||
371
docs/backend_spec.md
Normal file
371
docs/backend_spec.md
Normal 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 | 1–100 символов, обязателен |
|
||||||
|
|
||||||
|
**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
414
docs/tech_report.md
Normal 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-группу.
|
||||||
|
|
||||||
|
**Параметры нагрузки:** 300–400 зарегистрированных пользователей, одновременно нажимает максимум 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
575
docs/tech_research_raw.md
Normal 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
0
frontend/.gitkeep
Normal file
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-asyncio>=0.23
|
||||||
|
httpx>=0.27.0
|
||||||
|
respx>=0.21
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal 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
0
tests/__init__.py
Normal file
103
tests/conftest.py
Normal file
103
tests/conftest.py
Normal 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
248
tests/test_db.py
Normal 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
144
tests/test_models.py
Normal 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
105
tests/test_register.py
Normal 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
151
tests/test_signal.py
Normal 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
137
tests/test_structure.py
Normal 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
292
tests/test_telegram.py
Normal 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
115
tests/test_webhook.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue