Backend:
- Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs
- Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup
(bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint
(GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt,
GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config
- Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита),
enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы)
- Licensing guard: pipeline проверяет status ADR-файла до запуска источника
- scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc
Frontend:
- src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12),
debounced fetch + AbortController, фильтры активности/источника,
цветовая палитра by-source/by-activity, halo на спутнике, popup трека,
restorePublicTracksState(), localStorage persistence
- index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters
- app.css: .terrain-link-btn, .gps-filter-grid, .track-popup
- app.js: вызов restorePublicTracksState() в rebuildMapOverlays(),
applyGpsHaloVisibility() в applyBaseLayer()
Конфиги:
- config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled)
- config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled)
Docker:
- gps-collector service с profiles: [batch]
Тесты: 48 новых тестов (unit + integration), 125/125 pass
Refs: ET-008
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
183 lines
6.9 KiB
Python
183 lines
6.9 KiB
Python
"""Unit тесты для OSM GPS-источника (ET-008).
|
||
|
||
U-42: split_bbox_for_osm разбивает правильно
|
||
U-43: длина через Haversine
|
||
U-44: защита от XXE через defusedxml
|
||
"""
|
||
import os
|
||
import pytest
|
||
|
||
from src.api.gps_tracks.sources.osm import (
|
||
OsmParser,
|
||
split_bbox_for_osm,
|
||
_haversine_m,
|
||
_parse_gpx_trackpoints,
|
||
)
|
||
|
||
|
||
# ─── U-42: split_bbox_for_osm ────────────────────────────────────────────────
|
||
|
||
def test_u42_split_bbox_basic():
|
||
"""U-42: корректное разбиение на ячейки."""
|
||
bbox = (37.0, 55.0, 38.0, 56.0) # 1° x 1°
|
||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||
|
||
# 1° / 0.25° = 4 ячейки по каждой оси = 16 ячеек
|
||
assert len(cells) == 16
|
||
|
||
|
||
def test_u42_split_bbox_cell_size():
|
||
"""U-42: каждая ячейка не больше cell_size по размеру."""
|
||
bbox = (29.0, 49.5, 47.5, 60.0) # ЦФО
|
||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||
|
||
for cell in cells:
|
||
west, south, east, north = cell
|
||
assert east - west <= 0.25 + 1e-9
|
||
assert north - south <= 0.25 + 1e-9
|
||
|
||
|
||
def test_u42_split_bbox_covers_region():
|
||
"""U-42: все ячейки вместе покрывают весь регион."""
|
||
bbox = (37.0, 55.0, 38.0, 56.0)
|
||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||
|
||
min_lon = min(c[0] for c in cells)
|
||
min_lat = min(c[1] for c in cells)
|
||
max_lon = max(c[2] for c in cells)
|
||
max_lat = max(c[3] for c in cells)
|
||
|
||
assert abs(min_lon - 37.0) < 1e-9
|
||
assert abs(min_lat - 55.0) < 1e-9
|
||
assert abs(max_lon - 38.0) < 0.25 + 1e-9 # последняя ячейка обрезается
|
||
assert abs(max_lat - 56.0) < 0.25 + 1e-9
|
||
|
||
|
||
def test_u42_split_small_bbox():
|
||
"""U-42: bbox меньше cell_size даёт одну ячейку."""
|
||
bbox = (37.0, 55.0, 37.1, 55.1)
|
||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||
assert len(cells) == 1
|
||
|
||
|
||
def test_u42_split_bbox_no_overlap():
|
||
"""U-42: ячейки не перекрываются (west следующей = east предыдущей)."""
|
||
bbox = (37.0, 55.0, 37.5, 55.25)
|
||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||
|
||
# При bbox шириной 0.5° и cell_size=0.25 должно быть 2 ячейки по оси lon
|
||
assert len(cells) == 2
|
||
# Восток первой ячейки = запад второй
|
||
cells_sorted = sorted(cells, key=lambda c: c[0])
|
||
assert abs(cells_sorted[0][2] - cells_sorted[1][0]) < 1e-9
|
||
|
||
|
||
# ─── U-43: Haversine длина ───────────────────────────────────────────────────
|
||
|
||
def test_u43_haversine_known_distance():
|
||
"""U-43: проверка haversine на известном расстоянии."""
|
||
# Москва (37.617, 55.755) → Химки (37.425, 55.889) ≈ 20 км
|
||
dist = _haversine_m(37.617, 55.755, 37.425, 55.889)
|
||
assert 18000 < dist < 22000
|
||
|
||
|
||
def test_u43_haversine_zero_distance():
|
||
"""U-43: одна точка → расстояние 0."""
|
||
dist = _haversine_m(37.617, 55.755, 37.617, 55.755)
|
||
assert dist == pytest.approx(0.0, abs=1e-6)
|
||
|
||
|
||
def test_u43_haversine_symmetry():
|
||
"""U-43: расстояние A→B = B→A."""
|
||
d1 = _haversine_m(37.617, 55.755, 37.425, 55.889)
|
||
d2 = _haversine_m(37.425, 55.889, 37.617, 55.755)
|
||
assert abs(d1 - d2) < 1e-6
|
||
|
||
|
||
def test_u43_haversine_short_distance():
|
||
"""U-43: короткое расстояние (~111 м на экваторе при 0.001° по lon)."""
|
||
dist = _haversine_m(0.0, 0.0, 0.001, 0.0)
|
||
assert 100 < dist < 120
|
||
|
||
|
||
# ─── U-44: защита от XXE ─────────────────────────────────────────────────────
|
||
|
||
def test_u44_xxe_protection():
|
||
"""U-44: defusedxml блокирует XXE атаку."""
|
||
fixture_path = os.path.join(
|
||
os.path.dirname(__file__),
|
||
"../../tests/fixtures/gps-tracks/xxe-payload.gpx",
|
||
)
|
||
|
||
with open(fixture_path, "rb") as f:
|
||
content = f.read()
|
||
|
||
# Должен либо выбросить исключение, либо вернуть пустой список без чтения /etc/passwd
|
||
try:
|
||
tracks = _parse_gpx_trackpoints(content, "osm", "")
|
||
# Если парсинг прошёл без ошибки — проверяем что /etc/passwd не попал в данные
|
||
for track in tracks:
|
||
assert "root:" not in str(track)
|
||
assert "/bin/" not in str(track)
|
||
except Exception:
|
||
# defusedxml выбросил исключение — это ожидаемое поведение
|
||
pass
|
||
|
||
|
||
def test_u44_valid_gpx_parsed_correctly():
|
||
"""U-44: корректный GPX с gpx_id парсится правильно."""
|
||
fixture_path = os.path.join(
|
||
os.path.dirname(__file__),
|
||
"../../tests/fixtures/gps-tracks/osm-trackpoints-bbox-moscow.gpx",
|
||
)
|
||
|
||
with open(fixture_path, "rb") as f:
|
||
content = f.read()
|
||
|
||
tracks = _parse_gpx_trackpoints(content, "osm", "")
|
||
|
||
assert len(tracks) == 1
|
||
track = tracks[0]
|
||
assert track.points_count == 3
|
||
assert abs(track.min_lat - 55.751) < 0.001
|
||
assert abs(track.max_lat - 55.753) < 0.001
|
||
assert track.source_id == "osm"
|
||
|
||
|
||
def test_u44_anonymous_trackpoints_skipped():
|
||
"""U-44: анонимные точки без gpx_id пропускаются."""
|
||
gpx_without_ids = b"""<?xml version="1.0"?>
|
||
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
|
||
<trk>
|
||
<trkseg>
|
||
<trkpt lat="55.751" lon="37.618"><time>2024-05-12T10:00:00Z</time></trkpt>
|
||
<trkpt lat="55.752" lon="37.619"><time>2024-05-12T10:01:00Z</time></trkpt>
|
||
</trkseg>
|
||
</trk>
|
||
</gpx>"""
|
||
|
||
tracks = _parse_gpx_trackpoints(gpx_without_ids, "osm", "")
|
||
assert len(tracks) == 0
|
||
|
||
|
||
def test_u44_multiple_tracks_in_gpx():
|
||
"""U-44: несколько gpx_id в одном ответе парсятся как разные треки."""
|
||
gpx_multi = b"""<?xml version="1.0"?>
|
||
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
|
||
<trk>
|
||
<trkseg>
|
||
<trkpt lat="55.751" lon="37.618" gpx_id="111"><time>2024-05-12T10:00:00Z</time></trkpt>
|
||
<trkpt lat="55.752" lon="37.619" gpx_id="111"><time>2024-05-12T10:01:00Z</time></trkpt>
|
||
<trkpt lat="55.760" lon="37.700" gpx_id="222"><time>2024-05-13T08:00:00Z</time></trkpt>
|
||
<trkpt lat="55.765" lon="37.710" gpx_id="222"><time>2024-05-13T08:05:00Z</time></trkpt>
|
||
</trkseg>
|
||
</trk>
|
||
</gpx>"""
|
||
|
||
tracks = _parse_gpx_trackpoints(gpx_multi, "osm", "")
|
||
assert len(tracks) == 2
|
||
|
||
ids = {t.external_id for t in tracks}
|
||
assert "111" in ids
|
||
assert "222" in ids
|