From 6dff5de0778c7496bad56161eaaeb468e9a47e08 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:09:05 +0200 Subject: [PATCH 1/8] kin: BATON-ARCH-009-frontend_dev --- frontend/app.js | 264 ++++++++++++++++++++++++++++++++++++ frontend/icons/icon-192.png | Bin 0 -> 412 bytes frontend/icons/icon-512.png | Bin 0 -> 1496 bytes frontend/index.html | 78 +++++++++++ frontend/manifest.json | 23 ++++ frontend/style.css | 200 +++++++++++++++++++++++++++ frontend/sw.js | 60 ++++++++ 7 files changed, 625 insertions(+) create mode 100644 frontend/app.js create mode 100644 frontend/icons/icon-192.png create mode 100644 frontend/icons/icon-512.png create mode 100644 frontend/index.html create mode 100644 frontend/manifest.json create mode 100644 frontend/style.css create mode 100644 frontend/sw.js diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..10c4b1b --- /dev/null +++ b/frontend/app.js @@ -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(); +}); diff --git a/frontend/icons/icon-192.png b/frontend/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..0ddef3452d21347c82b59a5ed72b97816131f4ec GIT binary patch literal 412 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rficznGbb^5)^n^8Vv*_IG7U^cv#pDIRIIWKmi~ND#gYGl4@x@0Fp{n z082@5fOR1h(Lz7W5_0XwtDkvg1Bx%Pc>uow&`>Ja8tc+&;;?tO00R(sy85}Sb4q9e E0N@y9g#Z8m literal 0 HcmV?d00001 diff --git a/frontend/icons/icon-512.png b/frontend/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7c2d6804a5cf3b7984104274af611a5ffc553d34 GIT binary patch literal 1496 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_svoo-U3d6}R4AHRNSr;5ocu zk!HODvngM7`bmyYDwml)1gJ0=03{e0m>CW*kj11}yC_B016@st>p|KbSjqQ2B+$qX zJgR8tAvfwEwo|Mf7Z0*WHhfPs@D{h&lZK@I?EANVvBzzB;@GE?)wY9H`KM1@2^ dY8rZw#`uuMaiNOh(i{dL@O1TaS?83{1OSwBmnZ-L literal 0 HcmV?d00001 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e5fe30e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + Baton + + + + + + + +
+
?
+
+
+ + + + + + + + + + + + + diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..9655cea --- /dev/null +++ b/frontend/manifest.json @@ -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" + } + ] +} diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..487a443 --- /dev/null +++ b/frontend/style.css @@ -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; } diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..e37d2fa --- /dev/null +++ b/frontend/sw.js @@ -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; + }); + }) + ); +}); From f6f4300f736a310d2f497e34fbb47dbee9c11f2f Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:09:33 +0200 Subject: [PATCH 2/8] kin: BATON-ARCH-013-backend_dev --- tests/test_arch_013.py | 84 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test_arch_013.py diff --git a/tests/test_arch_013.py b/tests/test_arch_013.py new file mode 100644 index 0000000..b690937 --- /dev/null +++ b/tests/test_arch_013.py @@ -0,0 +1,84 @@ +""" +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. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("BOT_TOKEN", "test-bot-token") +os.environ.setdefault("CHAT_ID", "-1001234567890") +os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") +os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") + +from unittest.mock import AsyncMock, patch + +import pytest + +from tests.conftest import make_app_client, temp_db + + +# --------------------------------------------------------------------------- +# 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" + + +# --------------------------------------------------------------------------- +# 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 From 8012cb1c0fea3c13fec74491b6822b40bd83833e Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:10:26 +0200 Subject: [PATCH 3/8] =?UTF-8?q?kin:=20BATON-ARCH-010=20=D0=9D=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20unit-=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D0=B0=20(tester?= =?UTF-8?q?=20FAILED=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 5 +++-- backend/middleware.py | 26 ++++++++++++++++++++++++-- deploy/baton-keepalive.service | 10 ++++++++++ deploy/baton-keepalive.timer | 11 +++++++++++ tests/test_structure.py | 2 +- 5 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 deploy/baton-keepalive.service create mode 100644 deploy/baton-keepalive.timer diff --git a/backend/main.py b/backend/main.py index 092a764..025d69d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,7 +13,7 @@ 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.middleware import rate_limit_register, verify_webhook_secret from backend.models import ( RegisterRequest, RegisterResponse, @@ -45,6 +45,7 @@ async def _keep_alive_loop(app_url: str) -> None: @asynccontextmanager async def lifespan(app: FastAPI): # Startup + app.state.rate_counters = {} await db.init_db() logger.info("Database initialized") @@ -100,7 +101,7 @@ async def health() -> dict[str, Any]: @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) return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"]) diff --git a/backend/middleware.py b/backend/middleware.py index 2429250..34d913e 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -1,12 +1,34 @@ from __future__ import annotations -from fastapi import Header, HTTPException +import secrets +import time + +from fastapi import Header, HTTPException, Request from backend import config +_RATE_LIMIT = 5 +_RATE_WINDOW = 600 # 10 minutes + 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: + if not secrets.compare_digest( + x_telegram_bot_api_secret_token, config.WEBHOOK_SECRET + ): 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") diff --git a/deploy/baton-keepalive.service b/deploy/baton-keepalive.service new file mode 100644 index 0000000..8ed86fe --- /dev/null +++ b/deploy/baton-keepalive.service @@ -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 diff --git a/deploy/baton-keepalive.timer b/deploy/baton-keepalive.timer new file mode 100644 index 0000000..5933b76 --- /dev/null +++ b/deploy/baton-keepalive.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run Baton keep-alive every 10 minutes + +[Timer] +# Первый запуск через 1 минуту после загрузки системы +OnBootSec=1min +# Затем каждые 10 минут +OnUnitActiveSec=10min + +[Install] +WantedBy=timers.target diff --git a/tests/test_structure.py b/tests/test_structure.py index 5f897cb..81b7498 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -35,7 +35,7 @@ REQUIRED_FILES = [ ] # 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 = [ "backend/__init__.py", From aff655e73a9ccbd5e9aeaff9048adcd9fd40d321 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:11:04 +0200 Subject: [PATCH 4/8] =?UTF-8?q?kin:=20BATON-ARCH-003=20Rate=20limiting=20/?= =?UTF-8?q?api/register=20+=20timing-safe=20=D1=81=D1=80=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 05f5f12..b2f95ce 100644 --- a/README.md +++ b/README.md @@ -116,13 +116,44 @@ OnUnitActiveSec=10min WantedBy=timers.target ``` -Активация: +Готовые файлы находятся в `deploy/`: + ```bash -systemctl daemon-reload -systemctl enable --now baton-keepalive.timer +# 1. Скопировать файлы +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 ``` +### 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 используйте готовый шаблон `nginx/baton.conf`. From 004c20585a1aeeb150c0916a28c65b6f2a4dfa00 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:12:43 +0200 Subject: [PATCH 5/8] kin: BATON-ARCH-004-backend_dev --- ARCHITECTURE.md | 4 ++-- docs/adr/ADR-006-offline-ios-constraints.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cfb97f6..bd72d26 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -152,14 +152,14 @@ Telegram Backend SQLite | Слой | Технология | ADR | |------|-----------|-----| | 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 | | Backend | FastAPI (Python 3.11+) | ADR-001 | | Database | SQLite WAL + aiosqlite | ADR-001 | | Telegram | Direct sendMessage (v1) | ADR-004 | | TLS | Nginx + Let's Encrypt | — | | 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 | --- diff --git a/docs/adr/ADR-006-offline-ios-constraints.md b/docs/adr/ADR-006-offline-ios-constraints.md index 62baed7..d60404a 100644 --- a/docs/adr/ADR-006-offline-ios-constraints.md +++ b/docs/adr/ADR-006-offline-ios-constraints.md @@ -50,7 +50,7 @@ - localStorage недоступен в Service Worker контексте - При закрытии вкладки до online event — сигнал не отправлен (но сохранён) -#### Вариант C: IndexedDB + BackgroundSync (ADR-002 plan) +#### Вариант C: IndexedDB + BackgroundSync (ADR-007 plan) **Плюсы:** - Самое надёжное: IndexedDB доступен из SW, BackgroundSync работает даже при закрытой вкладке (Chromium) @@ -65,7 +65,7 @@ **Выбран Вариант A для v1 (#1019): показать ошибку, нет retry.** -Переход на Вариант C (IndexedDB + BackgroundSync) запланирован для v2 (полная спека в ADR-002). +Переход на Вариант C (IndexedDB + BackgroundSync) запланирован для v2 (полная спека в ADR-007). ### Обоснование (offline) @@ -90,7 +90,7 @@ ``` Даже если `navigator.onLine` ненадёжен — try/catch ловит ошибку fetch. -4. **Вариант C (IndexedDB + BackgroundSync) — зарезервирован для v2.** ADR-002 содержит полную спецификацию. Переход потребует ~4 часа dev-работы. +4. **Вариант C (IndexedDB + BackgroundSync) — зарезервирован для v2.** ADR-007 содержит полную спецификацию. Переход потребует ~4 часа dev-работы. --- From 12abac74f04f658aae9bcdc805df6e713f9d05f9 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:14:32 +0200 Subject: [PATCH 6/8] =?UTF-8?q?kin:=20BATON-ARCH-013=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20keep-alive=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=85=D0=B0=D0=BD=D0=B8=D0=B7=D0=BC=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B4=D0=BE=D1=82=D0=B2=D1=80=D0=B0=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20cold=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_arch_004.py | 173 +++++++++++++ tests/test_arch_009.py | 542 +++++++++++++++++++++++++++++++++++++++++ tests/test_arch_013.py | 122 ++++++++++ 3 files changed, 837 insertions(+) create mode 100644 tests/test_arch_004.py create mode 100644 tests/test_arch_009.py diff --git a/tests/test_arch_004.py b/tests/test_arch_004.py new file mode 100644 index 0000000..91d5c35 --- /dev/null +++ b/tests/test_arch_004.py @@ -0,0 +1,173 @@ +""" +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 +# --------------------------------------------------------------------------- + + +def test_adr_002_offline_pattern_file_does_not_exist() -> None: + """Файл ADR-002-offline-pattern*.md не должен существовать в docs/adr/.""" + matches = list(ADR_DIR.glob("ADR-002-offline-pattern*.md")) + assert len(matches) == 0, ( + f"Старый файл ADR-002-offline-pattern найден: {matches}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — no 'ADR-002-offline-pattern' textual references +# --------------------------------------------------------------------------- + + +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)}" + ) + + +def test_no_adr_002_offline_pattern_in_architecture_md() -> None: + """ARCHITECTURE.md не должен содержать строку 'ADR-002-offline-pattern'.""" + content = ARCHITECTURE_MD.read_text(encoding="utf-8") + assert "ADR-002-offline-pattern" not in content, ( + "Найдена устаревшая ссылка 'ADR-002-offline-pattern' в ARCHITECTURE.md" + ) + + +# --------------------------------------------------------------------------- +# Criterion 3 — no dangling bare ADR-002 references +# --------------------------------------------------------------------------- + + +def test_no_bare_adr_002_in_docs() -> None: + """Ни один файл в docs/ не должен содержать голую метку 'ADR-002' (без корректного имени файла).""" + pattern = re.compile(r"\bADR-002\b") + for path in _all_md_in_docs(): + content = path.read_text(encoding="utf-8") + assert not pattern.search(content), ( + f"Найдена висячая ссылка 'ADR-002' в {path.relative_to(PROJECT_ROOT)}" + ) + + +def test_no_bare_adr_002_in_architecture_md() -> None: + """ARCHITECTURE.md не должен содержать голую метку 'ADR-002'.""" + content = ARCHITECTURE_MD.read_text(encoding="utf-8") + assert not re.search(r"\bADR-002\b", content), ( + "Найдена висячая ссылка 'ADR-002' в ARCHITECTURE.md" + ) + + +def test_no_bare_adr_002_in_tests() -> None: + """Файлы тестов (кроме этого самого файла) не должны содержать голую метку 'ADR-002'.""" + pattern = re.compile(r"\bADR-002\b") + this_file = Path(__file__).resolve() + for path in (PROJECT_ROOT / "tests").glob("*.py"): + if path.resolve() == this_file: + continue # этот файл документирует задачу и легитимно упоминает ADR-002 + 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}" + ) diff --git a/tests/test_arch_009.py b/tests/test_arch_009.py new file mode 100644 index 0000000..9457374 --- /dev/null +++ b/tests/test_arch_009.py @@ -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"[^>]*>(.*?)', 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() diff --git a/tests/test_arch_013.py b/tests/test_arch_013.py index b690937..b70c682 100644 --- a/tests/test_arch_013.py +++ b/tests/test_arch_013.py @@ -6,10 +6,13 @@ Acceptance criteria: 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") @@ -23,6 +26,8 @@ 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 @@ -48,6 +53,26 @@ async def test_health_returns_status_ok(): 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 # --------------------------------------------------------------------------- @@ -82,3 +107,100 @@ async def test_keepalive_not_started_when_app_url_unset(): 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 From f082c75ff8fa5c2e791065f16f03df6fbe8240ef Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:23:34 +0200 Subject: [PATCH 7/8] =?UTF-8?q?kin:=20BATON-ARCH-004=20=D0=9F=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?ADR-002-offline-pattern.md=20=D0=B2=D0=BE=20=D0=B8=D0=B7=D0=B1?= =?UTF-8?q?=D0=B5=D0=B6=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D1=82=D0=B0=20=D0=BD=D1=83=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/ADR-007-offline-queue-v2.md | 2 +- tests/test_arch_004.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-007-offline-queue-v2.md b/docs/adr/ADR-007-offline-queue-v2.md index 05431c2..fbff592 100644 --- a/docs/adr/ADR-007-offline-queue-v2.md +++ b/docs/adr/ADR-007-offline-queue-v2.md @@ -120,4 +120,4 @@ Baton — приложение экстренного сигнала. Крити } ``` -6. **Решение #1001 требует обновления:** фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры. +6. **ACTION: Обновить решение #1001** — изменить охват BackgroundSync с 85% до 78.75%, пометить ручной fallback как обязательный (не опциональный) элемент архитектуры. diff --git a/tests/test_arch_004.py b/tests/test_arch_004.py index 91d5c35..f47a1a1 100644 --- a/tests/test_arch_004.py +++ b/tests/test_arch_004.py @@ -171,3 +171,26 @@ def test_architecture_md_references_adr_007_for_offline() -> None: 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" + ) From 2ee953866b92f4121d69850600275d0d9d405447 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 22:05:04 +0200 Subject: [PATCH 8/8] =?UTF-8?q?kin:=20BATON-ARCH-014=20=D0=94=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20ADR-002=20=D0=B8?= =?UTF-8?q?=20ADR-004=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан docs/adr/ADR-002-offline-pattern.md (Accepted, дата 2026-03-20) с секцией Open Questions: #1001, охват 78.75%, ACTION:/конвенция #1049 - ADR-004: добавлен "exponential backoff согласно решению #1046" к строке 429/retry_after - ARCHITECTURE.md: добавлена вводная фраза "ADR-файлы хранятся в docs/adr/" и строка таблицы для ADR-002 (Accepted) - tests/test_arch_004.py: удалены 4 теста на отсутствие ADR-002, устаревшие после создания нового ADR-002 (BATON-ARCH-014 supersedes) - tests/test_arch_014.py: 14 новых тестов для критериев приёмки - Все 216 тестов: passed Co-Authored-By: Claude Sonnet 4.6 --- ARCHITECTURE.md | 3 + docs/adr/ADR-002-offline-pattern.md | 41 +++++ docs/adr/ADR-004-telegram-strategy.md | 2 +- tests/test_arch_004.py | 52 ++---- tests/test_arch_014.py | 222 ++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 37 deletions(-) create mode 100644 docs/adr/ADR-002-offline-pattern.md create mode 100644 tests/test_arch_014.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bd72d26..81eb4cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -256,9 +256,12 @@ VPS (один сервер) ## Ссылки на ADR +ADR-файлы хранятся в `docs/adr/`. + | ADR | Тема | Статус | |-----|------|--------| | [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-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 | diff --git a/docs/adr/ADR-002-offline-pattern.md b/docs/adr/ADR-002-offline-pattern.md new file mode 100644 index 0000000..452a58a --- /dev/null +++ b/docs/adr/ADR-002-offline-pattern.md @@ -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). diff --git a/docs/adr/ADR-004-telegram-strategy.md b/docs/adr/ADR-004-telegram-strategy.md index 9fb0cee..cb4464e 100644 --- a/docs/adr/ADR-004-telegram-strategy.md +++ b/docs/adr/ADR-004-telegram-strategy.md @@ -19,7 +19,7 @@ | Группа | 20 msg/минуту | | Глобально | ~30 msg/сек | -При превышении: HTTP 429 + `parameters.retry_after`. +При превышении: HTTP 429 + `parameters.retry_after`. При последовательных 429 рекомендуется exponential backoff согласно решению #1046. --- diff --git a/tests/test_arch_004.py b/tests/test_arch_004.py index f47a1a1..c7228d6 100644 --- a/tests/test_arch_004.py +++ b/tests/test_arch_004.py @@ -28,16 +28,12 @@ ADR_007 = ADR_DIR / "ADR-007-offline-queue-v2.md" # --------------------------------------------------------------------------- -def test_adr_002_offline_pattern_file_does_not_exist() -> None: - """Файл ADR-002-offline-pattern*.md не должен существовать в docs/adr/.""" - matches = list(ADR_DIR.glob("ADR-002-offline-pattern*.md")) - assert len(matches) == 0, ( - f"Старый файл ADR-002-offline-pattern найден: {matches}" - ) +# Criterion 1 superseded by BATON-ARCH-014: ADR-002-offline-pattern.md now exists +# as a legitimate new ADR document. # --------------------------------------------------------------------------- -# Criterion 2 — no 'ADR-002-offline-pattern' textual references +# Criterion 2 — no stale 'ADR-002-offline-pattern' textual references in docs/ # --------------------------------------------------------------------------- @@ -54,44 +50,28 @@ def test_no_adr_002_offline_pattern_in_docs() -> None: ) -def test_no_adr_002_offline_pattern_in_architecture_md() -> None: - """ARCHITECTURE.md не должен содержать строку 'ADR-002-offline-pattern'.""" - content = ARCHITECTURE_MD.read_text(encoding="utf-8") - assert "ADR-002-offline-pattern" not in content, ( - "Найдена устаревшая ссылка 'ADR-002-offline-pattern' в ARCHITECTURE.md" - ) +# 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 +# Criterion 3 — no dangling bare ADR-002 references in test files # --------------------------------------------------------------------------- -def test_no_bare_adr_002_in_docs() -> None: - """Ни один файл в docs/ не должен содержать голую метку 'ADR-002' (без корректного имени файла).""" - pattern = re.compile(r"\bADR-002\b") - for path in _all_md_in_docs(): - content = path.read_text(encoding="utf-8") - assert not pattern.search(content), ( - f"Найдена висячая ссылка 'ADR-002' в {path.relative_to(PROJECT_ROOT)}" - ) - - -def test_no_bare_adr_002_in_architecture_md() -> None: - """ARCHITECTURE.md не должен содержать голую метку 'ADR-002'.""" - content = ARCHITECTURE_MD.read_text(encoding="utf-8") - assert not re.search(r"\bADR-002\b", content), ( - "Найдена висячая ссылка 'ADR-002' в ARCHITECTURE.md" - ) - - def test_no_bare_adr_002_in_tests() -> None: - """Файлы тестов (кроме этого самого файла) не должны содержать голую метку 'ADR-002'.""" + """Файлы тестов (кроме легитимных исключений) не должны содержать голую метку 'ADR-002'.""" pattern = re.compile(r"\bADR-002\b") - this_file = Path(__file__).resolve() + # Легитимные исключения: файлы, документирующие задачи, которые явно работают с 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() == this_file: - continue # этот файл документирует задачу и легитимно упоминает ADR-002 + 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)}" diff --git a/tests/test_arch_014.py b/tests/test_arch_014.py new file mode 100644 index 0000000..ead50c5 --- /dev/null +++ b/tests/test_arch_014.py @@ -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»" + )