Реализация 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>
332 lines
11 KiB
Python
332 lines
11 KiB
Python
"""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"
|