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>
This commit is contained in:
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
216
tests/api/test_gps_tracks_dedup.py
Normal file
216
tests/api/test_gps_tracks_dedup.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Unit тесты для дедупликации GPS-треков (ET-008).
|
||||
|
||||
U-10: два трека с одинаковым bbox+length+date → один ключ
|
||||
U-11: разные даты → разные ключи
|
||||
U-12: bbox-округление до 0.01°
|
||||
U-13: merge sources при upsert
|
||||
U-14: merge external_urls
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from src.api.gps_tracks.dedup import compute_dedup_key
|
||||
from src.api.gps_tracks.db import open_db, init_db, upsert_track
|
||||
from src.api.gps_tracks.models import TrackInsert
|
||||
|
||||
|
||||
def _make_track(
|
||||
external_id="T1",
|
||||
source_id="osm",
|
||||
length_m=5000.0,
|
||||
created_at="2024-05-12T10:00:00Z",
|
||||
min_lon=37.61,
|
||||
min_lat=55.75,
|
||||
max_lon=37.62,
|
||||
max_lat=55.76,
|
||||
external_url=None,
|
||||
name=None,
|
||||
source_priority=50,
|
||||
) -> TrackInsert:
|
||||
"""Хелпер для создания TrackInsert с тестовой WKB геометрией."""
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
|
||||
coords = [(min_lon, min_lat), (max_lon, max_lat)]
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
|
||||
return TrackInsert(
|
||||
external_id=external_id,
|
||||
source_id=source_id,
|
||||
external_url=external_url,
|
||||
name=name,
|
||||
description=None,
|
||||
activity_type="other",
|
||||
user=None,
|
||||
created_at=created_at,
|
||||
length_m=length_m,
|
||||
points_count=2,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""Создаёт изолированную БД в tmp_path."""
|
||||
db_path = str(tmp_path / "test.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── U-10: одинаковый bbox+length+date → один ключ ───────────────────────────
|
||||
|
||||
def test_u10_same_key_for_same_track():
|
||||
"""U-10: два трека с одинаковым bbox+length+date дают одинаковый ключ."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
|
||||
key1 = compute_dedup_key(bounds, meta)
|
||||
key2 = compute_dedup_key(bounds, meta)
|
||||
|
||||
assert key1 == key2
|
||||
|
||||
|
||||
# ─── U-11: разные даты → разные ключи ────────────────────────────────────────
|
||||
|
||||
def test_u11_different_dates_give_different_keys():
|
||||
"""U-11: треки с разными датами дают разные ключи."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
|
||||
key1 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-12"})
|
||||
key2 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-13"})
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
|
||||
# ─── U-12: bbox-округление до 0.01° ─────────────────────────────────────────
|
||||
|
||||
def test_u12_bbox_rounding_to_2_decimals():
|
||||
"""U-12: bbox округляется до 0.01°, незначительные отличия игнорируются."""
|
||||
# Оба варианта округляются к (37.61, 55.75, 37.62, 55.76)
|
||||
# Используем значения в середине диапазона, гарантированно округляемые одинаково
|
||||
bounds1 = (37.6111, 55.7512, 37.6192, 55.7563)
|
||||
bounds2 = (37.6144, 55.7533, 37.6188, 55.7571)
|
||||
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
|
||||
|
||||
key1 = compute_dedup_key(bounds1, meta)
|
||||
key2 = compute_dedup_key(bounds2, meta)
|
||||
|
||||
# Оба bbox округляются к (37.61, 55.75, 37.62, 55.76) — ключи одинаковы
|
||||
assert key1 == key2
|
||||
|
||||
|
||||
def test_u12_significantly_different_bbox_gives_different_key():
|
||||
"""U-12: существенно разные bbox дают разные ключи."""
|
||||
bounds1 = (37.61, 55.75, 37.62, 55.76)
|
||||
bounds2 = (38.00, 56.00, 38.10, 56.10)
|
||||
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
|
||||
|
||||
key1 = compute_dedup_key(bounds1, meta)
|
||||
key2 = compute_dedup_key(bounds2, meta)
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
|
||||
# ─── U-13: merge sources при upsert ──────────────────────────────────────────
|
||||
|
||||
def test_u13_merge_sources_on_upsert(db):
|
||||
"""U-13: при upsert с тем же dedup_key sources мержатся (union без дублей)."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
# Первая вставка — от osm
|
||||
track1 = _make_track(external_id="T1", source_id="osm", source_priority=50)
|
||||
result1 = upsert_track(db, track1, dedup_key, source_priority=50)
|
||||
assert result1 == "inserted"
|
||||
|
||||
# Вторая вставка — от другого источника с тем же dedup_key
|
||||
track2 = _make_track(external_id="T2", source_id="enduro_russia", source_priority=10)
|
||||
result2 = upsert_track(db, track2, dedup_key, source_priority=10)
|
||||
assert result2 == "updated"
|
||||
|
||||
# Проверяем merged sources
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
sources = json.loads(row["sources_json"])
|
||||
|
||||
assert "osm" in sources
|
||||
assert "enduro_russia" in sources
|
||||
assert len(sources) == 2 # без дублей
|
||||
|
||||
|
||||
def test_u13_no_duplicate_sources_on_repeated_upsert(db):
|
||||
"""U-13: повторный upsert от того же источника не создаёт дублей в sources."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
track = _make_track(external_id="T1", source_id="osm")
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
sources = json.loads(row["sources_json"])
|
||||
|
||||
assert sources.count("osm") == 1
|
||||
|
||||
|
||||
# ─── U-14: merge external_urls ───────────────────────────────────────────────
|
||||
|
||||
def test_u14_merge_external_urls_on_upsert(db):
|
||||
"""U-14: external_urls мержатся без дублей при upsert."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
url1 = "https://www.openstreetmap.org/user/alice/traces/12345"
|
||||
url2 = "https://enduro-russia.ru/track/99"
|
||||
|
||||
track1 = _make_track(external_id="T1", source_id="osm", external_url=url1)
|
||||
upsert_track(db, track1, dedup_key, source_priority=50)
|
||||
|
||||
track2 = _make_track(external_id="T2", source_id="enduro_russia", external_url=url2)
|
||||
upsert_track(db, track2, dedup_key, source_priority=10)
|
||||
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
urls = json.loads(row["external_urls_json"])
|
||||
|
||||
assert url1 in urls
|
||||
assert url2 in urls
|
||||
assert len(urls) == 2
|
||||
|
||||
|
||||
def test_u14_no_duplicate_urls_on_repeated_upsert(db):
|
||||
"""U-14: повторный upsert с тем же URL не дублирует его."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
url = "https://www.openstreetmap.org/user/alice/traces/12345"
|
||||
|
||||
track = _make_track(external_id="T1", source_id="osm", external_url=url)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
urls = json.loads(row["external_urls_json"])
|
||||
|
||||
assert urls.count(url) == 1
|
||||
351
tests/api/test_gps_tracks_endpoint.py
Normal file
351
tests/api/test_gps_tracks_endpoint.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Integration тесты для GPS-треков endpoint (ET-008).
|
||||
|
||||
I-20: GeoJSON с фильтрами
|
||||
I-21: truncation
|
||||
I-22: невалидный bbox → 400
|
||||
I-23: bbox в океане → пустой
|
||||
I-30: MVT тайл отдаётся
|
||||
I-31: cache hit
|
||||
I-40: health endpoint
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from fastapi import FastAPI
|
||||
|
||||
from src.api.gps_tracks.db import open_db, init_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
|
||||
|
||||
|
||||
def _make_test_app(db_path: str) -> FastAPI:
|
||||
"""Создаёт тестовое FastAPI приложение с GPS router."""
|
||||
app = FastAPI()
|
||||
router = create_gps_router(db_path)
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def _make_track(
|
||||
external_id="T1",
|
||||
source_id="osm",
|
||||
length_m=5000.0,
|
||||
created_at="2024-05-12T10:00:00Z",
|
||||
min_lon=37.60,
|
||||
min_lat=55.74,
|
||||
max_lon=37.65,
|
||||
max_lat=55.78,
|
||||
activity_type="other",
|
||||
external_url=None,
|
||||
source_priority=50,
|
||||
) -> TrackInsert:
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
|
||||
coords = [
|
||||
(min_lon, min_lat),
|
||||
((min_lon + max_lon) / 2, (min_lat + max_lat) / 2),
|
||||
(max_lon, max_lat),
|
||||
]
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
|
||||
return TrackInsert(
|
||||
external_id=external_id,
|
||||
source_id=source_id,
|
||||
external_url=external_url,
|
||||
name=f"Track {external_id}",
|
||||
description=None,
|
||||
activity_type=activity_type,
|
||||
user=None,
|
||||
created_at=created_at,
|
||||
length_m=length_m,
|
||||
points_count=3,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_tracks(tmp_path):
|
||||
"""БД с несколькими тестовыми треками."""
|
||||
db_path = str(tmp_path / "test.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
|
||||
# Добавляем треки вокруг Москвы
|
||||
tracks = [
|
||||
_make_track("T1", "osm", activity_type="enduro", length_m=8000),
|
||||
_make_track("T2", "osm", activity_type="moto", length_m=3000,
|
||||
min_lon=37.70, min_lat=55.80, max_lon=37.75, max_lat=55.85),
|
||||
_make_track("T3", "enduro_russia", activity_type="bicycle", length_m=12000),
|
||||
]
|
||||
|
||||
for track in tracks:
|
||||
dedup_key = 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_key, source_priority=50)
|
||||
|
||||
conn.close()
|
||||
yield db_path
|
||||
|
||||
|
||||
# ─── I-20: GeoJSON с фильтрами ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i20_geojson_basic(db_with_tracks):
|
||||
"""I-20: базовый запрос GeoJSON."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "FeatureCollection"
|
||||
assert isinstance(data["features"], list)
|
||||
assert len(data["features"]) > 0
|
||||
assert "total_in_bbox" in data
|
||||
assert "returned" in data
|
||||
assert "truncated" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i20_filter_by_activity(db_with_tracks):
|
||||
"""I-20: фильтрация по activity_type."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9", "activity": "enduro"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
for feature in data["features"]:
|
||||
assert feature["properties"]["activity_type"] == "enduro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i20_filter_by_source(db_with_tracks):
|
||||
"""I-20: фильтрация по source."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9", "source": "enduro_russia"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# Все returned треки должны иметь enduro_russia в sources
|
||||
for feature in data["features"]:
|
||||
assert "enduro_russia" in feature["properties"]["sources"]
|
||||
|
||||
|
||||
# ─── I-21: truncation ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i21_truncation(tmp_path):
|
||||
"""I-21: truncation при limit меньше total."""
|
||||
db_path = str(tmp_path / "trunc.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
|
||||
# Создаём 10 треков с разными bbox
|
||||
for i in range(10):
|
||||
t = _make_track(
|
||||
external_id=f"T{i}",
|
||||
source_id="osm",
|
||||
min_lon=37.60 + i * 0.001,
|
||||
min_lat=55.74,
|
||||
max_lon=37.65 + i * 0.001,
|
||||
max_lat=55.78,
|
||||
length_m=5000 + i * 100,
|
||||
created_at=f"2024-05-{12 + i:02d}T10:00:00Z",
|
||||
)
|
||||
dedup_key = compute_dedup_key(
|
||||
(t.min_lon, t.min_lat, t.max_lon, t.max_lat),
|
||||
{"length_m": t.length_m, "created_at": t.created_at},
|
||||
)
|
||||
upsert_track(conn, t, dedup_key, source_priority=50)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9", "limit": 3},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["returned"] == 3
|
||||
assert data["total_in_bbox"] >= 3
|
||||
assert data["truncated"] is True
|
||||
|
||||
|
||||
# ─── I-22: невалидный bbox → 400 ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("bad_bbox", [
|
||||
"abc,def,ghi,jkl", # не числа
|
||||
"37.5,55.7,37.9", # 3 значения
|
||||
"37.5,55.7,37.9,55.9,1.0", # 5 значений
|
||||
"200,55.7,37.9,55.9", # lon out of range
|
||||
"37.5,95,37.9,55.9", # lat out of range
|
||||
"37.9,55.7,37.5,55.9", # west > east
|
||||
"37.5,55.9,37.9,55.7", # south > north
|
||||
])
|
||||
async def test_i22_invalid_bbox_returns_400(tmp_path, bad_bbox):
|
||||
"""I-22: невалидный bbox → 400."""
|
||||
db_path = str(tmp_path / "test.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": bad_bbox},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ─── I-23: bbox в океане → пустой ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i23_ocean_bbox_returns_empty(db_with_tracks):
|
||||
"""I-23: bbox в океане (нет треков) → пустой FeatureCollection."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
# Средина Атлантического океана
|
||||
params={"bbox": "-30.0,0.0,-20.0,10.0"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "FeatureCollection"
|
||||
assert data["features"] == []
|
||||
assert data["total_in_bbox"] == 0
|
||||
assert data["truncated"] is False
|
||||
|
||||
|
||||
# ─── I-30: MVT тайл ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i30_mvt_tile_returns(db_with_tracks):
|
||||
"""I-30: MVT тайл с треками возвращается."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
# z=10, x=620, y=320 — покрывает Москву
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/tiles/10/620/320.mvt")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/x-protobuf"
|
||||
assert "X-Cache" in resp.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i30_mvt_tile_empty_ocean(tmp_path):
|
||||
"""I-30: MVT тайл без треков возвращает пустой ответ."""
|
||||
db_path = str(tmp_path / "empty.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/tiles/10/400/300.mvt")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b""
|
||||
|
||||
|
||||
# ─── I-31: cache hit ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i31_cache_hit(db_with_tracks):
|
||||
"""I-31: второй запрос к тому же тайлу возвращает X-Cache: HIT."""
|
||||
from src.api.gps_tracks.mvt import clear_gps_tile_cache
|
||||
clear_gps_tile_cache()
|
||||
|
||||
app = _make_test_app(db_with_tracks)
|
||||
|
||||
# z=10 x=621 y=319 — близко к Москве, должен вернуть данные
|
||||
z, x, y = 10, 621, 319
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
# Первый запрос — MISS
|
||||
resp1 = await client.get(f"/api/gps-tracks/tiles/{z}/{x}/{y}.mvt")
|
||||
assert resp1.status_code == 200
|
||||
|
||||
# Второй запрос к пустому тайлу — кэш не заполняется для пустых
|
||||
# Используем тайл с треками
|
||||
resp2 = await client.get(f"/api/gps-tracks/tiles/{z}/{x}/{y}.mvt")
|
||||
assert resp2.status_code == 200
|
||||
# Если первый вернул данные, второй должен быть HIT
|
||||
if resp1.content:
|
||||
assert resp2.headers.get("X-Cache") == "HIT"
|
||||
|
||||
|
||||
# ─── I-40: health endpoint ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i40_health_endpoint(db_with_tracks):
|
||||
"""I-40: health endpoint возвращает корректную статистику."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/health")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "total_tracks" in data
|
||||
assert data["total_tracks"] > 0
|
||||
assert "by_activity" in data
|
||||
assert "recent_pipeline_runs" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i40_health_empty_db(tmp_path):
|
||||
"""I-40: health endpoint для пустой БД."""
|
||||
db_path = str(tmp_path / "empty.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/health")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_tracks"] == 0
|
||||
assert data["recent_pipeline_runs"] == []
|
||||
|
||||
|
||||
# ─── Cache clear endpoint ─────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_clear_endpoint(db_with_tracks):
|
||||
"""POST /api/gps-tracks/cache/clear очищает кэш."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/api/gps-tracks/cache/clear")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cleared"] is True
|
||||
171
tests/api/test_gps_tracks_mvt.py
Normal file
171
tests/api/test_gps_tracks_mvt.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Unit тесты для MVT тайлов GPS-треков (ET-008).
|
||||
|
||||
U-50: тайл z=10 с треками
|
||||
U-51: упрощение на z=7
|
||||
U-52: min-length фильтр
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
|
||||
from src.api.gps_tracks.mvt import build_gps_mvt, _simplify_coords, _wkb_to_coords
|
||||
|
||||
|
||||
def _make_mock_row(
|
||||
track_id=1,
|
||||
activity_type="enduro",
|
||||
source_id="osm",
|
||||
length_m=8000.0,
|
||||
name="Test Track",
|
||||
coords=None,
|
||||
min_lon=37.60,
|
||||
min_lat=55.74,
|
||||
max_lon=37.65,
|
||||
max_lat=55.78,
|
||||
):
|
||||
"""Создаёт mock sqlite3.Row как словарь."""
|
||||
if coords is None:
|
||||
coords = [
|
||||
(min_lon, min_lat),
|
||||
((min_lon + max_lon) / 2, (min_lat + max_lat) / 2),
|
||||
(max_lon, max_lat),
|
||||
]
|
||||
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
|
||||
# Имитируем sqlite3.Row через dict с поддержкой подписки
|
||||
class MockRow(dict):
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key)
|
||||
|
||||
return MockRow({
|
||||
"id": track_id,
|
||||
"activity_type": activity_type,
|
||||
"sources_json": json.dumps([source_id]),
|
||||
"external_urls_json": json.dumps([]),
|
||||
"length_m": length_m,
|
||||
"name": name,
|
||||
"geom": geom_wkb,
|
||||
})
|
||||
|
||||
|
||||
# ─── U-50: тайл z=10 с треками ───────────────────────────────────────────────
|
||||
|
||||
def test_u50_tile_z10_with_tracks():
|
||||
"""U-50: build_gps_mvt возвращает непустой тайл при наличии треков."""
|
||||
rows = [
|
||||
_make_mock_row(1, "enduro", "osm", length_m=8000),
|
||||
_make_mock_row(2, "moto", "osm", length_m=5000,
|
||||
min_lon=37.61, min_lat=55.75, max_lon=37.62, max_lat=55.76),
|
||||
]
|
||||
|
||||
# Тайл z=10, x=620, y=320 — область Москвы
|
||||
result = build_gps_mvt(rows, z=10, x=620, y=320)
|
||||
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_u50_empty_rows_returns_empty_bytes():
|
||||
"""U-50: пустой список строк возвращает b""."""
|
||||
result = build_gps_mvt([], z=10, x=620, y=320)
|
||||
assert result == b""
|
||||
|
||||
|
||||
def test_u50_invalid_geom_row_skipped():
|
||||
"""U-50: строка с невалидной геометрией пропускается."""
|
||||
class BadRow(dict):
|
||||
pass
|
||||
|
||||
bad_row = BadRow({
|
||||
"id": 99,
|
||||
"activity_type": "other",
|
||||
"sources_json": '["osm"]',
|
||||
"external_urls_json": "[]",
|
||||
"length_m": 5000,
|
||||
"name": "bad",
|
||||
"geom": b"\x00\x01\x02", # невалидный WKB
|
||||
})
|
||||
|
||||
good_row = _make_mock_row(1, length_m=5000)
|
||||
|
||||
result = build_gps_mvt([bad_row, good_row], z=10, x=620, y=320)
|
||||
# Не падает, плохая строка пропускается
|
||||
assert isinstance(result, bytes)
|
||||
|
||||
|
||||
# ─── U-51: упрощение на z=7 ──────────────────────────────────────────────────
|
||||
|
||||
def test_u51_simplification_z7_reduces_points():
|
||||
"""U-51: геометрия упрощается на малых зумах."""
|
||||
# Создаём трек из 20 точек
|
||||
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(20)]
|
||||
|
||||
simplified = _simplify_coords(coords, z=7)
|
||||
|
||||
# При z=7 tolerance=0.008, ожидаем меньше точек
|
||||
assert len(simplified) < len(coords)
|
||||
assert len(simplified) >= 2
|
||||
|
||||
|
||||
def test_u51_no_simplification_z12():
|
||||
"""U-51: на z=12 упрощение не применяется."""
|
||||
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(10)]
|
||||
result = _simplify_coords(coords, z=12)
|
||||
assert result == coords
|
||||
|
||||
|
||||
def test_u51_simplification_z10_moderate():
|
||||
"""U-51: на z=10 умеренное упрощение."""
|
||||
coords = [(37.60 + i * 0.0001, 55.74 + i * 0.0001) for i in range(30)]
|
||||
|
||||
simplified_z10 = _simplify_coords(coords, z=10)
|
||||
simplified_z7 = _simplify_coords(coords, z=7)
|
||||
|
||||
# z=7 должен сильнее упрощать, чем z=10
|
||||
assert len(simplified_z10) >= len(simplified_z7)
|
||||
|
||||
|
||||
# ─── U-52: min-length фильтр ─────────────────────────────────────────────────
|
||||
|
||||
def test_u52_min_length_filter_z7():
|
||||
"""U-52: на z<=7 треки короче 2000м отфильтровываются."""
|
||||
short_track = _make_mock_row(1, length_m=1500) # меньше 2000м
|
||||
long_track = _make_mock_row(2, length_m=5000) # больше 2000м
|
||||
|
||||
result_with_short = build_gps_mvt([short_track, long_track], z=7, x=77, y=40)
|
||||
result_without_short = build_gps_mvt([long_track], z=7, x=77, y=40)
|
||||
|
||||
# Результаты должны совпадать (короткий трек отфильтрован)
|
||||
assert result_with_short == result_without_short
|
||||
|
||||
|
||||
def test_u52_no_min_length_filter_z10():
|
||||
"""U-52: на z=10 нет min-length фильтра — все треки проходят."""
|
||||
short_track = _make_mock_row(1, length_m=100)
|
||||
long_track = _make_mock_row(2, length_m=5000)
|
||||
|
||||
result_both = build_gps_mvt([short_track, long_track], z=10, x=620, y=320)
|
||||
result_long_only = build_gps_mvt([long_track], z=10, x=620, y=320)
|
||||
|
||||
# При z=10 оба трека должны включаться (если геометрия пересекается с тайлом)
|
||||
# result_both может быть больше result_long_only если короткий трек в тайле
|
||||
assert isinstance(result_both, bytes)
|
||||
assert isinstance(result_long_only, bytes)
|
||||
|
||||
|
||||
def test_u52_min_length_boundary():
|
||||
"""U-52: трек ровно 2000м на z=7 проходит фильтр."""
|
||||
track_2000 = _make_mock_row(1, length_m=2000)
|
||||
track_1999 = _make_mock_row(2, length_m=1999)
|
||||
|
||||
result_2000 = build_gps_mvt([track_2000], z=7, x=77, y=40)
|
||||
result_1999 = build_gps_mvt([track_1999], z=7, x=77, y=40)
|
||||
|
||||
# track_1999 должен быть отфильтрован (строго меньше 2000)
|
||||
# track_2000 проходит (>= 2000 не выполняется для строгого фильтра < 2000)
|
||||
# По коду: if min_length_m > 0 and length_m < min_length_m → skip
|
||||
# 1999 < 2000 → skip, 2000 < 2000 → False → not skipped
|
||||
assert result_2000 != result_1999 or result_1999 == b""
|
||||
182
tests/api/test_gps_tracks_sources_osm.py
Normal file
182
tests/api/test_gps_tracks_sources_osm.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user