feat(gps-tracks): GPX download from public track popup
Some checks failed
CI / lint (push) Failing after 5s
CI / test (push) Failing after 6s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped

Реализация ET-011: кнопка «Скачать GPX» в popup публичного GPS-трека и
новый эндпоинт GET /api/gps-tracks/{track_id}/download (GPX 1.1 +
Content-Disposition с UTF-8 именем по RFC 5987). Реэкспорт защищён
per-source флагом `download_allowed` в `config/gps_sources.yaml`
(default-deny, MVP whitelist = `osm`).

Backend:
- `src/api/gps_tracks/export.py` — чистый stdlib-builder GPX 1.1
  (`build_gpx`) + санитизация имени файла (`safe_filename`, RFC 5987).
- `src/api/gps_tracks/endpoint.py` — новый route с проверками
  400 / 403 / 404 / 413; cap 200 000 точек (REQ-NF-02).
- `src/api/gps_tracks/config.py` — `load_download_allowed_sources()`
  читает YAML, default-deny при отсутствии поля; fallback на `{"osm"}`
  при отсутствии конфига.
- `src/api/main.py` — пробрасывает `GPS_SOURCES_CONFIG_PATH` в router.

Frontend:
- `src/web/gps_tracks.js` — кнопка в `_renderTrackPopupHtml`,
  обработчик `_downloadPublicTrack` (fetch + Blob + a.download — тот же
  паттерн, что в `app.js::downloadGPX`, R-1 митигирован), парсер
  `_parseFilenameFromCD` для RFC 5987, маппинг ошибок
  `_handleDownloadError` (403/404/413/5xx → showToast).
- `src/web/app.css` — стиль кнопки, 32×32 CSS px (REQ-NF-04).

Тесты:
- 13 unit для GPX-builder (UT-01/02/03/05; XSD-валидация против
  `tests/fixtures/gpx-1.1/gpx.xsd`).
- 10 unit для `safe_filename` (UT-04).
- 11 integration для download-эндпоинта (IT-01..08 +
  ANY-rule license check + default-deny без конфига).

ADR-014 (gpx-download-endpoint), ADR-015 (source-redistribution-policy).
Refs: ET-011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 20:59:53 +00:00
parent 6fe2ecf12b
commit eea6c846c2
12 changed files with 2284 additions and 8 deletions

View File

@@ -0,0 +1,412 @@
"""Integration-тесты ET-011 download-эндпоинта.
Покрывает test-plan: IT-01..IT-07 (+ IT-05 license-фильтр).
"""
from __future__ import annotations
import json
import os
import sqlite3
import urllib.parse
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from lxml import etree as lxml_et
from shapely import wkb as shp_wkb
from shapely.geometry import LineString
from src.api.gps_tracks.db import init_db, open_db, upsert_track
from src.api.gps_tracks.dedup import compute_dedup_key
from src.api.gps_tracks.endpoint import create_gps_router
from src.api.gps_tracks.models import TrackInsert
GPX_NS = "http://www.topografix.com/GPX/1/1"
_FIXTURES_DIR = os.path.join(
os.path.dirname(__file__), "..", "fixtures", "gpx-1.1"
)
_GPX_XSD_PATH = os.path.abspath(os.path.join(_FIXTURES_DIR, "gpx.xsd"))
# ─── Helpers ──────────────────────────────────────────────────────────────
def _make_track(
*,
external_id: str = "T1",
source_id: str = "osm",
name: str | None = "Test trail",
description: str | None = None,
activity_type: str | None = "enduro",
user: str | None = None,
created_at: str | None = "2024-05-12T10:00:00Z",
n_points: int = 10,
length_m: float = 5000.0,
external_url: str | None = "https://www.openstreetmap.org/way/1",
source_priority: int = 50,
base_lon: float = 37.60,
base_lat: float = 55.74,
) -> TrackInsert:
"""Создаёт TrackInsert с реальной WKB-геометрией."""
coords = [(base_lon + i * 0.001, base_lat + i * 0.001) for i in range(n_points)]
line = LineString(coords)
geom_wkb = shp_wkb.dumps(line)
min_lon = min(c[0] for c in coords)
max_lon = max(c[0] for c in coords)
min_lat = min(c[1] for c in coords)
max_lat = max(c[1] for c in coords)
return TrackInsert(
external_id=external_id,
source_id=source_id,
external_url=external_url,
name=name,
description=description,
activity_type=activity_type,
user=user,
created_at=created_at,
length_m=length_m,
points_count=n_points,
geom_wkb=geom_wkb,
min_lon=min_lon,
min_lat=min_lat,
max_lon=max_lon,
max_lat=max_lat,
tags=[],
source_priority=source_priority,
)
def _insert_track(conn: sqlite3.Connection, track: TrackInsert) -> int:
"""Вставляет трек и возвращает его id."""
dedup = compute_dedup_key(
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
{"length_m": track.length_m, "created_at": track.created_at},
)
upsert_track(conn, track, dedup, source_priority=track.source_priority or 50)
cur = conn.cursor()
cur.execute("SELECT id FROM tracks WHERE dedup_key = ?", (dedup,))
return cur.fetchone()["id"]
def _make_app(db_path: str, sources_config_path: str | None = None) -> FastAPI:
app = FastAPI()
router = create_gps_router(db_path, sources_config_path)
app.include_router(router)
return app
def _config_with(sources: dict[str, bool], tmp_path) -> str:
"""Создаёт временный gps_sources.yaml с заданными download_allowed."""
lines = ["sources:"]
for sid, allowed in sources.items():
lines.append(f" - id: {sid}")
lines.append(f" name: \"{sid}\"")
lines.append(" enabled: true")
lines.append(f" license_adr: \"docs/test-{sid}.md\"")
lines.append(f" base_url: \"https://example.com/{sid}\"")
lines.append(f" download_allowed: {'true' if allowed else 'false'}")
path = tmp_path / "gps_sources.yaml"
path.write_text("\n".join(lines), encoding="utf-8")
return str(path)
@pytest.fixture
def osm_db(tmp_path):
"""БД с одним OSM-треком из 10 точек."""
db_path = str(tmp_path / "osm.sqlite")
conn = open_db(db_path)
init_db(conn)
track_id = _insert_track(conn, _make_track())
conn.close()
return db_path, track_id
@pytest.fixture
def osm_db_app(osm_db, tmp_path):
"""App, где OSM разрешён для скачивания (default)."""
db_path, track_id = osm_db
cfg = _config_with({"osm": True, "wikiloc": False}, tmp_path)
return _make_app(db_path, cfg), track_id
# ─── IT-01: happy path ─────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_it01_download_happy_path(osm_db_app):
"""IT-01: GET /api/gps-tracks/{id}/download → 200 + правильные хедеры."""
app, track_id = osm_db_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(f"/api/gps-tracks/{track_id}/download")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/gpx+xml")
cd = resp.headers["content-disposition"]
assert "attachment" in cd
assert "filename*=UTF-8''" in cd
assert resp.text.startswith("<?xml")
assert "<gpx" in resp.text
assert 'version="1.1"' in resp.text
assert resp.text.count("<trkpt") == 10
# ─── IT-02: 404 ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_it02_track_not_found(osm_db_app):
app, _ = osm_db_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get("/api/gps-tracks/99999999/download")
assert resp.status_code == 404
detail = resp.json().get("detail", "")
assert "not_found" in str(detail).lower() or "track_not_found" in str(detail)
# ─── IT-03: невалидный format ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_it03_invalid_format(osm_db_app):
app, track_id = osm_db_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(
f"/api/gps-tracks/{track_id}/download", params={"format": "fit"}
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_it03_explicit_gpx_format_ok(osm_db_app):
"""format=gpx синонимично default'у."""
app, track_id = osm_db_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(
f"/api/gps-tracks/{track_id}/download", params={"format": "gpx"}
)
assert resp.status_code == 200
# ─── IT-04: 413 patho-трек ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_it04_track_too_large(tmp_path):
"""IT-04: points_count > 200000 → 413 (без чтения geom)."""
db_path = str(tmp_path / "huge.sqlite")
conn = open_db(db_path)
init_db(conn)
# Используем upsert обычным путём, потом подменим points_count
track_id = _insert_track(conn, _make_track(name="Huge"))
cur = conn.cursor()
cur.execute("UPDATE tracks SET points_count = 300000 WHERE id = ?", (track_id,))
conn.commit()
conn.close()
cfg = _config_with({"osm": True}, tmp_path)
app = _make_app(db_path, cfg)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(f"/api/gps-tracks/{track_id}/download")
assert resp.status_code == 413
# ─── IT-05: license-фильтр (403) ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_it05_source_forbidden_403(tmp_path):
"""IT-05: трек только с wikiloc → 403 если wikiloc нет в whitelist."""
db_path = str(tmp_path / "wikiloc.sqlite")
conn = open_db(db_path)
init_db(conn)
track_id = _insert_track(
conn,
_make_track(
external_id="W1",
source_id="wikiloc",
external_url="https://www.wikiloc.com/abc",
),
)
conn.close()
# Whitelist только osm → wikiloc запрещён
cfg = _config_with({"osm": True, "wikiloc": False}, tmp_path)
app = _make_app(db_path, cfg)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(f"/api/gps-tracks/{track_id}/download")
assert resp.status_code == 403
body = resp.json()
# FastAPI обёртывает наш detail-dict в {"detail": {...}}.
detail = body.get("detail", body)
if isinstance(detail, dict):
assert detail.get("detail") == "source_forbidden"
assert detail.get("external_urls") == ["https://www.wikiloc.com/abc"]
else:
# Если FastAPI отдал просто строку — должно содержать source_forbidden
assert "source_forbidden" in str(detail)
@pytest.mark.asyncio
async def test_it05_dual_source_with_osm_passes(tmp_path):
"""ADR-015 §B1: ANY-rule — трек с sources=[osm, wikiloc] разрешён."""
db_path = str(tmp_path / "dual.sqlite")
conn = open_db(db_path)
init_db(conn)
# Создаём трек один раз как osm, затем upsert-мерж с wikiloc
t1 = _make_track(
external_id="X1", source_id="osm",
external_url="https://www.openstreetmap.org/way/1",
)
t2 = _make_track(
external_id="X1", source_id="wikiloc",
external_url="https://www.wikiloc.com/x",
source_priority=70,
)
_insert_track(conn, t1)
track_id = _insert_track(conn, t2)
# Проверяем, что записалось два source'а
cur = conn.cursor()
cur.execute("SELECT sources_json FROM tracks WHERE id = ?", (track_id,))
sources = json.loads(cur.fetchone()["sources_json"])
assert "osm" in sources and "wikiloc" in sources
conn.close()
cfg = _config_with({"osm": True, "wikiloc": False}, tmp_path)
app = _make_app(db_path, cfg)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(f"/api/gps-tracks/{track_id}/download")
assert resp.status_code == 200
# ─── IT-06: UTF-8 имя ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_it06_utf8_filename_in_cd(tmp_path):
db_path = str(tmp_path / "ru.sqlite")
conn = open_db(db_path)
init_db(conn)
track_id = _insert_track(
conn,
_make_track(name="По грязи к Чёрному озеру"),
)
conn.close()
cfg = _config_with({"osm": True}, tmp_path)
app = _make_app(db_path, cfg)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(f"/api/gps-tracks/{track_id}/download")
assert resp.status_code == 200
cd = resp.headers["content-disposition"]
assert "filename*=UTF-8''" in cd
# Декодируем RFC 5987 часть
star = cd.split("filename*=UTF-8''", 1)[1]
encoded = star.split(";", 1)[0].strip()
decoded = urllib.parse.unquote(encoded, encoding="utf-8")
assert decoded == "По грязи к Чёрному озеру.gpx"
# ASCII fallback — без кириллицы (проверим, что filename="..." есть)
assert 'filename="' in cd
plain = cd.split('filename="', 1)[1].split('"', 1)[0]
assert all(ord(c) < 128 for c in plain)
# ─── IT-07: валидация GPX по XSD ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_it07_response_validates_against_xsd(osm_db_app):
if not os.path.exists(_GPX_XSD_PATH):
pytest.skip("GPX XSD not present")
app, track_id = osm_db_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp = await client.get(f"/api/gps-tracks/{track_id}/download")
assert resp.status_code == 200
schema = lxml_et.XMLSchema(lxml_et.parse(_GPX_XSD_PATH))
doc = lxml_et.fromstring(resp.content)
schema.assertValid(doc)
# ─── IT-08: регрессия — существующие эндпоинты живы ───────────────────────
@pytest.mark.asyncio
async def test_it08_existing_endpoints_smoke(osm_db_app):
"""IT-08: добавление download не сломало /api/gps-tracks и /health."""
app, _ = osm_db_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
resp_bbox = await client.get(
"/api/gps-tracks", params={"bbox": "37.5,55.7,37.9,55.9"}
)
resp_health = await client.get("/api/gps-tracks/health")
assert resp_bbox.status_code == 200
body = resp_bbox.json()
assert body["type"] == "FeatureCollection"
assert isinstance(body["features"], list)
assert resp_health.status_code == 200
health = resp_health.json()
assert health["status"] == "ok"
assert "tracks_total" in health
# ─── Дополнительно: проверка default-deny при отсутствии конфига ──────────
@pytest.mark.asyncio
async def test_default_deny_without_config(tmp_path):
"""Без sources_config_path whitelist = {osm} (см. ADR-015 §F)."""
db_path = str(tmp_path / "noconfig.sqlite")
conn = open_db(db_path)
init_db(conn)
# OSM трек — должен пройти даже без конфига
osm_id = _insert_track(conn, _make_track(source_id="osm"))
# Wikiloc трек в другом регионе — должен быть отдельной записью с другим
# dedup_key и запрещён к скачиванию.
wiki_id = _insert_track(
conn,
_make_track(
external_id="W1",
source_id="wikiloc",
external_url="https://www.wikiloc.com/y",
base_lon=40.0,
base_lat=50.0,
created_at="2025-01-01T00:00:00Z",
length_m=8888.0,
),
)
conn.close()
# Sanity: треки должны быть разными записями
assert osm_id != wiki_id, (
"test setup: tracks merged into one record via dedup_key"
)
app = _make_app(db_path, sources_config_path=None)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
r_osm = await client.get(f"/api/gps-tracks/{osm_id}/download")
r_wiki = await client.get(f"/api/gps-tracks/{wiki_id}/download")
assert r_osm.status_code == 200
assert r_wiki.status_code == 403

View File

@@ -0,0 +1,95 @@
"""Unit-тесты для ET-011 sanitize/safe_filename (UT-04, REQ-F-04)."""
from __future__ import annotations
import urllib.parse
from src.api.gps_tracks.export import safe_filename
def test_ut04_cyrillic_utf8():
"""UT-04: кириллическое имя → ascii-fallback пустой и читается из utf-8."""
name = "По грязи к Чёрному озеру"
ascii_fb, utf8_quoted = safe_filename(name, 42)
# ascii_fallback содержит только ASCII (после санитизации
# нелатинские символы стали '_'), длина ≤ 80
assert all(ord(c) < 128 for c in ascii_fb)
assert len(ascii_fb) <= 80
# decoded utf-8 совпадает с исходным именем (до триммирования по 80 байтам)
decoded = urllib.parse.unquote(utf8_quoted, encoding="utf-8")
assert decoded == name
def test_ut04_forbidden_chars_replaced():
"""UT-04: запрещённые ФС-символы → '_'."""
name = 'Trail/with:bad*chars?"<>|'
ascii_fb, _ = safe_filename(name, 1)
for ch in '/\\:*?"<>|':
assert ch not in ascii_fb
# должно быть несколько подчёркиваний (хотя бы один на запрещённый символ)
assert "_" in ascii_fb
def test_ut04_empty_name_fallback_track_id():
"""UT-04: пустое имя → 'track-<id>'."""
ascii_fb, utf8_q = safe_filename("", 42)
assert ascii_fb == "track-42"
assert urllib.parse.unquote(utf8_q) == "track-42"
def test_ut04_none_name_fallback_track_id():
ascii_fb, utf8_q = safe_filename(None, 7)
assert ascii_fb == "track-7"
assert urllib.parse.unquote(utf8_q) == "track-7"
def test_ut04_truncated_to_80_bytes():
"""UT-04: длинное ASCII-имя триммится до 80 байт."""
name = "X" * 200
ascii_fb, utf8_q = safe_filename(name, 1)
assert len(ascii_fb.encode("utf-8")) <= 80
# utf8_q после percent-decoding тоже не должен превышать лимит
decoded = urllib.parse.unquote(utf8_q, encoding="utf-8")
assert len(decoded.encode("utf-8")) <= 80
def test_ut04_truncated_utf8_no_broken_codepoints():
"""UT-04: триммирование multibyte-строки не ломает code-point."""
# 200 русских букв = 400 байт UTF-8; триммим до 80 байт → ~40 букв
name = "Я" * 200
ascii_fb, utf8_q = safe_filename(name, 1)
decoded = urllib.parse.unquote(utf8_q, encoding="utf-8")
# должно успешно декодироваться
assert len(decoded) > 0
assert len(decoded.encode("utf-8")) <= 80
def test_ut04_only_forbidden_chars_fallback():
"""UT-04: имя из одних запрещённых символов после strip → fallback."""
ascii_fb, utf8_q = safe_filename("...", 5)
# точки страйпятся, остаётся пустота → fallback
assert ascii_fb == "track-5"
def test_ut04_whitespace_only_fallback():
ascii_fb, _ = safe_filename(" ", 8)
assert ascii_fb == "track-8"
def test_ut04_control_chars_replaced():
"""Управляющие символы (0x00..0x1F, 0x7F) → '_'."""
name = "abc\x00\x01\x1fdef\x7f"
ascii_fb, _ = safe_filename(name, 1)
assert "\x00" not in ascii_fb
assert "\x1f" not in ascii_fb
assert "\x7f" not in ascii_fb
assert "abc" in ascii_fb and "def" in ascii_fb
def test_ut04_ascii_clean_kept_as_is():
"""ASCII-чистое имя сохраняется в ascii-fallback."""
name = "OSM Trail 42"
ascii_fb, utf8_q = safe_filename(name, 1)
assert ascii_fb == "OSM Trail 42"
assert urllib.parse.unquote(utf8_q) == "OSM Trail 42"

View File

@@ -0,0 +1,331 @@
"""Unit-тесты для ET-011 GPX-builder (`src/api/gps_tracks/export.py`).
Покрывает test-plan: UT-01, UT-02, UT-03, UT-05.
"""
from __future__ import annotations
import os
import xml.etree.ElementTree as ET
import pytest
from lxml import etree as lxml_et
from src.api.gps_tracks.export import build_gpx
GPX_NS = "http://www.topografix.com/GPX/1/1"
GPX = "{%s}" % GPX_NS
_FIXTURES_DIR = os.path.join(
os.path.dirname(__file__), "..", "fixtures", "gpx-1.1"
)
_GPX_XSD_PATH = os.path.abspath(os.path.join(_FIXTURES_DIR, "gpx.xsd"))
@pytest.fixture(scope="module")
def gpx_schema() -> lxml_et.XMLSchema:
"""Загружает GPX 1.1 XSD-схему (см. tests/fixtures/gpx-1.1/gpx.xsd)."""
if not os.path.exists(_GPX_XSD_PATH):
pytest.skip(f"GPX XSD not found at {_GPX_XSD_PATH}")
return lxml_et.XMLSchema(lxml_et.parse(_GPX_XSD_PATH))
def _validate_gpx(xml_str: str, schema: lxml_et.XMLSchema) -> None:
"""Валидирует GPX-строку по schema; падает с диагностикой при ошибке."""
doc = lxml_et.fromstring(xml_str.encode("utf-8"))
schema.assertValid(doc)
# ─── UT-01: корректная структура GPX 1.1 ──────────────────────────────────
def test_ut01_build_gpx_basic_structure():
"""UT-01: 5 точек, name/description/external_urls — все элементы на месте."""
xml_str = build_gpx(
track_id=1,
name="Test trail",
description="A short description",
activity_type="enduro",
user="testuser",
created_at="2024-05-12T10:00:00Z",
sources=["osm"],
external_urls=["https://www.openstreetmap.org/way/1"],
coords=[
(37.60, 55.74),
(37.61, 55.75),
(37.62, 55.76),
(37.63, 55.77),
(37.64, 55.78),
],
)
# ET-парсинг — используем тот же ElementTree namespace
root = ET.fromstring(xml_str)
assert root.tag == f"{GPX}gpx"
assert root.attrib["version"] == "1.1"
assert root.attrib["creator"] == "Enduro Trails"
metadata = root.find(f"{GPX}metadata")
assert metadata is not None
name_el = metadata.find(f"{GPX}name")
assert name_el is not None and name_el.text == "Test trail"
link_el = metadata.find(f"{GPX}link")
assert link_el is not None
assert link_el.attrib["href"] == "https://www.openstreetmap.org/way/1"
trks = root.findall(f"{GPX}trk")
assert len(trks) == 1
trk = trks[0]
segs = trk.findall(f"{GPX}trkseg")
assert len(segs) == 1
pts = segs[0].findall(f"{GPX}trkpt")
assert len(pts) == 5
for pt in pts:
# lat/lon — float-парсебельные
lat = float(pt.attrib["lat"])
lon = float(pt.attrib["lon"])
assert -90 <= lat <= 90
assert -180 <= lon <= 180
def test_ut01_metadata_link_text_includes_source():
"""UT-01: text <link> = 'Источник: <source_id>'."""
xml_str = build_gpx(
track_id=1,
name="x",
description=None,
activity_type=None,
user=None,
created_at=None,
sources=["osm"],
external_urls=["https://www.openstreetmap.org/way/42"],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
root = ET.fromstring(xml_str)
link = root.find(f"{GPX}metadata/{GPX}link")
text_el = link.find(f"{GPX}text")
assert text_el is not None
assert text_el.text == "Источник: osm"
def test_ut01_osm_copyright_present():
"""UT-01 / AC-10: для OSM-источника присутствует <copyright> с OSM license."""
xml_str = build_gpx(
track_id=1,
name="osm track",
description=None,
activity_type=None,
user=None,
created_at=None,
sources=["osm"],
external_urls=["https://www.openstreetmap.org/way/123"],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
root = ET.fromstring(xml_str)
cr = root.find(f"{GPX}metadata/{GPX}copyright")
assert cr is not None
assert cr.attrib["author"] == "Enduro Trails"
lic = cr.find(f"{GPX}license")
assert lic is not None
assert lic.text == "https://www.openstreetmap.org/copyright"
# ─── UT-02: пустые / NULL поля ────────────────────────────────────────────
def test_ut02_empty_fields_no_elements():
"""UT-02: <desc>, <time>, <author>, <link> отсутствуют, а не пустые."""
xml_str = build_gpx(
track_id=99,
name=None,
description=None,
activity_type=None,
user=None,
created_at=None,
sources=[],
external_urls=[],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
root = ET.fromstring(xml_str)
metadata = root.find(f"{GPX}metadata")
assert metadata.find(f"{GPX}desc") is None
assert metadata.find(f"{GPX}time") is None
assert metadata.find(f"{GPX}author") is None
assert metadata.find(f"{GPX}link") is None
assert metadata.find(f"{GPX}copyright") is None
name_el = metadata.find(f"{GPX}name")
assert name_el is not None
assert name_el.text == "Без названия"
def test_ut02_empty_name_in_trk_too():
xml_str = build_gpx(
track_id=99,
name="",
description="",
activity_type="",
user="",
created_at=None,
sources=[],
external_urls=[],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
root = ET.fromstring(xml_str)
trk_name = root.find(f"{GPX}trk/{GPX}name")
assert trk_name.text == "Без названия"
# type отсутствует, потому что activity_type пустой
assert root.find(f"{GPX}trk/{GPX}type") is None
# ─── UT-03: соответствие XSD-схеме ───────────────────────────────────────
def test_ut03_xsd_minimal(gpx_schema):
"""UT-03: минимальный трек — без metadata-полей и без activity_type."""
xml_str = build_gpx(
track_id=1,
name=None,
description=None,
activity_type=None,
user=None,
created_at=None,
sources=[],
external_urls=[],
coords=[(37.0, 55.0), (37.1, 55.1)],
)
_validate_gpx(xml_str, gpx_schema)
def test_ut03_xsd_typical(gpx_schema):
"""UT-03: типичный OSM-трек со всеми полями."""
xml_str = build_gpx(
track_id=10,
name="OSM trail in Moscow",
description="A nice trail",
activity_type="enduro",
user="alice",
created_at="2024-05-12T10:00:00Z",
sources=["osm"],
external_urls=["https://www.openstreetmap.org/way/1"],
coords=[(37.6 + i / 100, 55.7 + i / 100) for i in range(20)],
)
_validate_gpx(xml_str, gpx_schema)
def test_ut03_xsd_utf8_name(gpx_schema):
"""UT-03: UTF-8 имя/описание не ломают XSD-валидацию."""
xml_str = build_gpx(
track_id=42,
name="По грязи к Чёрному озеру",
description="Тестовое описание с & < > символами",
activity_type="enduro",
user="ivan",
created_at="2025-06-01T12:34:56+03:00",
sources=["osm", "enduro_russia"],
external_urls=[
"https://www.openstreetmap.org/way/9",
"https://endurorussia.ru/tracks/9",
],
coords=[(37.6, 55.7), (37.7, 55.8), (37.8, 55.9)],
)
_validate_gpx(xml_str, gpx_schema)
# ─── UT-05: smoke for wkb_to_coords boundary (2 точки) ──────────────────
def test_ut05_two_point_coords():
"""UT-05: минимальный LineString (2 точки) собирается корректно."""
xml_str = build_gpx(
track_id=1,
name="two-pt",
description=None,
activity_type=None,
user=None,
created_at=None,
sources=[],
external_urls=[],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
root = ET.fromstring(xml_str)
pts = root.findall(f"{GPX}trk/{GPX}trkseg/{GPX}trkpt")
assert len(pts) == 2
# ─── Дополнительные проверки структуры ──────────────────────────────────
def test_xml_declaration_present():
xml_str = build_gpx(
track_id=1,
name="x",
description=None,
activity_type=None,
user=None,
created_at=None,
sources=[],
external_urls=[],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
assert xml_str.startswith("<?xml")
def test_trkpt_coordinate_precision_6_digits():
"""ADR-014 §G: lat/lon с фиксированной точностью 6 знаков."""
xml_str = build_gpx(
track_id=1,
name="x",
description=None,
activity_type=None,
user=None,
created_at=None,
sources=[],
external_urls=[],
coords=[(37.123456789, 55.987654321)],
)
root = ET.fromstring(xml_str)
pt = root.find(f"{GPX}trk/{GPX}trkseg/{GPX}trkpt")
# 6 знаков после точки
assert pt.attrib["lon"] == "37.123457"
assert pt.attrib["lat"] == "55.987654"
def test_non_osm_source_no_osm_copyright():
"""ADR-014 §G: для не-OSM источников нет OSM-license в <copyright>."""
xml_str = build_gpx(
track_id=1,
name="wikiloc-only",
description=None,
activity_type=None,
user=None,
created_at=None,
sources=["wikiloc"],
external_urls=["https://www.wikiloc.com/x"],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
root = ET.fromstring(xml_str)
cr = root.find(f"{GPX}metadata/{GPX}copyright")
# либо <copyright> отсутствует, либо license != OSM URL
if cr is not None:
lic = cr.find(f"{GPX}license")
assert lic is None or lic.text != "https://www.openstreetmap.org/copyright"
def test_time_normalized_to_utc():
"""ADR-014 §G: <metadata><time> приводится к UTC с суффиксом Z."""
xml_str = build_gpx(
track_id=1,
name="x",
description=None,
activity_type=None,
user=None,
created_at="2024-05-12T13:00:00+03:00",
sources=[],
external_urls=[],
coords=[(0.0, 0.0), (1.0, 1.0)],
)
root = ET.fromstring(xml_str)
time_el = root.find(f"{GPX}metadata/{GPX}time")
assert time_el is not None
# +03:00 → UTC = 10:00:00Z
assert time_el.text == "2024-05-12T10:00:00Z"