Compare commits
11 commits
2c17ad4ddc
...
2ee953866b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee953866b | ||
|
|
f082c75ff8 | ||
|
|
12abac74f0 | ||
|
|
5435d2006f | ||
|
|
004c20585a | ||
|
|
aff655e73a | ||
|
|
8012cb1c0f | ||
|
|
59eb117589 | ||
|
|
f6f4300f73 | ||
|
|
bfa134e157 | ||
|
|
6dff5de077 |
22 changed files with 1905 additions and 15 deletions
|
|
@ -152,14 +152,14 @@ Telegram Backend SQLite
|
||||||
| Слой | Технология | ADR |
|
| Слой | Технология | ADR |
|
||||||
|------|-----------|-----|
|
|------|-----------|-----|
|
||||||
| Frontend | Vanilla JS (zero deps) | ADR-005 |
|
| Frontend | Vanilla JS (zero deps) | ADR-005 |
|
||||||
| Service Worker | Cache-first precache | ADR-002, ADR-006 |
|
| Service Worker | Cache-first precache | ADR-007, ADR-006 |
|
||||||
| Auth | UUID v4 + localStorage fallback | ADR-003 |
|
| Auth | UUID v4 + localStorage fallback | ADR-003 |
|
||||||
| Backend | FastAPI (Python 3.11+) | ADR-001 |
|
| Backend | FastAPI (Python 3.11+) | ADR-001 |
|
||||||
| Database | SQLite WAL + aiosqlite | ADR-001 |
|
| Database | SQLite WAL + aiosqlite | ADR-001 |
|
||||||
| Telegram | Direct sendMessage (v1) | ADR-004 |
|
| Telegram | Direct sendMessage (v1) | ADR-004 |
|
||||||
| TLS | Nginx + Let's Encrypt | — |
|
| TLS | Nginx + Let's Encrypt | — |
|
||||||
| i18n | English-only v1, deferred | ADR-005 |
|
| i18n | English-only v1, deferred | ADR-005 |
|
||||||
| Offline | Show error v1, IndexedDB v2 | ADR-006, ADR-002 |
|
| Offline | Show error v1, IndexedDB v2 | ADR-006, ADR-007 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -256,9 +256,12 @@ VPS (один сервер)
|
||||||
|
|
||||||
## Ссылки на ADR
|
## Ссылки на ADR
|
||||||
|
|
||||||
|
ADR-файлы хранятся в `docs/adr/`.
|
||||||
|
|
||||||
| ADR | Тема | Статус |
|
| ADR | Тема | Статус |
|
||||||
|-----|------|--------|
|
|-----|------|--------|
|
||||||
| [ADR-001](docs/adr/ADR-001-backend-stack.md) | Backend stack: FastAPI | Accepted |
|
| [ADR-001](docs/adr/ADR-001-backend-stack.md) | Backend stack: FastAPI | Accepted |
|
||||||
|
| [ADR-002](docs/adr/ADR-002-offline-pattern.md) | Offline pattern (v1): IndexedDB+BackgroundSync | Accepted |
|
||||||
| [ADR-007](docs/adr/ADR-007-offline-queue-v2.md) | Offline pattern: IndexedDB+BackgroundSync (v2) | Accepted |
|
| [ADR-007](docs/adr/ADR-007-offline-queue-v2.md) | Offline pattern: IndexedDB+BackgroundSync (v2) | Accepted |
|
||||||
| [ADR-003](docs/adr/ADR-003-auth-pattern.md) | Auth: UUID v4 + localStorage fallback | Accepted |
|
| [ADR-003](docs/adr/ADR-003-auth-pattern.md) | Auth: UUID v4 + localStorage fallback | Accepted |
|
||||||
| [ADR-004](docs/adr/ADR-004-telegram-strategy.md) | Telegram: direct sendMessage (v1) | Accepted |
|
| [ADR-004](docs/adr/ADR-004-telegram-strategy.md) | Telegram: direct sendMessage (v1) | Accepted |
|
||||||
|
|
|
||||||
37
README.md
37
README.md
|
|
@ -116,13 +116,44 @@ OnUnitActiveSec=10min
|
||||||
WantedBy=timers.target
|
WantedBy=timers.target
|
||||||
```
|
```
|
||||||
|
|
||||||
Активация:
|
Готовые файлы находятся в `deploy/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl daemon-reload
|
# 1. Скопировать файлы
|
||||||
systemctl enable --now baton-keepalive.timer
|
sudo cp deploy/baton-keepalive.service /etc/systemd/system/
|
||||||
|
sudo cp deploy/baton-keepalive.timer /etc/systemd/system/
|
||||||
|
|
||||||
|
# 2. Заменить URL на реальный
|
||||||
|
sudo sed -i 's|https://your-app.example.com|https://YOUR_APP_URL|g' \
|
||||||
|
/etc/systemd/system/baton-keepalive.service
|
||||||
|
|
||||||
|
# 3. Включить и запустить
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now baton-keepalive.timer
|
||||||
|
|
||||||
|
# 4. Проверить
|
||||||
systemctl list-timers baton-keepalive.timer
|
systemctl list-timers baton-keepalive.timer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Keep-alive через UptimeRobot (внешний сервис, рекомендуется)
|
||||||
|
|
||||||
|
[UptimeRobot](https://uptimerobot.com) — бесплатный сервис мониторинга, который пингует ваш `/health` снаружи каждые 5 минут. В отличие от self-ping, он работает даже если платформа убила процесс.
|
||||||
|
|
||||||
|
**Настройка (бесплатно, без регистрации кредитной карты):**
|
||||||
|
|
||||||
|
1. Зарегистрируйтесь на [uptimerobot.com](https://uptimerobot.com)
|
||||||
|
2. **Add New Monitor** → тип **HTTP(s)**
|
||||||
|
3. Заполните:
|
||||||
|
- **Friendly Name:** `Baton Health`
|
||||||
|
- **URL:** `https://your-app.example.com/health`
|
||||||
|
- **Monitoring Interval:** `5 minutes`
|
||||||
|
4. Сохраните. UptimeRobot начнёт пинговать каждые 5 минут и пришлёт email при падении.
|
||||||
|
|
||||||
|
**Плюсы:** работает независимо от хостинга, бесплатно до 50 мониторов, email/Telegram-уведомления.
|
||||||
|
**Минусы:** требует публичный URL (для локальной разработки не подходит).
|
||||||
|
|
||||||
|
> **Рекомендация:** для прода используйте UptimeRobot как внешний watchdog + self-ping (APP_URL) как запасной вариант.
|
||||||
|
|
||||||
## Nginx deployment
|
## Nginx deployment
|
||||||
|
|
||||||
Для проксирования через nginx используйте готовый шаблон `nginx/baton.conf`.
|
Для проксирования через nginx используйте готовый шаблон `nginx/baton.conf`.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from backend import config, db, telegram
|
from backend import config, db, telegram
|
||||||
from backend.middleware import verify_webhook_secret
|
from backend.middleware import rate_limit_register, verify_webhook_secret
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
|
|
@ -45,6 +45,7 @@ async def _keep_alive_loop(app_url: str) -> None:
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
|
app.state.rate_counters = {}
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
logger.info("Database initialized")
|
logger.info("Database initialized")
|
||||||
|
|
||||||
|
|
@ -100,7 +101,7 @@ async def health() -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/register", response_model=RegisterResponse)
|
@app.post("/api/register", response_model=RegisterResponse)
|
||||||
async def register(body: RegisterRequest) -> RegisterResponse:
|
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
|
||||||
result = await db.register_user(uuid=body.uuid, name=body.name)
|
result = await db.register_user(uuid=body.uuid, name=body.name)
|
||||||
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
|
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,34 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import Header, HTTPException
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException, Request
|
||||||
|
|
||||||
from backend import config
|
from backend import config
|
||||||
|
|
||||||
|
_RATE_LIMIT = 5
|
||||||
|
_RATE_WINDOW = 600 # 10 minutes
|
||||||
|
|
||||||
|
|
||||||
async def verify_webhook_secret(
|
async def verify_webhook_secret(
|
||||||
x_telegram_bot_api_secret_token: str = Header(default=""),
|
x_telegram_bot_api_secret_token: str = Header(default=""),
|
||||||
) -> None:
|
) -> None:
|
||||||
if x_telegram_bot_api_secret_token != config.WEBHOOK_SECRET:
|
if not secrets.compare_digest(
|
||||||
|
x_telegram_bot_api_secret_token, config.WEBHOOK_SECRET
|
||||||
|
):
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_register(request: Request) -> None:
|
||||||
|
counters = request.app.state.rate_counters
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
now = time.time()
|
||||||
|
count, window_start = counters.get(client_ip, (0, now))
|
||||||
|
if now - window_start >= _RATE_WINDOW:
|
||||||
|
count = 0
|
||||||
|
window_start = now
|
||||||
|
count += 1
|
||||||
|
counters[client_ip] = (count, window_start)
|
||||||
|
if count > _RATE_LIMIT:
|
||||||
|
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||||
|
|
|
||||||
10
deploy/baton-keepalive.service
Normal file
10
deploy/baton-keepalive.service
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Baton keep-alive ping
|
||||||
|
# Запускается baton-keepalive.timer, не вручную
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Замените URL на реальный адрес вашего приложения
|
||||||
|
ExecStart=curl -sf https://your-app.example.com/health
|
||||||
|
StandardOutput=null
|
||||||
|
StandardError=journal
|
||||||
11
deploy/baton-keepalive.timer
Normal file
11
deploy/baton-keepalive.timer
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Run Baton keep-alive every 10 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Первый запуск через 1 минуту после загрузки системы
|
||||||
|
OnBootSec=1min
|
||||||
|
# Затем каждые 10 минут
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
41
docs/adr/ADR-002-offline-pattern.md
Normal file
41
docs/adr/ADR-002-offline-pattern.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# ADR-002: Паттерн офлайн-очереди
|
||||||
|
|
||||||
|
**Дата:** 2026-03-20
|
||||||
|
**Статус:** Accepted
|
||||||
|
**Автор:** Architect Agent (Kin pipeline, BATON-001)
|
||||||
|
**Решения:** #1001, #1003, #1006
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Baton — приложение экстренного сигнала. Критичное требование: сигнал не должен быть потерян, если пользователь нажал кнопку в момент отсутствия сети (тоннель, слабый сигнал, офлайн).
|
||||||
|
|
||||||
|
Данный ADR фиксирует исходное архитектурное решение по паттерну офлайн-очереди. Актуальная реализация с деталями вариантов — в ADR-007.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
**IndexedDB outbox + BackgroundSync + online event fallback**
|
||||||
|
|
||||||
|
1. Кнопка нажата → немедленная попытка `fetch('/api/signal')`
|
||||||
|
2. Ошибка или offline → запись в IndexedDB outbox
|
||||||
|
3. Trigger 1: `window.addEventListener('online', flushOutbox)` — main thread, все браузеры
|
||||||
|
4. Trigger 2: SW регистрирует `registration.sync.register('flush-outbox')` — Chromium только
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обоснование
|
||||||
|
|
||||||
|
- **#1006:** IndexedDB — единственный вариант, доступный и в main thread, и в Service Worker. Общее хранилище исключает дублирование кода между `app.js` и `sw.js`.
|
||||||
|
- **#1003:** localStorage в iOS Safari приватном режиме бросает `SecurityError` → не подходит для надёжного офлайн-хранилища в приложении экстренного сигнала.
|
||||||
|
- **BackgroundSync (#1001):** браузер управляет повтором, flush возможен даже при закрытой вкладке (Chrome). Dual trigger (BackgroundSync + online event) страхует пользователей Safari/Firefox.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
**Вопрос о покрытии BackgroundSync:** Решение #1001 фиксирует охват BackgroundSync как ~85%. Актуальные данные caniuse (март 2026) показывают 78.75% — Safari и Firefox не поддерживают API. Это влияет на описание архитектурных гарантий: ручной online-fallback является **обязательным**, а не опциональным элементом.
|
||||||
|
|
||||||
|
ACTION: Обновить решение #1001 — изменить охват BackgroundSync с 85% до 78.75%, пометить ручной fallback как обязательный элемент архитектуры (конвенция #1049).
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
| Группа | 20 msg/минуту |
|
| Группа | 20 msg/минуту |
|
||||||
| Глобально | ~30 msg/сек |
|
| Глобально | ~30 msg/сек |
|
||||||
|
|
||||||
При превышении: HTTP 429 + `parameters.retry_after`.
|
При превышении: HTTP 429 + `parameters.retry_after`. При последовательных 429 рекомендуется exponential backoff согласно решению #1046.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
- localStorage недоступен в Service Worker контексте
|
- localStorage недоступен в Service Worker контексте
|
||||||
- При закрытии вкладки до online event — сигнал не отправлен (но сохранён)
|
- При закрытии вкладки до online event — сигнал не отправлен (но сохранён)
|
||||||
|
|
||||||
#### Вариант C: IndexedDB + BackgroundSync (ADR-002 plan)
|
#### Вариант C: IndexedDB + BackgroundSync (ADR-007 plan)
|
||||||
|
|
||||||
**Плюсы:**
|
**Плюсы:**
|
||||||
- Самое надёжное: IndexedDB доступен из SW, BackgroundSync работает даже при закрытой вкладке (Chromium)
|
- Самое надёжное: IndexedDB доступен из SW, BackgroundSync работает даже при закрытой вкладке (Chromium)
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
**Выбран Вариант A для v1 (#1019): показать ошибку, нет retry.**
|
**Выбран Вариант A для v1 (#1019): показать ошибку, нет retry.**
|
||||||
|
|
||||||
Переход на Вариант C (IndexedDB + BackgroundSync) запланирован для v2 (полная спека в ADR-002).
|
Переход на Вариант C (IndexedDB + BackgroundSync) запланирован для v2 (полная спека в ADR-007).
|
||||||
|
|
||||||
### Обоснование (offline)
|
### Обоснование (offline)
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
```
|
```
|
||||||
Даже если `navigator.onLine` ненадёжен — try/catch ловит ошибку fetch.
|
Даже если `navigator.onLine` ненадёжен — try/catch ловит ошибку fetch.
|
||||||
|
|
||||||
4. **Вариант C (IndexedDB + BackgroundSync) — зарезервирован для v2.** ADR-002 содержит полную спецификацию. Переход потребует ~4 часа dev-работы.
|
4. **Вариант C (IndexedDB + BackgroundSync) — зарезервирован для v2.** ADR-007 содержит полную спецификацию. Переход потребует ~4 часа dev-работы.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,4 +120,4 @@ Baton — приложение экстренного сигнала. Крити
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Решение #1001 требует обновления:** фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры.
|
6. **ACTION: Обновить решение #1001** — изменить охват BackgroundSync с 85% до 78.75%, пометить ручной fallback как обязательный (не опциональный) элемент архитектуры.
|
||||||
|
|
|
||||||
264
frontend/app.js
Normal file
264
frontend/app.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ========== Storage abstraction (decisions #1024, #1025) ==========
|
||||||
|
//
|
||||||
|
// #1024: probe availability with a real write, NOT typeof/in
|
||||||
|
// #1025: fallback chain: localStorage → sessionStorage → in-memory
|
||||||
|
|
||||||
|
let _storage = null;
|
||||||
|
let _storageType = 'memory';
|
||||||
|
const _mem = {};
|
||||||
|
|
||||||
|
function _probeStorage(s) {
|
||||||
|
try {
|
||||||
|
const k = '__baton_probe__';
|
||||||
|
s.setItem(k, '1');
|
||||||
|
s.removeItem(k);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initStorage() {
|
||||||
|
if (_probeStorage(localStorage)) {
|
||||||
|
_storage = localStorage;
|
||||||
|
_storageType = 'local';
|
||||||
|
} else if (_probeStorage(sessionStorage)) {
|
||||||
|
_storage = sessionStorage;
|
||||||
|
_storageType = 'session';
|
||||||
|
} else {
|
||||||
|
_storage = {
|
||||||
|
getItem: (k) => (Object.prototype.hasOwnProperty.call(_mem, k) ? _mem[k] : null),
|
||||||
|
setItem: (k, v) => { _mem[k] = String(v); },
|
||||||
|
removeItem: (k) => { delete _mem[k]; },
|
||||||
|
};
|
||||||
|
_storageType = 'memory';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== User identity ==========
|
||||||
|
|
||||||
|
function _getOrCreateUserId() {
|
||||||
|
let id = _storage.getItem('baton_user_id');
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
_storage.setItem('baton_user_id', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isRegistered() {
|
||||||
|
return _storage.getItem('baton_registered') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getUserName() {
|
||||||
|
return _storage.getItem('baton_user_name') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveRegistration(name) {
|
||||||
|
_storage.setItem('baton_user_name', name);
|
||||||
|
_storage.setItem('baton_registered', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getInitials(name) {
|
||||||
|
const trimmed = (name || '').trim();
|
||||||
|
if (!trimmed) return '?';
|
||||||
|
return trimmed
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w) => w[0] || '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UI helpers ==========
|
||||||
|
|
||||||
|
function _showScreen(id) {
|
||||||
|
document.querySelectorAll('.screen').forEach((el) => {
|
||||||
|
el.hidden = el.id !== id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setStatus(msg, cls) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'status' + (cls ? ' status--' + cls : '');
|
||||||
|
el.hidden = !msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateNetworkIndicator() {
|
||||||
|
const el = document.getElementById('indicator-network');
|
||||||
|
if (!el) return;
|
||||||
|
el.className = 'network-indicator ' + (navigator.onLine ? 'online' : 'offline');
|
||||||
|
el.setAttribute('aria-label', navigator.onLine ? 'Online' : 'Offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateUserAvatar() {
|
||||||
|
const el = document.getElementById('user-avatar');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = _getInitials(_getUserName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== API calls ==========
|
||||||
|
|
||||||
|
async function _apiPost(path, body) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Geolocation (optional, non-blocking) ==========
|
||||||
|
|
||||||
|
function _getGeo() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!navigator.geolocation) { resolve(null); return; }
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
({ coords }) =>
|
||||||
|
resolve({
|
||||||
|
lat: coords.latitude,
|
||||||
|
lon: coords.longitude,
|
||||||
|
accuracy: coords.accuracy,
|
||||||
|
}),
|
||||||
|
() => resolve(null),
|
||||||
|
{ timeout: 5000, maximumAge: 30000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Handlers ==========
|
||||||
|
|
||||||
|
async function _handleRegister() {
|
||||||
|
const input = document.getElementById('name-input');
|
||||||
|
const btn = document.getElementById('btn-confirm');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
_setStatus('', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uuid = _getOrCreateUserId();
|
||||||
|
await _apiPost('/api/register', { uuid, name });
|
||||||
|
_saveRegistration(name);
|
||||||
|
_updateUserAvatar();
|
||||||
|
_showMain();
|
||||||
|
} catch (_) {
|
||||||
|
_setStatus('Error. Please try again.', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setSosState(state) {
|
||||||
|
const btn = document.getElementById('btn-sos');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.dataset.state = state;
|
||||||
|
btn.disabled = state === 'sending';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _handleSignal() {
|
||||||
|
// v1: no offline queue — show error and return (decision #1019)
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
_setStatus('No connection. Check your network and try again.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setSosState('sending');
|
||||||
|
_setStatus('', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geo = await _getGeo();
|
||||||
|
const uuid = _getOrCreateUserId();
|
||||||
|
const body = { user_id: uuid, timestamp: Date.now() };
|
||||||
|
if (geo) body.geo = geo;
|
||||||
|
|
||||||
|
await _apiPost('/api/signal', body);
|
||||||
|
|
||||||
|
_setSosState('success');
|
||||||
|
_setStatus('Signal sent!', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
_setSosState('default');
|
||||||
|
_setStatus('', '');
|
||||||
|
}, 2000);
|
||||||
|
} catch (_) {
|
||||||
|
_setSosState('default');
|
||||||
|
_setStatus('Error sending. Try again.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Screens ==========
|
||||||
|
|
||||||
|
function _showOnboarding() {
|
||||||
|
_showScreen('screen-onboarding');
|
||||||
|
|
||||||
|
const input = document.getElementById('name-input');
|
||||||
|
const btn = document.getElementById('btn-confirm');
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
btn.disabled = input.value.trim().length === 0;
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !btn.disabled) _handleRegister();
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', _handleRegister);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showMain() {
|
||||||
|
_showScreen('screen-main');
|
||||||
|
_updateUserAvatar();
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-sos');
|
||||||
|
if (btn && !btn.dataset.listenerAttached) {
|
||||||
|
btn.addEventListener('click', _handleSignal);
|
||||||
|
btn.dataset.listenerAttached = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Service Worker ==========
|
||||||
|
|
||||||
|
function _registerSW() {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
||||||
|
console.warn('[baton] SW registration failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Init ==========
|
||||||
|
|
||||||
|
function _init() {
|
||||||
|
_initStorage();
|
||||||
|
|
||||||
|
// Pre-generate and persist UUID on first visit (per arch spec flow)
|
||||||
|
_getOrCreateUserId();
|
||||||
|
|
||||||
|
// Private mode graceful degradation (decision #1041):
|
||||||
|
// show inline banner with explicit action guidance when localStorage is unavailable
|
||||||
|
if (_storageType !== 'local') {
|
||||||
|
const banner = document.getElementById('private-mode-banner');
|
||||||
|
if (banner) banner.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network indicator — initial state + live updates
|
||||||
|
_updateNetworkIndicator();
|
||||||
|
window.addEventListener('online', _updateNetworkIndicator);
|
||||||
|
window.addEventListener('offline', _updateNetworkIndicator);
|
||||||
|
|
||||||
|
// Route to correct screen
|
||||||
|
if (_isRegistered()) {
|
||||||
|
_showMain();
|
||||||
|
} else {
|
||||||
|
_showOnboarding();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
_registerSW();
|
||||||
|
_init();
|
||||||
|
});
|
||||||
BIN
frontend/icons/icon-192.png
Normal file
BIN
frontend/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 B |
BIN
frontend/icons/icon-512.png
Normal file
BIN
frontend/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
78
frontend/index.html
Normal file
78
frontend/index.html
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="description" content="Emergency signal button">
|
||||||
|
|
||||||
|
<!-- PWA meta tags -->
|
||||||
|
<meta name="theme-color" content="#ff0000">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Baton">
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
|
||||||
|
<title>Baton</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Private mode banner: shown when localStorage is unavailable (decision #1041).
|
||||||
|
Explicit action guidance: user must open in normal mode to retain registration. -->
|
||||||
|
<div id="private-mode-banner" class="private-banner" hidden>
|
||||||
|
⚠️ Private mode detected. Your registration will be lost when this tab
|
||||||
|
closes. To keep your data, open Baton in a regular (non-private) browser tab.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared top bar: user initials avatar + network status indicator -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="user-avatar" id="user-avatar" aria-label="User">?</div>
|
||||||
|
<div class="network-indicator" id="indicator-network"
|
||||||
|
role="status" aria-label="Network status"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
|
||||||
|
<div id="screen-onboarding" class="screen" role="main" hidden>
|
||||||
|
<div class="screen-content">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name-input"
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Your name"
|
||||||
|
maxlength="100"
|
||||||
|
autocomplete="name"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="words"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="Your name"
|
||||||
|
>
|
||||||
|
<button type="button" id="btn-confirm" class="btn-confirm" disabled>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main screen: SOS button -->
|
||||||
|
<div id="screen-main" class="screen" role="main" hidden>
|
||||||
|
<div class="screen-content">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="btn-sos"
|
||||||
|
class="btn-sos"
|
||||||
|
data-state="default"
|
||||||
|
aria-label="Send emergency signal"
|
||||||
|
>
|
||||||
|
SOS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status message: errors and confirmations -->
|
||||||
|
<div id="status" class="status" role="alert" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
frontend/manifest.json
Normal file
23
frontend/manifest.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "Baton",
|
||||||
|
"short_name": "Baton",
|
||||||
|
"description": "Emergency signal button",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"start_url": "/",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#ff0000",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
200
frontend/style.css
Normal file
200
frontend/style.css
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #000000;
|
||||||
|
--text: #ffffff;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--input-bg: #1a1a1a;
|
||||||
|
--border: #374151;
|
||||||
|
--border-focus: #6b7280;
|
||||||
|
--confirm-bg: #374151;
|
||||||
|
--confirm-active: #4b5563;
|
||||||
|
--sos: #cc2626;
|
||||||
|
--sos-pressed: #991c1c;
|
||||||
|
--sos-success: #16a34a;
|
||||||
|
--banner-bg: #7c2d12;
|
||||||
|
--banner-text: #fed7aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
/* Use dynamic viewport height on mobile to account for browser chrome */
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Private mode banner (decision #1041) ===== */
|
||||||
|
|
||||||
|
.private-banner {
|
||||||
|
background: var(--banner-bg);
|
||||||
|
color: var(--banner-text);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.private-banner[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* ===== Top bar ===== */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #374151;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-indicator {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-indicator.online { background: #16a34a; }
|
||||||
|
.network-indicator.offline { background: #4b5563; }
|
||||||
|
|
||||||
|
/* ===== Screens ===== */
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen[hidden] { display: none; }
|
||||||
|
|
||||||
|
.screen-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Onboarding ===== */
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 17px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input::placeholder { color: var(--muted); }
|
||||||
|
.name-input:focus { border-color: var(--border-focus); }
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--confirm-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.btn-confirm:not(:disabled):active { background: var(--confirm-active); }
|
||||||
|
|
||||||
|
/* ===== SOS button (min 60vmin × 60vmin per UX spec) ===== */
|
||||||
|
|
||||||
|
.btn-sos {
|
||||||
|
width: 60vmin;
|
||||||
|
height: 60vmin;
|
||||||
|
min-width: 180px;
|
||||||
|
min-height: 180px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--sos);
|
||||||
|
color: #fff;
|
||||||
|
font-size: clamp(24px, 9vmin, 60px);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sos:active,
|
||||||
|
.btn-sos[data-state="sending"] {
|
||||||
|
background: var(--sos-pressed);
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sos[data-state="sending"] {
|
||||||
|
animation: baton-pulse 1s ease-in-out infinite;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sos[data-state="success"] {
|
||||||
|
background: var(--sos-success);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes baton-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.55; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Status message ===== */
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[hidden] { display: none; }
|
||||||
|
.status--error { color: #f87171; }
|
||||||
|
.status--success { color: #4ade80; }
|
||||||
60
frontend/sw.js
Normal file
60
frontend/sw.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CACHE_NAME = 'baton-v1';
|
||||||
|
|
||||||
|
// App shell assets to precache
|
||||||
|
const APP_SHELL = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/app.js',
|
||||||
|
'/style.css',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install: precache app shell + skipWaiting so new SW activates immediately
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate: delete stale caches + claim open clients (decision: skipWaiting + clients.claim)
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch: cache-first for app shell; API calls pass through to network
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Never intercept API calls — they must always reach the server
|
||||||
|
if (url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
if (response && response.status === 200) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
176
tests/test_arch_004.py
Normal file
176
tests/test_arch_004.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-ARCH-004: Переименование ADR-002-offline-pattern.md.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. No file named ADR-002-offline-pattern*.md exists in docs/adr/.
|
||||||
|
2. No references to 'ADR-002-offline-pattern' anywhere in docs/ and ARCHITECTURE.md.
|
||||||
|
3. No dangling bare 'ADR-002' references in docs/, ARCHITECTURE.md, or tests/.
|
||||||
|
4. ADR-007-offline-queue-v2.md exists in docs/adr/.
|
||||||
|
5. tech_report.md references ADR-007 (not ADR-002).
|
||||||
|
6. ADR-006 references ADR-007 (not ADR-002).
|
||||||
|
7. ARCHITECTURE.md references ADR-007 (not ADR-002) for offline-related rows.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
ADR_DIR = PROJECT_ROOT / "docs" / "adr"
|
||||||
|
ARCHITECTURE_MD = PROJECT_ROOT / "ARCHITECTURE.md"
|
||||||
|
TECH_REPORT_MD = PROJECT_ROOT / "docs" / "tech_report.md"
|
||||||
|
ADR_006 = ADR_DIR / "ADR-006-offline-ios-constraints.md"
|
||||||
|
ADR_007 = ADR_DIR / "ADR-007-offline-queue-v2.md"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — old ADR-002-offline-pattern file must not exist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Criterion 1 superseded by BATON-ARCH-014: ADR-002-offline-pattern.md now exists
|
||||||
|
# as a legitimate new ADR document.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — no stale 'ADR-002-offline-pattern' textual references in docs/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _all_md_in_docs() -> list[Path]:
|
||||||
|
return list((PROJECT_ROOT / "docs").rglob("*.md"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_adr_002_offline_pattern_in_docs() -> None:
|
||||||
|
"""Ни один файл в docs/ не должен содержать строку 'ADR-002-offline-pattern'."""
|
||||||
|
for path in _all_md_in_docs():
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
assert "ADR-002-offline-pattern" not in content, (
|
||||||
|
f"Найдена устаревшая ссылка 'ADR-002-offline-pattern' в {path.relative_to(PROJECT_ROOT)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# test_no_adr_002_offline_pattern_in_architecture_md superseded by BATON-ARCH-014:
|
||||||
|
# ARCHITECTURE.md now legitimately links to ADR-002-offline-pattern.md.
|
||||||
|
# test_no_bare_adr_002_in_docs superseded: ADR-002-offline-pattern.md is a valid new ADR.
|
||||||
|
# test_no_bare_adr_002_in_architecture_md superseded: [ADR-002] is now a valid table row.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — no dangling bare ADR-002 references in test files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_bare_adr_002_in_tests() -> None:
|
||||||
|
"""Файлы тестов (кроме легитимных исключений) не должны содержать голую метку 'ADR-002'."""
|
||||||
|
pattern = re.compile(r"\bADR-002\b")
|
||||||
|
# Легитимные исключения: файлы, документирующие задачи, которые явно работают с ADR-002.
|
||||||
|
_ALLOWED = {
|
||||||
|
Path(__file__).resolve(), # test_arch_004.py: задача по переименованию
|
||||||
|
(PROJECT_ROOT / "tests" / "test_arch_014.py").resolve(), # задача по созданию ADR-002
|
||||||
|
}
|
||||||
|
for path in (PROJECT_ROOT / "tests").glob("*.py"):
|
||||||
|
if path.resolve() in _ALLOWED:
|
||||||
|
continue
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
assert not pattern.search(content), (
|
||||||
|
f"Найдена висячая ссылка 'ADR-002' в {path.relative_to(PROJECT_ROOT)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — ADR-007-offline-queue-v2.md exists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_007_offline_queue_file_exists() -> None:
|
||||||
|
"""Файл ADR-007-offline-queue-v2.md должен существовать в docs/adr/."""
|
||||||
|
assert ADR_007.is_file(), (
|
||||||
|
f"Переименованный файл ADR-007-offline-queue-v2.md не найден в {ADR_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 — tech_report.md references ADR-007
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tech_report_references_adr_007() -> None:
|
||||||
|
"""docs/tech_report.md должен содержать ссылку на ADR-007."""
|
||||||
|
content = TECH_REPORT_MD.read_text(encoding="utf-8")
|
||||||
|
assert "ADR-007" in content, (
|
||||||
|
"tech_report.md не ссылается на ADR-007 (переименованный offline-pattern)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 6 — ADR-006 references ADR-007 (not ADR-002)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_006_references_adr_007() -> None:
|
||||||
|
"""ADR-006-offline-ios-constraints.md должен ссылаться на ADR-007."""
|
||||||
|
content = ADR_006.read_text(encoding="utf-8")
|
||||||
|
assert "ADR-007" in content, (
|
||||||
|
"ADR-006 не содержит ссылки на ADR-007"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_006_has_no_adr_002_references() -> None:
|
||||||
|
"""ADR-006-offline-ios-constraints.md не должен ссылаться на ADR-002."""
|
||||||
|
content = ADR_006.read_text(encoding="utf-8")
|
||||||
|
assert not re.search(r"\bADR-002\b", content), (
|
||||||
|
"ADR-006 всё ещё содержит ссылку 'ADR-002'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 7 — ARCHITECTURE.md references ADR-007 for offline rows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_md_references_adr_007_for_service_worker() -> None:
|
||||||
|
"""ARCHITECTURE.md должен ссылаться на ADR-007 в строке Service Worker."""
|
||||||
|
content = ARCHITECTURE_MD.read_text(encoding="utf-8")
|
||||||
|
sw_line = next(
|
||||||
|
(line for line in content.splitlines() if "Service Worker" in line), None
|
||||||
|
)
|
||||||
|
assert sw_line is not None, "Строка 'Service Worker' не найдена в ARCHITECTURE.md"
|
||||||
|
assert "ADR-007" in sw_line, (
|
||||||
|
f"Строка Service Worker в ARCHITECTURE.md не содержит ADR-007: {sw_line!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_md_references_adr_007_for_offline() -> None:
|
||||||
|
"""ARCHITECTURE.md должен ссылаться на ADR-007 в строке Offline."""
|
||||||
|
content = ARCHITECTURE_MD.read_text(encoding="utf-8")
|
||||||
|
offline_line = next(
|
||||||
|
(line for line in content.splitlines() if line.startswith("| Offline")), None
|
||||||
|
)
|
||||||
|
assert offline_line is not None, "Строка '| Offline' не найдена в ARCHITECTURE.md"
|
||||||
|
assert "ADR-007" in offline_line, (
|
||||||
|
f"Строка Offline в ARCHITECTURE.md не содержит ADR-007: {offline_line!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 8 — ADR-007 строка, помечающая #1001 устаревшим, содержит ACTION:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_007_stale_reference_has_action_item() -> None:
|
||||||
|
"""Строка ADR-007, ссылающаяся на решение #1001, должна содержать маркер ACTION:.
|
||||||
|
|
||||||
|
Конвенция #1049: все ссылки на устаревшие решения обязаны быть оформлены как
|
||||||
|
явный ACTION item, а не как пассивная заметка.
|
||||||
|
"""
|
||||||
|
content = ADR_007.read_text(encoding="utf-8")
|
||||||
|
lines_with_1001 = [line for line in content.splitlines() if "#1001" in line]
|
||||||
|
assert lines_with_1001, (
|
||||||
|
"ADR-007 не содержит ни одной строки со ссылкой на решение #1001"
|
||||||
|
)
|
||||||
|
has_action = any(re.search(r"ACTION:", line) for line in lines_with_1001)
|
||||||
|
assert has_action, (
|
||||||
|
"Строка, помечающая решение #1001 устаревшим, не содержит явного маркера ACTION: "
|
||||||
|
"— нарушение конвенции #1049"
|
||||||
|
)
|
||||||
542
tests/test_arch_009.py
Normal file
542
tests/test_arch_009.py
Normal file
|
|
@ -0,0 +1,542 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-ARCH-009: PWA frontend (manifest + SW + UUID auth + SOS button).
|
||||||
|
|
||||||
|
Acceptance criteria verified:
|
||||||
|
1. manifest.json — required fields, icons 192+512, display:standalone, start_url=/
|
||||||
|
2. SW — cache-first, skipWaiting, clients.claim, API bypass
|
||||||
|
3. Onboarding — crypto.randomUUID(), POST /api/register, UUID saved to storage
|
||||||
|
4. Storage fallback — real write probe, chain localStorage→sessionStorage→in-memory
|
||||||
|
5. Private mode banner — present in HTML with explicit instruction text
|
||||||
|
6. Main screen — SOS button sends POST /api/signal with UUID
|
||||||
|
7. Offline — error shown to user when navigator.onLine === false
|
||||||
|
8. Structure — all 7 required frontend files exist
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
FRONTEND = PROJECT_ROOT / "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 8 — File structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
REQUIRED_FRONTEND_FILES = [
|
||||||
|
"frontend/index.html",
|
||||||
|
"frontend/app.js",
|
||||||
|
"frontend/style.css",
|
||||||
|
"frontend/sw.js",
|
||||||
|
"frontend/manifest.json",
|
||||||
|
"frontend/icons/icon-192.png",
|
||||||
|
"frontend/icons/icon-512.png",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rel_path", REQUIRED_FRONTEND_FILES)
|
||||||
|
def test_frontend_file_exists(rel_path: str) -> None:
|
||||||
|
"""Every required frontend file must exist on disk."""
|
||||||
|
assert (PROJECT_ROOT / rel_path).is_file(), f"Required file missing: {rel_path}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — manifest.json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest() -> dict:
|
||||||
|
return json.loads((FRONTEND / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_is_valid_json() -> None:
|
||||||
|
"""manifest.json must be parseable as JSON."""
|
||||||
|
_manifest() # raises if invalid
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_has_name() -> None:
|
||||||
|
"""manifest.json must have a non-empty 'name' field."""
|
||||||
|
m = _manifest()
|
||||||
|
assert m.get("name"), "manifest.json missing 'name'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_display_standalone() -> None:
|
||||||
|
"""manifest.json must have display: 'standalone'."""
|
||||||
|
assert _manifest().get("display") == "standalone"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_start_url_root() -> None:
|
||||||
|
"""manifest.json must have start_url: '/'."""
|
||||||
|
assert _manifest().get("start_url") == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_has_icon_192() -> None:
|
||||||
|
"""manifest.json must include a 192x192 icon entry."""
|
||||||
|
icons = _manifest().get("icons", [])
|
||||||
|
sizes = [icon.get("sizes") for icon in icons]
|
||||||
|
assert "192x192" in sizes, f"No 192x192 icon in manifest icons: {sizes}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_has_icon_512() -> None:
|
||||||
|
"""manifest.json must include a 512x512 icon entry."""
|
||||||
|
icons = _manifest().get("icons", [])
|
||||||
|
sizes = [icon.get("sizes") for icon in icons]
|
||||||
|
assert "512x512" in sizes, f"No 512x512 icon in manifest icons: {sizes}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_icon_entries_have_src_and_type() -> None:
|
||||||
|
"""Every icon in manifest.json must have 'src' and 'type' fields."""
|
||||||
|
for icon in _manifest().get("icons", []):
|
||||||
|
assert icon.get("src"), f"Icon missing 'src': {icon}"
|
||||||
|
assert icon.get("type"), f"Icon missing 'type': {icon}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_icon_192_src_points_to_png() -> None:
|
||||||
|
"""The 192x192 manifest icon src must point to a .png file."""
|
||||||
|
icons = _manifest().get("icons", [])
|
||||||
|
icon_192 = next((i for i in icons if i.get("sizes") == "192x192"), None)
|
||||||
|
assert icon_192 is not None
|
||||||
|
assert icon_192["src"].endswith(".png"), f"192 icon src is not .png: {icon_192['src']}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_icon_512_src_points_to_png() -> None:
|
||||||
|
"""The 512x512 manifest icon src must point to a .png file."""
|
||||||
|
icons = _manifest().get("icons", [])
|
||||||
|
icon_512 = next((i for i in icons if i.get("sizes") == "512x512"), None)
|
||||||
|
assert icon_512 is not None
|
||||||
|
assert icon_512["src"].endswith(".png"), f"512 icon src is not .png: {icon_512['src']}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PNG icon validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _png_dimensions(path: Path) -> tuple[int, int]:
|
||||||
|
"""Return (width, height) from a PNG file's IHDR chunk."""
|
||||||
|
data = path.read_bytes()
|
||||||
|
assert data[:8] == PNG_SIGNATURE, f"Not a valid PNG: {path}"
|
||||||
|
# IHDR chunk: 4 bytes length + 4 bytes 'IHDR' + 4 bytes width + 4 bytes height
|
||||||
|
width = struct.unpack(">I", data[16:20])[0]
|
||||||
|
height = struct.unpack(">I", data[20:24])[0]
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_192_is_valid_png() -> None:
|
||||||
|
"""icons/icon-192.png must be a valid PNG file."""
|
||||||
|
path = FRONTEND / "icons" / "icon-192.png"
|
||||||
|
data = path.read_bytes()
|
||||||
|
assert data[:8] == PNG_SIGNATURE, "icon-192.png is not a valid PNG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_192_has_correct_dimensions() -> None:
|
||||||
|
"""icons/icon-192.png must be exactly 192x192 pixels."""
|
||||||
|
w, h = _png_dimensions(FRONTEND / "icons" / "icon-192.png")
|
||||||
|
assert w == 192, f"icon-192.png width is {w}, expected 192"
|
||||||
|
assert h == 192, f"icon-192.png height is {h}, expected 192"
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_512_is_valid_png() -> None:
|
||||||
|
"""icons/icon-512.png must be a valid PNG file."""
|
||||||
|
path = FRONTEND / "icons" / "icon-512.png"
|
||||||
|
data = path.read_bytes()
|
||||||
|
assert data[:8] == PNG_SIGNATURE, "icon-512.png is not a valid PNG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_512_has_correct_dimensions() -> None:
|
||||||
|
"""icons/icon-512.png must be exactly 512x512 pixels."""
|
||||||
|
w, h = _png_dimensions(FRONTEND / "icons" / "icon-512.png")
|
||||||
|
assert w == 512, f"icon-512.png width is {w}, expected 512"
|
||||||
|
assert h == 512, f"icon-512.png height is {h}, expected 512"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# index.html — PWA meta tags and DOM structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _html() -> str:
|
||||||
|
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_manifest_link() -> None:
|
||||||
|
"""index.html must link to /manifest.json."""
|
||||||
|
assert 'href="/manifest.json"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_theme_color_meta() -> None:
|
||||||
|
"""index.html must have theme-color meta tag."""
|
||||||
|
assert 'name="theme-color"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_apple_mobile_web_app_capable() -> None:
|
||||||
|
"""index.html must have apple-mobile-web-app-capable meta tag."""
|
||||||
|
assert "apple-mobile-web-app-capable" in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_viewport_meta() -> None:
|
||||||
|
"""index.html must have a viewport meta tag."""
|
||||||
|
assert 'name="viewport"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_private_mode_banner() -> None:
|
||||||
|
"""index.html must contain the private mode banner element."""
|
||||||
|
assert 'id="private-mode-banner"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_private_mode_banner_has_instruction() -> None:
|
||||||
|
"""Private mode banner must contain explicit instruction text."""
|
||||||
|
html = _html()
|
||||||
|
# Find banner content — check for key guidance phrases
|
||||||
|
assert "private" in html.lower() or "Private" in html, \
|
||||||
|
"Banner must reference private mode"
|
||||||
|
# The banner must mention what to do (open in normal tab)
|
||||||
|
assert "regular" in html or "normal" in html or "обычн" in html, \
|
||||||
|
"Banner must instruct user to open in a regular (non-private) tab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_sos_button() -> None:
|
||||||
|
"""index.html must have a SOS button (id=btn-sos)."""
|
||||||
|
assert 'id="btn-sos"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_screen_onboarding() -> None:
|
||||||
|
"""index.html must have the onboarding screen element."""
|
||||||
|
assert 'id="screen-onboarding"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_screen_main() -> None:
|
||||||
|
"""index.html must have the main screen element."""
|
||||||
|
assert 'id="screen-main"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_loads_app_js() -> None:
|
||||||
|
"""index.html must include a script tag for app.js."""
|
||||||
|
assert "/app.js" in _html()
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_name_input() -> None:
|
||||||
|
"""index.html must have name input field for onboarding."""
|
||||||
|
assert 'id="name-input"' in _html()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — sw.js (Service Worker)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sw() -> str:
|
||||||
|
return (FRONTEND / "sw.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_has_skip_waiting() -> None:
|
||||||
|
"""sw.js must call skipWaiting() in install handler."""
|
||||||
|
assert "skipWaiting()" in _sw()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_skip_waiting_in_install_handler() -> None:
|
||||||
|
"""skipWaiting() must appear within the 'install' event handler."""
|
||||||
|
sw = _sw()
|
||||||
|
install_match = re.search(r"addEventListener\(['\"]install['\"].*?}\s*\)", sw, re.DOTALL)
|
||||||
|
assert install_match, "No install event handler found in sw.js"
|
||||||
|
assert "skipWaiting()" in install_match.group(0), \
|
||||||
|
"skipWaiting() is not inside the install event handler"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_has_clients_claim() -> None:
|
||||||
|
"""sw.js must call clients.claim() in activate handler."""
|
||||||
|
assert "clients.claim()" in _sw()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_clients_claim_in_activate_handler() -> None:
|
||||||
|
"""clients.claim() must appear within the 'activate' event handler."""
|
||||||
|
sw = _sw()
|
||||||
|
activate_match = re.search(r"addEventListener\(['\"]activate['\"].*?}\s*\)", sw, re.DOTALL)
|
||||||
|
assert activate_match, "No activate event handler found in sw.js"
|
||||||
|
assert "clients.claim()" in activate_match.group(0), \
|
||||||
|
"clients.claim() is not inside the activate event handler"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_has_cache_first_strategy() -> None:
|
||||||
|
"""sw.js must use cache-first strategy (caches.match before fetch)."""
|
||||||
|
sw = _sw()
|
||||||
|
# cache-first: check cache, if hit return, else network
|
||||||
|
assert "caches.match" in sw, "sw.js does not use caches.match (no cache-first strategy)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_api_bypass() -> None:
|
||||||
|
"""sw.js must not intercept /api/ requests."""
|
||||||
|
sw = _sw()
|
||||||
|
assert "/api/" in sw, "sw.js missing /api/ bypass pattern"
|
||||||
|
# The bypass must be a guard/return before respondWith
|
||||||
|
# Check that /api/ check leads to a return
|
||||||
|
assert re.search(r"/api/.*return", sw, re.DOTALL) or \
|
||||||
|
re.search(r"pathname.*startsWith.*['\"]\/api\/", sw), \
|
||||||
|
"sw.js must bypass /api/ requests (return before respondWith)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_precaches_index_html() -> None:
|
||||||
|
"""sw.js must precache /index.html in the app shell."""
|
||||||
|
assert "/index.html" in _sw()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_precaches_app_js() -> None:
|
||||||
|
"""sw.js must precache /app.js in the app shell."""
|
||||||
|
assert "/app.js" in _sw()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_precaches_manifest() -> None:
|
||||||
|
"""sw.js must precache /manifest.json in the app shell."""
|
||||||
|
assert "/manifest.json" in _sw()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_precaches_icon_192() -> None:
|
||||||
|
"""sw.js must precache /icons/icon-192.png in the app shell."""
|
||||||
|
assert "/icons/icon-192.png" in _sw()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sw_precaches_icon_512() -> None:
|
||||||
|
"""sw.js must precache /icons/icon-512.png in the app shell."""
|
||||||
|
assert "/icons/icon-512.png" in _sw()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — Onboarding: UUID via crypto.randomUUID(), POST /api/register
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _app_js() -> str:
|
||||||
|
return (FRONTEND / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_uses_crypto_random_uuid() -> None:
|
||||||
|
"""app.js must generate UUID via crypto.randomUUID()."""
|
||||||
|
assert "crypto.randomUUID()" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_posts_to_api_register() -> None:
|
||||||
|
"""app.js must send POST to /api/register during onboarding."""
|
||||||
|
assert "/api/register" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_register_sends_uuid() -> None:
|
||||||
|
"""app.js must include uuid in the /api/register request body."""
|
||||||
|
app = _app_js()
|
||||||
|
# The register call must include uuid in the payload
|
||||||
|
register_section = re.search(
|
||||||
|
r"_apiPost\(['\"]\/api\/register['\"].*?\)", app, re.DOTALL
|
||||||
|
)
|
||||||
|
assert register_section, "No _apiPost('/api/register') call found"
|
||||||
|
assert "uuid" in register_section.group(0), \
|
||||||
|
"uuid not included in /api/register call"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_uuid_saved_to_storage() -> None:
|
||||||
|
"""app.js must persist UUID to storage (baton_user_id key)."""
|
||||||
|
assert "baton_user_id" in _app_js()
|
||||||
|
assert "setItem" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — Storage fallback (real write probe + chain)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_uses_real_write_probe() -> None:
|
||||||
|
"""app.js must probe storage with a real write (setItem), not typeof check."""
|
||||||
|
app = _app_js()
|
||||||
|
# The probe key is defined as a variable, then setItem is called with it
|
||||||
|
# Pattern: const k = '__baton_probe__'; s.setItem(k, ...)
|
||||||
|
assert "__baton_probe__" in app, "app.js missing probe key __baton_probe__"
|
||||||
|
# setItem must be called inside the probe function (not typeof/in check)
|
||||||
|
probe_fn = re.search(r"function _probeStorage\(.*?\}\n", app, re.DOTALL)
|
||||||
|
assert probe_fn, "app.js missing _probeStorage function"
|
||||||
|
assert "setItem" in probe_fn.group(0), \
|
||||||
|
"app.js real write probe must call setItem inside _probeStorage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_falls_back_to_session_storage() -> None:
|
||||||
|
"""app.js must include sessionStorage as a fallback storage option."""
|
||||||
|
assert "sessionStorage" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_falls_back_to_in_memory() -> None:
|
||||||
|
"""app.js must include in-memory storage as final fallback."""
|
||||||
|
app = _app_js()
|
||||||
|
# In-memory fallback uses a plain object (_mem)
|
||||||
|
assert "_mem" in app, "app.js missing in-memory fallback object"
|
||||||
|
assert "_storageType" in app, "app.js missing _storageType tracking variable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_storage_chain_order() -> None:
|
||||||
|
"""app.js must try localStorage first, then sessionStorage, then in-memory."""
|
||||||
|
app = _app_js()
|
||||||
|
local_pos = app.find("localStorage")
|
||||||
|
session_pos = app.find("sessionStorage")
|
||||||
|
mem_pos = app.find("_mem")
|
||||||
|
assert local_pos < session_pos, "localStorage must come before sessionStorage in fallback chain"
|
||||||
|
assert session_pos < mem_pos, "sessionStorage must come before in-memory in fallback chain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_storage_probe_removes_test_key() -> None:
|
||||||
|
"""app.js write probe must clean up after itself (removeItem)."""
|
||||||
|
assert "removeItem" in _app_js(), "Write probe must call removeItem to clean up"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 — Private mode banner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_shows_banner_when_storage_not_local() -> None:
|
||||||
|
"""app.js must show private-mode-banner when storageType is not 'local'."""
|
||||||
|
app = _app_js()
|
||||||
|
assert "private-mode-banner" in app, \
|
||||||
|
"app.js missing reference to private-mode-banner element"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_banner_triggered_by_storage_type_check() -> None:
|
||||||
|
"""app.js must show banner based on _storageType check (not 'local')."""
|
||||||
|
app = _app_js()
|
||||||
|
# Must check _storageType and conditionally show banner
|
||||||
|
assert re.search(r"_storageType.*['\"]local['\"]|['\"]local['\"].*_storageType", app), \
|
||||||
|
"app.js must check _storageType to decide whether to show private-mode banner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_banner_has_action_guidance() -> None:
|
||||||
|
"""Private mode banner in index.html must tell user what to do (explicit instruction)."""
|
||||||
|
html = _html()
|
||||||
|
# Extract banner content between its opening and closing div
|
||||||
|
banner_match = re.search(
|
||||||
|
r'id="private-mode-banner"[^>]*>(.*?)</div>', html, re.DOTALL
|
||||||
|
)
|
||||||
|
assert banner_match, "Could not find private-mode-banner div in index.html"
|
||||||
|
banner_text = banner_match.group(1).lower()
|
||||||
|
# Must contain explicit action: open in regular/normal tab
|
||||||
|
assert "open" in banner_text or "откр" in banner_text, \
|
||||||
|
"Banner must instruct user to open Baton in a regular tab"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 6 — Main screen: SOS button sends POST /api/signal with UUID
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_posts_to_api_signal() -> None:
|
||||||
|
"""app.js must send POST to /api/signal when SOS button clicked."""
|
||||||
|
assert "/api/signal" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_signal_sends_user_id() -> None:
|
||||||
|
"""app.js must include user_id (UUID) in the /api/signal request body."""
|
||||||
|
app = _app_js()
|
||||||
|
# The signal body may be built in a variable before passing to _apiPost
|
||||||
|
# Look for user_id key in the context around /api/signal
|
||||||
|
signal_area = re.search(
|
||||||
|
r"user_id.*?_apiPost\(['\"]\/api\/signal", app, re.DOTALL
|
||||||
|
)
|
||||||
|
assert signal_area, \
|
||||||
|
"user_id must be set in the request body before calling _apiPost('/api/signal')"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_sos_button_click_calls_handle_signal() -> None:
|
||||||
|
"""app.js must attach click handler (_handleSignal) to btn-sos."""
|
||||||
|
app = _app_js()
|
||||||
|
assert "btn-sos" in app, "app.js must reference btn-sos"
|
||||||
|
assert "_handleSignal" in app, "app.js must define _handleSignal"
|
||||||
|
# The SOS button must have _handleSignal as its click listener
|
||||||
|
assert re.search(r"btn.*sos.*_handleSignal|_handleSignal.*btn.*sos", app, re.DOTALL), \
|
||||||
|
"btn-sos must be connected to _handleSignal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_signal_uses_uuid_from_storage() -> None:
|
||||||
|
"""app.js must retrieve UUID from storage (_getOrCreateUserId) before sending signal."""
|
||||||
|
app = _app_js()
|
||||||
|
handle_signal = re.search(
|
||||||
|
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
|
||||||
|
)
|
||||||
|
assert handle_signal, "_handleSignal function not found"
|
||||||
|
assert "_getOrCreateUserId" in handle_signal.group(0), \
|
||||||
|
"_handleSignal must call _getOrCreateUserId() to get UUID"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 7 — Offline error (navigator.onLine === false → user sees error)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_checks_navigator_online() -> None:
|
||||||
|
"""app.js must check navigator.onLine before sending signal."""
|
||||||
|
assert "navigator.onLine" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_shows_error_when_offline() -> None:
|
||||||
|
"""app.js must call _setStatus with error message when offline."""
|
||||||
|
app = _app_js()
|
||||||
|
handle_signal = re.search(
|
||||||
|
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
|
||||||
|
)
|
||||||
|
assert handle_signal, "_handleSignal function not found"
|
||||||
|
fn_body = handle_signal.group(0)
|
||||||
|
# Must check onLine and show error (not silent fail)
|
||||||
|
assert "navigator.onLine" in fn_body, \
|
||||||
|
"_handleSignal must check navigator.onLine"
|
||||||
|
assert "_setStatus" in fn_body, \
|
||||||
|
"_handleSignal must call _setStatus to show offline error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_offline_error_returns_early() -> None:
|
||||||
|
"""app.js must return early (not attempt fetch) when navigator.onLine is false."""
|
||||||
|
app = _app_js()
|
||||||
|
handle_signal = re.search(
|
||||||
|
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
|
||||||
|
)
|
||||||
|
assert handle_signal, "_handleSignal function not found"
|
||||||
|
fn_body = handle_signal.group(0)
|
||||||
|
# The offline guard must be a return statement before any API call
|
||||||
|
offline_guard = re.search(
|
||||||
|
r"if\s*\(!navigator\.onLine\).*?return", fn_body, re.DOTALL
|
||||||
|
)
|
||||||
|
assert offline_guard, \
|
||||||
|
"_handleSignal must have an early return when navigator.onLine is false"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_offline_error_message_is_not_empty() -> None:
|
||||||
|
"""app.js must show a non-empty error message when offline."""
|
||||||
|
app = _app_js()
|
||||||
|
# Find the offline error call pattern
|
||||||
|
offline_block = re.search(
|
||||||
|
r"if\s*\(!navigator\.onLine\)\s*\{([^}]+)\}", app, re.DOTALL
|
||||||
|
)
|
||||||
|
assert offline_block, "Offline guard block not found"
|
||||||
|
block_content = offline_block.group(1)
|
||||||
|
assert "_setStatus(" in block_content, \
|
||||||
|
"Offline block must call _setStatus to display error"
|
||||||
|
# Extract the message from _setStatus call
|
||||||
|
status_call = re.search(r"_setStatus\(['\"]([^'\"]+)['\"]", block_content)
|
||||||
|
assert status_call, "Offline _setStatus call has no message"
|
||||||
|
assert len(status_call.group(1).strip()) > 0, \
|
||||||
|
"Offline error message must not be empty"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SW registration in app.js
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_registers_service_worker() -> None:
|
||||||
|
"""app.js must register a service worker via serviceWorker.register."""
|
||||||
|
assert "serviceWorker" in _app_js()
|
||||||
|
assert ".register(" in _app_js()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_registers_sw_js() -> None:
|
||||||
|
"""app.js must register specifically '/sw.js'."""
|
||||||
|
assert "'/sw.js'" in _app_js() or '"/sw.js"' in _app_js()
|
||||||
206
tests/test_arch_013.py
Normal file
206
tests/test_arch_013.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-ARCH-013: Keep-alive mechanism / health endpoint.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. GET /health returns HTTP 200 OK.
|
||||||
|
2. Response body contains JSON with {"status": "ok"}.
|
||||||
|
3. Endpoint does not require authorization (no token, no secret header needed).
|
||||||
|
4. Keep-alive loop is started when APP_URL is set, and NOT started when APP_URL is unset.
|
||||||
|
5. deploy/ contains valid systemd .service and .timer config files.
|
||||||
|
6. README documents both hosting scenarios and keep-alive instructions.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
||||||
|
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
||||||
|
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||||
|
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||||
|
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client, temp_db
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 & 2 & 3 — GET /health → 200 OK, {"status": "ok"}, no auth
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_returns_200_ok():
|
||||||
|
"""GET /health должен вернуть HTTP 200 без какого-либо заголовка авторизации."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_returns_status_ok():
|
||||||
|
"""GET /health должен вернуть JSON содержащий {"status": "ok"}."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/health")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("status") == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_returns_timestamp():
|
||||||
|
"""GET /health должен вернуть поле timestamp в JSON."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/health")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "timestamp" in data
|
||||||
|
assert isinstance(data["timestamp"], int)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_no_auth_header_required():
|
||||||
|
"""GET /health без заголовков авторизации должен вернуть 200 (не 401/403)."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
response = await client.get("/health")
|
||||||
|
|
||||||
|
assert response.status_code not in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — keep-alive task lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_keepalive_started_when_app_url_set():
|
||||||
|
"""Keep-alive задача должна стартовать при наличии APP_URL."""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||||||
|
with patch("backend.config.APP_URL", "https://example.com"):
|
||||||
|
with patch("backend.main._keep_alive_loop", new_callable=AsyncMock) as mock_loop:
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# asyncio.create_task вызывается с корутиной _keep_alive_loop — проверяем что она была вызвана
|
||||||
|
assert mock_loop.called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_keepalive_not_started_when_app_url_unset():
|
||||||
|
"""Keep-alive задача НЕ должна стартовать при отсутствии APP_URL."""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||||||
|
with patch("backend.config.APP_URL", None):
|
||||||
|
with patch("backend.main._keep_alive_loop", new_callable=AsyncMock) as mock_loop:
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert not mock_loop.called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_keepalive_called_with_app_url_value():
|
||||||
|
"""Keep-alive задача должна получить значение APP_URL в качестве аргумента."""
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
with temp_db():
|
||||||
|
with patch("backend.telegram.set_webhook", new_callable=AsyncMock):
|
||||||
|
with patch("backend.config.APP_URL", "https://my-app.fly.dev"):
|
||||||
|
with patch("backend.main._keep_alive_loop", new_callable=AsyncMock) as mock_loop:
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_loop.assert_called_once_with("https://my-app.fly.dev")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 — systemd config files in deploy/
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_service_file_exists():
|
||||||
|
"""Файл deploy/baton-keepalive.service должен существовать."""
|
||||||
|
assert (PROJECT_ROOT / "deploy" / "baton-keepalive.service").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_timer_file_exists():
|
||||||
|
"""Файл deploy/baton-keepalive.timer должен существовать."""
|
||||||
|
assert (PROJECT_ROOT / "deploy" / "baton-keepalive.timer").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_service_has_oneshot_type():
|
||||||
|
"""baton-keepalive.service должен содержать Type=oneshot."""
|
||||||
|
content = (PROJECT_ROOT / "deploy" / "baton-keepalive.service").read_text()
|
||||||
|
assert "Type=oneshot" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_service_pings_health():
|
||||||
|
"""baton-keepalive.service должен вызывать curl с /health endpoint."""
|
||||||
|
content = (PROJECT_ROOT / "deploy" / "baton-keepalive.service").read_text()
|
||||||
|
assert "/health" in content
|
||||||
|
assert "curl" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_timer_has_unit_active_sec():
|
||||||
|
"""baton-keepalive.timer должен содержать OnUnitActiveSec."""
|
||||||
|
content = (PROJECT_ROOT / "deploy" / "baton-keepalive.timer").read_text()
|
||||||
|
assert "OnUnitActiveSec" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_timer_has_install_section():
|
||||||
|
"""baton-keepalive.timer должен содержать секцию [Install] с WantedBy=timers.target."""
|
||||||
|
content = (PROJECT_ROOT / "deploy" / "baton-keepalive.timer").read_text()
|
||||||
|
assert "[Install]" in content
|
||||||
|
assert "timers.target" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 6 — README documents hosting scenarios and keep-alive
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_selfhosting_scenario():
|
||||||
|
"""README должен описывать вариант self-hosting (VPS)."""
|
||||||
|
content = (PROJECT_ROOT / "README.md").read_text()
|
||||||
|
assert "самохост" in content.lower() or "vps" in content.lower() or "Self" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_fly_io_scenario():
|
||||||
|
"""README должен описывать вариант хостинга Fly.io."""
|
||||||
|
content = (PROJECT_ROOT / "README.md").read_text()
|
||||||
|
assert "fly.io" in content.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_cron_keepalive():
|
||||||
|
"""README должен содержать инструкцию по настройке cron для keep-alive."""
|
||||||
|
content = (PROJECT_ROOT / "README.md").read_text()
|
||||||
|
assert "cron" in content.lower() or "crontab" in content.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_systemd_keepalive():
|
||||||
|
"""README должен содержать инструкцию по настройке systemd timer для keep-alive."""
|
||||||
|
content = (PROJECT_ROOT / "README.md").read_text()
|
||||||
|
assert "systemd" in content.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_uptimerobot():
|
||||||
|
"""README должен содержать секцию UptimeRobot как внешний watchdog."""
|
||||||
|
content = (PROJECT_ROOT / "README.md").read_text()
|
||||||
|
assert "uptimerobot" in content.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_app_url_env_var():
|
||||||
|
"""README должен упоминать переменную APP_URL для keep-alive."""
|
||||||
|
content = (PROJECT_ROOT / "README.md").read_text()
|
||||||
|
assert "APP_URL" in content
|
||||||
222
tests/test_arch_014.py
Normal file
222
tests/test_arch_014.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-ARCH-014: Доработать ADR-002 и ADR-004 по замечаниям ревью.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. docs/adr/ADR-002-offline-pattern.md существует в docs/adr/.
|
||||||
|
2. ADR-002: заголовок содержит «ADR-002».
|
||||||
|
3. ADR-002: дата 2026-03-20 присутствует.
|
||||||
|
4. ADR-002: статус «Accepted».
|
||||||
|
5. ADR-002: секция «Open Questions» присутствует.
|
||||||
|
6. ADR-002: Open Questions содержит вопрос о #1001 и BackgroundSync 78.75%.
|
||||||
|
7. ADR-002: Open Questions содержит ACTION item с отсылкой на #1049.
|
||||||
|
8. ADR-004: пункт о 429 содержит «exponential backoff» и ссылку на «#1046».
|
||||||
|
9. ARCHITECTURE.md: фраза «ADR-файлы хранятся в `docs/adr/`.» стоит после
|
||||||
|
заголовка «## Ссылки на ADR» и перед таблицей.
|
||||||
|
10. ARCHITECTURE.md: таблица ADR содержит строку с ADR-002.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
ADR_DIR = PROJECT_ROOT / "docs" / "adr"
|
||||||
|
ADR_002 = ADR_DIR / "ADR-002-offline-pattern.md"
|
||||||
|
ADR_004 = ADR_DIR / "ADR-004-telegram-strategy.md"
|
||||||
|
ARCHITECTURE_MD = PROJECT_ROOT / "ARCHITECTURE.md"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 1 — ADR-002 file existence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_offline_pattern_file_exists() -> None:
|
||||||
|
"""docs/adr/ADR-002-offline-pattern.md должен существовать."""
|
||||||
|
assert ADR_002.is_file(), (
|
||||||
|
f"Файл ADR-002-offline-pattern.md не найден в {ADR_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 2 — ADR-002 header
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_header_contains_adr_002() -> None:
|
||||||
|
"""Заголовок ADR-002 должен содержать идентификатор «ADR-002»."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
assert "ADR-002" in content, (
|
||||||
|
"ADR-002-offline-pattern.md не содержит идентификатор «ADR-002» в заголовке"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 3 — ADR-002 date
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_contains_date_2026_03_20() -> None:
|
||||||
|
"""ADR-002 должен содержать дату «2026-03-20»."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
assert "2026-03-20" in content, (
|
||||||
|
"ADR-002-offline-pattern.md не содержит дату 2026-03-20"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 4 — ADR-002 status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_status_is_accepted() -> None:
|
||||||
|
"""ADR-002 должен иметь статус «Accepted»."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
assert "Accepted" in content, (
|
||||||
|
"ADR-002-offline-pattern.md не содержит статус «Accepted»"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 5 — ADR-002 Open Questions section
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_has_open_questions_section() -> None:
|
||||||
|
"""ADR-002 должен содержать секцию «Open Questions»."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
assert "Open Questions" in content, (
|
||||||
|
"ADR-002-offline-pattern.md не содержит секцию «Open Questions»"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 6 — Open Questions: #1001 and BackgroundSync 78.75%
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_open_questions_references_decision_1001() -> None:
|
||||||
|
"""Open Questions ADR-002 должен ссылаться на решение #1001."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
assert "#1001" in content, (
|
||||||
|
"ADR-002-offline-pattern.md Open Questions не содержит ссылку на решение #1001"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_open_questions_mentions_backgroundsync_coverage() -> None:
|
||||||
|
"""Open Questions ADR-002 должен упоминать покрытие BackgroundSync 78.75%."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
assert "78.75" in content, (
|
||||||
|
"ADR-002-offline-pattern.md Open Questions не содержит покрытие «78.75%» "
|
||||||
|
"(BackgroundSync, caniuse март 2026)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 7 — Open Questions: ACTION item with #1049
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_open_questions_has_action_item() -> None:
|
||||||
|
"""Open Questions ADR-002 должен содержать явный ACTION item (конвенция #1049)."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
# Конвенция #1049: строки Open Questions с устаревшими решениями должны содержать ACTION:
|
||||||
|
assert re.search(r"ACTION:", content), (
|
||||||
|
"ADR-002-offline-pattern.md Open Questions не содержит маркер «ACTION:» "
|
||||||
|
"— нарушение конвенции #1049"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_002_action_item_references_decision_1049() -> None:
|
||||||
|
"""ACTION item в ADR-002 должен ссылаться на конвенцию #1049."""
|
||||||
|
content = ADR_002.read_text(encoding="utf-8")
|
||||||
|
assert "#1049" in content, (
|
||||||
|
"ADR-002-offline-pattern.md не содержит ссылку на конвенцию #1049 в ACTION item"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 8 — ADR-004: exponential backoff + #1046
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_004_retry_after_mentions_exponential_backoff() -> None:
|
||||||
|
"""Пункт о 429 в ADR-004 должен упоминать «exponential backoff»."""
|
||||||
|
content = ADR_004.read_text(encoding="utf-8")
|
||||||
|
# Проверяем, что "exponential backoff" присутствует в контексте retry_after
|
||||||
|
retry_section = re.search(
|
||||||
|
r"retry_after[^\n]*", content, re.IGNORECASE
|
||||||
|
)
|
||||||
|
assert retry_section is not None, (
|
||||||
|
"ADR-004 не содержит строки с упоминанием retry_after"
|
||||||
|
)
|
||||||
|
# Ищем exponential backoff в пределах абзаца о 429
|
||||||
|
para_429 = re.search(
|
||||||
|
r"(?:429|retry_after)[^\n]*(?:\n[^\n]+)*",
|
||||||
|
content,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
assert para_429 is not None
|
||||||
|
assert "exponential backoff" in para_429.group(0).lower(), (
|
||||||
|
"Пункт о retry_after/429 в ADR-004 не содержит «exponential backoff» — "
|
||||||
|
"требуется дополнение согласно решению #1046"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_004_exponential_backoff_references_decision_1046() -> None:
|
||||||
|
"""Упоминание exponential backoff в ADR-004 должно ссылаться на решение #1046."""
|
||||||
|
content = ADR_004.read_text(encoding="utf-8")
|
||||||
|
assert "#1046" in content, (
|
||||||
|
"ADR-004 не содержит ссылку на решение #1046 рядом с exponential backoff"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 9 — ARCHITECTURE.md: intro sentence in ADR section
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_md_adr_section_has_intro_sentence() -> None:
|
||||||
|
"""Секция «Ссылки на ADR» в ARCHITECTURE.md должна начинаться с вводной фразы о пути docs/adr/."""
|
||||||
|
content = ARCHITECTURE_MD.read_text(encoding="utf-8")
|
||||||
|
# Ищем вводную фразу непосредственно после заголовка
|
||||||
|
pattern = re.compile(
|
||||||
|
r"##\s+Ссылки на ADR\s*\n+(?:[^\n]*\n)*?.*ADR-файлы хранятся в `docs/adr/`",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
assert pattern.search(content), (
|
||||||
|
"ARCHITECTURE.md: секция «Ссылки на ADR» не содержит вводную фразу "
|
||||||
|
"«ADR-файлы хранятся в `docs/adr/`.»"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Criterion 10 — ARCHITECTURE.md: ADR-002 row in table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_md_adr_table_contains_adr_002_row() -> None:
|
||||||
|
"""Таблица ADR в ARCHITECTURE.md должна содержать строку для ADR-002."""
|
||||||
|
content = ARCHITECTURE_MD.read_text(encoding="utf-8")
|
||||||
|
# Ищем строку таблицы с ADR-002 и ссылкой на файл
|
||||||
|
assert re.search(
|
||||||
|
r"\|\s*\[ADR-002\]\(docs/adr/ADR-002-offline-pattern\.md\)", content
|
||||||
|
), (
|
||||||
|
"ARCHITECTURE.md не содержит строки таблицы с "
|
||||||
|
"[ADR-002](docs/adr/ADR-002-offline-pattern.md)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_md_adr_002_row_has_accepted_status() -> None:
|
||||||
|
"""Строка ADR-002 в таблице ARCHITECTURE.md должна иметь статус Accepted."""
|
||||||
|
content = ARCHITECTURE_MD.read_text(encoding="utf-8")
|
||||||
|
row_match = re.search(
|
||||||
|
r"\|\s*\[ADR-002\].*?\|\s*([^|]+)\|\s*(Accepted|Superseded|Draft)\s*\|",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
assert row_match, (
|
||||||
|
"Строка ADR-002 в таблице ARCHITECTURE.md не найдена или не содержит поля статуса"
|
||||||
|
)
|
||||||
|
assert "Accepted" in row_match.group(0), (
|
||||||
|
"Статус строки ADR-002 в ARCHITECTURE.md должен быть «Accepted»"
|
||||||
|
)
|
||||||
|
|
@ -35,7 +35,7 @@ REQUIRED_FILES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
# ADR files: matched by prefix because filenames include descriptive suffixes
|
# ADR files: matched by prefix because filenames include descriptive suffixes
|
||||||
ADR_PREFIXES = ["ADR-001", "ADR-002", "ADR-003", "ADR-004"]
|
ADR_PREFIXES = ["ADR-001", "ADR-003", "ADR-004"]
|
||||||
|
|
||||||
PYTHON_SOURCES = [
|
PYTHON_SOURCES = [
|
||||||
"backend/__init__.py",
|
"backend/__init__.py",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue