Files
enduro-trails/tests/api/test_gps_tracks_sources_osm.py
claude-bot 0060003f28
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped
feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
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>
2026-06-01 12:28:54 +00:00

183 lines
6.9 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 тесты для 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