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>
This commit is contained in:
412
tests/api/test_gps_tracks_download.py
Normal file
412
tests/api/test_gps_tracks_download.py
Normal 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
|
||||
95
tests/api/test_gps_tracks_filename.py
Normal file
95
tests/api/test_gps_tracks_filename.py
Normal 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"
|
||||
331
tests/api/test_gps_tracks_gpx_builder.py
Normal file
331
tests/api/test_gps_tracks_gpx_builder.py
Normal 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"
|
||||
Reference in New Issue
Block a user