kin: BATON-ARCH-013 Добавить keep-alive механизм для предотвращения cold start
This commit is contained in:
parent
5435d2006f
commit
12abac74f0
3 changed files with 837 additions and 0 deletions
173
tests/test_arch_004.py
Normal file
173
tests/test_arch_004.py
Normal file
|
|
@ -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}"
|
||||||
|
)
|
||||||
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()
|
||||||
|
|
@ -6,10 +6,13 @@ Acceptance criteria:
|
||||||
2. Response body contains JSON with {"status": "ok"}.
|
2. Response body contains JSON with {"status": "ok"}.
|
||||||
3. Endpoint does not require authorization (no token, no secret header needed).
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
||||||
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
||||||
|
|
@ -23,6 +26,8 @@ import pytest
|
||||||
|
|
||||||
from tests.conftest import make_app_client, temp_db
|
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
|
# 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"
|
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
|
# Criterion 4 — keep-alive task lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -82,3 +107,100 @@ async def test_keepalive_not_started_when_app_url_unset():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert not mock_loop.called
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue