Конфиг-only активация двух новых источников GPS-треков поверх pipeline ET-008. Не вводит новых компонентов, БД-таблиц, endpoint'ов. Config: - config/gps_sources.yaml: enduro_russia enabled=true, base_url исправлен на endurorussia.ru (без дефиса); добавлена запись wikiloc с max_tracks_per_run=50, activity_filter=[motorcycle, enduro]. - config/gps_regions.yaml: wikiloc добавлен в tsfo_plus_chuvashia.sources. Parser: - wikiloc.py: добавлен soft-cap max_tracks_per_run в collect(), извлечение created_at из GPX metadata/первого trkpt — для корректной межисточниковой дедупликации с EnduroRussia. UI (src/web/gps_tracks.js): - GPS_SOURCE_COLORS: добавлен цвет wikiloc (#4363d8). - Дефолтный фильтр sources включает wikiloc. - GPS_SOURCE_ATTRIBUTIONS: маппинг source_id → строка атрибуции; _updateGpsAttribution() подтягивает /api/gps-tracks/health и выставляет attribution с теми источниками, у которых tracks > 0. - _buildGpsFiltersUI: чекбокс «Wikiloc» в #gps-source-grid. Tests: - Fixtures: 7 файлов в tests/fixtures/gps-tracks/. - Unit: 10 UT-ER + 10 UT-WL — парсеры, MAPPING, bbox-фильтр, pagination, 429/403 graceful-stop, rate-limit, max_tracks_per_run. - Integration: IT-ER-01, IT-WL-01, IT-WL-02, IT-DEDUP-01, IT-LIC-01 через scripts.gps_collect.main + httpx.MockTransport. - Contract: 2 CT-ER с маркером @pytest.mark.network (nightly only). - JS: 2 новых теста на наличие wikiloc в SOURCE_COLORS и в фильтрах. Linters/Tests: ruff clean (новые файлы), 166 pytest passed, 24 JS-tests passed. Refs: ET-009 Acceptance: AC-01..AC-08, AC-14..AC-17 (для AC-09..AC-13 — продакшн-прогон) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
11 KiB
Python
265 lines
11 KiB
Python
"""Unit tests for EnduroRussiaParser (ET-009).
|
||
|
||
Coverage:
|
||
- UT-ER-01: _parse_gpx success on valid GPX fixture
|
||
- UT-ER-02: _parse_gpx returns None on empty GPX
|
||
- UT-ER-03: bbox filter rejects out-of-bbox track
|
||
- UT-ER-04: MAPPING translates categories correctly
|
||
- UT-ER-05: base_url without dash preserved (regression R-4)
|
||
- UT-ER-06: pagination stops when fetched_so_far >= total
|
||
- UT-ER-07: HTTP 429 on /api/tracks → graceful return
|
||
- UT-ER-08: HTTP 429 on /api/tracks/{id}/gpx → graceful return, earlier tracks preserved
|
||
"""
|
||
import json
|
||
import os
|
||
from typing import Callable
|
||
|
||
import httpx
|
||
|
||
from src.api.gps_tracks.sources.enduro_russia import (
|
||
EnduroRussiaParser,
|
||
_bbox_intersects,
|
||
_parse_gpx,
|
||
)
|
||
from src.api.gps_tracks.sources import enduro_russia as er_module
|
||
|
||
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures", "gps-tracks")
|
||
|
||
# Region bbox for ЦФО+Чувашия
|
||
BBOX_TSFO = (29.0, 49.5, 47.5, 60.0)
|
||
|
||
|
||
def _read_fixture(name: str) -> bytes:
|
||
with open(os.path.join(FIXTURES_DIR, name), "rb") as f:
|
||
return f.read()
|
||
|
||
|
||
def _make_config(**overrides) -> dict:
|
||
cfg = {
|
||
"id": "enduro_russia",
|
||
"base_url": "https://endurorussia.ru",
|
||
"rate_limit_sec": 0, # speed up tests
|
||
"user_agent": "test-agent",
|
||
"source_priority": 80,
|
||
}
|
||
cfg.update(overrides)
|
||
return cfg
|
||
|
||
|
||
def _patch_client(monkeypatch, handler: Callable[[httpx.Request], httpx.Response]) -> None:
|
||
"""Подменяет httpx.AsyncClient в модуле enduro_russia на клиент с MockTransport."""
|
||
transport = httpx.MockTransport(handler)
|
||
original = httpx.AsyncClient
|
||
|
||
def factory(*args, **kwargs):
|
||
kwargs["transport"] = transport
|
||
return original(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(er_module.httpx, "AsyncClient", factory)
|
||
|
||
|
||
async def _collect_all(parser, bbox):
|
||
"""Собирает все треки из async-генератора."""
|
||
tracks = []
|
||
async for t in parser.collect(bbox, {}):
|
||
tracks.append(t)
|
||
return tracks
|
||
|
||
|
||
# ─── UT-ER-01 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_ut_er_01_parse_gpx_track1_success():
|
||
"""UT-ER-01: _parse_gpx на track-1 → TrackInsert с points_count ≥ 10."""
|
||
content = _read_fixture("enduro-russia-track-1.gpx")
|
||
meta = {"name": "Маршрут Дмитровский", "difficulty": "hard", "created_at": "2024-08-15 12:30:00"}
|
||
|
||
track = _parse_gpx(
|
||
content,
|
||
track_id=1,
|
||
meta=meta,
|
||
source_id="enduro_russia",
|
||
base_url="https://endurorussia.ru",
|
||
source_priority=80,
|
||
mapping=EnduroRussiaParser.MAPPING,
|
||
)
|
||
|
||
assert track is not None
|
||
assert track.points_count >= 10
|
||
assert track.length_m > 0
|
||
assert track.min_lon < track.max_lon
|
||
assert track.min_lat < track.max_lat
|
||
assert track.external_url == "https://endurorussia.ru/tracks/1"
|
||
assert track.source_id == "enduro_russia"
|
||
# difficulty 'hard' → enduro
|
||
assert track.activity_type == "enduro"
|
||
|
||
|
||
# ─── UT-ER-02 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_ut_er_02_parse_gpx_empty_returns_none():
|
||
"""UT-ER-02: _parse_gpx на пустом GPX → None."""
|
||
content = _read_fixture("enduro-russia-track-2.gpx")
|
||
|
||
track = _parse_gpx(
|
||
content,
|
||
track_id=2,
|
||
meta={},
|
||
source_id="enduro_russia",
|
||
base_url="https://endurorussia.ru",
|
||
source_priority=80,
|
||
mapping=EnduroRussiaParser.MAPPING,
|
||
)
|
||
|
||
assert track is None
|
||
|
||
|
||
# ─── UT-ER-03 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_ut_er_03_bbox_filter_rejects_outside():
|
||
"""UT-ER-03: track-3 за пределами bbox ЦФО → _bbox_intersects False."""
|
||
content = _read_fixture("enduro-russia-track-3.gpx")
|
||
track = _parse_gpx(
|
||
content,
|
||
track_id=3,
|
||
meta={},
|
||
source_id="enduro_russia",
|
||
base_url="https://endurorussia.ru",
|
||
source_priority=80,
|
||
mapping=EnduroRussiaParser.MAPPING,
|
||
)
|
||
assert track is not None # парсится, но bbox не пересекается
|
||
intersects = _bbox_intersects(
|
||
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
|
||
BBOX_TSFO,
|
||
)
|
||
assert intersects is False
|
||
|
||
|
||
async def test_ut_er_03_collect_skips_out_of_bbox(monkeypatch):
|
||
"""UT-ER-03 (collect): out-of-bbox трек не yield-ится."""
|
||
api_data = json.loads(_read_fixture("enduro-russia-api-tracks-page1.json"))
|
||
# Оставим только один трек id=3 (вне bbox)
|
||
api_data = {"items": [it for it in api_data["items"] if it["id"] == 3], "total": 1, "page": 0}
|
||
|
||
def handler(req: httpx.Request) -> httpx.Response:
|
||
if req.url.path == "/api/tracks":
|
||
return httpx.Response(200, json=api_data)
|
||
if req.url.path == "/api/tracks/3/gpx":
|
||
return httpx.Response(200, content=_read_fixture("enduro-russia-track-3.gpx"))
|
||
return httpx.Response(404)
|
||
|
||
_patch_client(monkeypatch, handler)
|
||
parser = EnduroRussiaParser(_make_config())
|
||
tracks = await _collect_all(parser, BBOX_TSFO)
|
||
assert tracks == []
|
||
|
||
|
||
# ─── UT-ER-04 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_ut_er_04_mapping_categories():
|
||
"""UT-ER-04: MAPPING маппит ключевые категории."""
|
||
m = EnduroRussiaParser.MAPPING
|
||
assert m["hard"] == "enduro"
|
||
assert m["soft"] == "enduro"
|
||
assert m["мото"] == "moto"
|
||
# 'unknown' нет в MAPPING → map_activity → 'other'
|
||
parser = EnduroRussiaParser(_make_config())
|
||
assert parser.map_activity("unknown") == "other"
|
||
|
||
|
||
# ─── UT-ER-05 ───────────────────────────────────────────────────────────────
|
||
|
||
def test_ut_er_05_base_url_no_dash_preserved():
|
||
"""UT-ER-05: base_url 'https://endurorussia.ru' сохраняется без замены."""
|
||
cfg = _make_config(base_url="https://endurorussia.ru")
|
||
parser = EnduroRussiaParser(cfg)
|
||
assert parser.config["base_url"] == "https://endurorussia.ru"
|
||
# Регрессия: проверим что в default fallback тоже без дефиса
|
||
parser_no_url = EnduroRussiaParser({"id": "enduro_russia"})
|
||
# default используется в collect() — но base_url берётся через get()
|
||
assert "enduro-russia" not in parser_no_url.config.get("base_url", "https://endurorussia.ru")
|
||
|
||
|
||
async def test_ut_er_05_collect_uses_no_dash_url(monkeypatch):
|
||
"""UT-ER-05 (collect): HTTP-запросы уходят на endurorussia.ru (без дефиса)."""
|
||
seen_hosts = []
|
||
|
||
def handler(req: httpx.Request) -> httpx.Response:
|
||
seen_hosts.append(req.url.host)
|
||
if req.url.path == "/api/tracks":
|
||
return httpx.Response(200, json={"items": [], "total": 0, "page": 0})
|
||
return httpx.Response(404)
|
||
|
||
_patch_client(monkeypatch, handler)
|
||
parser = EnduroRussiaParser(_make_config(base_url="https://endurorussia.ru"))
|
||
await _collect_all(parser, BBOX_TSFO)
|
||
assert any(h == "endurorussia.ru" for h in seen_hosts)
|
||
assert not any("enduro-russia" in h for h in seen_hosts)
|
||
|
||
|
||
# ─── UT-ER-06 ───────────────────────────────────────────────────────────────
|
||
|
||
async def test_ut_er_06_pagination_stops_at_total(monkeypatch):
|
||
"""UT-ER-06: collect() делает 1 запрос /api/tracks при total=5, items=5."""
|
||
api_data = json.loads(_read_fixture("enduro-russia-api-tracks-page1.json"))
|
||
list_calls = []
|
||
|
||
def handler(req: httpx.Request) -> httpx.Response:
|
||
if req.url.path == "/api/tracks":
|
||
list_calls.append(req.url.query.decode() if isinstance(req.url.query, bytes) else str(req.url.query))
|
||
return httpx.Response(200, json=api_data)
|
||
# GPX: вернём пустой (None) или валидный для track-1
|
||
return httpx.Response(200, content=_read_fixture("enduro-russia-track-2.gpx"))
|
||
|
||
_patch_client(monkeypatch, handler)
|
||
parser = EnduroRussiaParser(_make_config())
|
||
await _collect_all(parser, BBOX_TSFO)
|
||
assert len(list_calls) == 1, f"expected 1 /api/tracks call, got {len(list_calls)}: {list_calls}"
|
||
|
||
|
||
# ─── UT-ER-07 ───────────────────────────────────────────────────────────────
|
||
|
||
async def test_ut_er_07_http_429_on_tracks_list_graceful(monkeypatch):
|
||
"""UT-ER-07: 429 на /api/tracks → завершение без exception, 0 треков."""
|
||
|
||
def handler(req: httpx.Request) -> httpx.Response:
|
||
if req.url.path == "/api/tracks":
|
||
return httpx.Response(429, json={"error": "Too Many Requests"})
|
||
return httpx.Response(404)
|
||
|
||
_patch_client(monkeypatch, handler)
|
||
parser = EnduroRussiaParser(_make_config())
|
||
tracks = await _collect_all(parser, BBOX_TSFO)
|
||
assert tracks == []
|
||
|
||
|
||
# ─── UT-ER-08 ───────────────────────────────────────────────────────────────
|
||
|
||
async def test_ut_er_08_http_429_on_gpx_graceful(monkeypatch):
|
||
"""UT-ER-08: 429 на /api/tracks/{id}/gpx после первых OK → ранние треки сохраняются."""
|
||
# Соберём API ответ с двумя треками: 1 (OK) и 2 (429)
|
||
api_data = {
|
||
"items": [
|
||
{"id": 1, "name": "T1", "difficulty": "hard", "created_at": "2024-08-15 12:30:00"},
|
||
{"id": 2, "name": "T2", "difficulty": "hard", "created_at": "2024-08-15 13:00:00"},
|
||
],
|
||
"total": 2,
|
||
"page": 0,
|
||
}
|
||
|
||
def handler(req: httpx.Request) -> httpx.Response:
|
||
if req.url.path == "/api/tracks":
|
||
return httpx.Response(200, json=api_data)
|
||
if req.url.path == "/api/tracks/1/gpx":
|
||
return httpx.Response(200, content=_read_fixture("enduro-russia-track-1.gpx"))
|
||
if req.url.path == "/api/tracks/2/gpx":
|
||
return httpx.Response(429)
|
||
return httpx.Response(404)
|
||
|
||
_patch_client(monkeypatch, handler)
|
||
parser = EnduroRussiaParser(_make_config())
|
||
tracks = await _collect_all(parser, BBOX_TSFO)
|
||
# Ранний трек должен сохраниться
|
||
assert len(tracks) == 1
|
||
assert tracks[0].external_url.endswith("/tracks/1")
|