Files
enduro-trails/tests/api/test_gps_tracks_gpx_builder.py
claude-bot eea6c846c2
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
feat(gps-tracks): GPX download from public track popup
Реализация 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>
2026-06-03 20:59:53 +00:00

332 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"