Files
enduro-trails/tests/api/test_gps_tracks_download.py
claude-bot eea6c846c2
Some checks failed
CI / lint (push) Failing after 5s
CI / test (push) Failing after 6s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
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>
2026-06-03 20:59:53 +00:00

413 lines
15 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.
"""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