Files
enduro-trails/tests/api/test_gps_tracks_download.py
claude-bot 721b33a2f6
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / lint (pull_request) Successful in 4s
CI / build (push) Successful in 2s
CI / test (pull_request) Successful in 6s
CI / build (pull_request) Successful in 4s
fix(gps-tracks): address ET-011 review — JS UI tests + flat 403 contract
Закрывает findings из docs/work-items/ET-011/12-review.md (REQUEST_CHANGES,
попытка 3/3):

P1-01 — добавлены поведенческие JS unit-тесты UI download-flow
  - tests/web/track_download.test.js — 28 кейсов (node --test):
      • _parseFilenameFromCD — RFC 5987 приоритет, plain fallback,
        битый percent-encoding, null/empty (REQ-F-05.2, AC-2 UI)
      • _handleDownloadError — 400/403/404/413/5xx тосты, defensive
        при отсутствии showToast, поддержка flat (ADR-015 §G) и legacy
        wrapped 403-форм (REQ-F-05.4, AC-7 UI)
      • _renderTrackPopupHtml — наличие кнопки, aria-label «Скачать GPX»,
        data-track-id, отсутствие при невалидном id, регрессия прочих
        полей (REQ-F-01, AC-1)
  - tests/web/test_track_download.py — pytest-обёртка (статические
    проверки + запуск Node-раннера), исполняется в обычном pytest tests/
  - 04b-ui-test-cases.md: AC-13 (mobile-bbox) явно маркирован как
    MANUAL release-smoke (Playwright-раннер в проекте не настроен;
    альтернатива согласована reviewer'ом в P1-01).

P2-01 — устранено расхождение «doc vs runtime» по контракту 403
  - endpoint.py: HTTPException(detail={...}) → JSONResponse(content={...}),
    чтобы FastAPI не оборачивал dict во второй слой «detail». Контракт
    теперь совпадает с ADR-015 §G и ADR-014 §6:
        {"detail":"source_forbidden","external_urls":[...]}
  - test_gps_tracks_download.py IT-05: упрощено — body уже плоский,
    без двухуровневого `body.get("detail", body)` workaround.
  - gps_tracks.js::_handleDownloadError: flat-форма стала приоритетной,
    wrapped-форма оставлена как defensive fallback (с комментарием).

Регрессия: 89/89 API-тестов + 24/24 предыдущих JS-тестов + 28 новых
JS-тестов download-flow проходят. ruff check — clean.

Refs: ET-011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 23:01:19 +00:00

410 lines
15 KiB
Python
Raw Permalink 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()
# ADR-015 §G: одноуровневый контракт через JSONResponse в endpoint.py
# (см. P2-01 в 12-review.md). Раньше FastAPI оборачивал detail-dict
# в {"detail": {...}}; сейчас body == {"detail": "...", "external_urls": [...]}.
assert body.get("detail") == "source_forbidden"
assert body.get("external_urls") == ["https://www.wikiloc.com/abc"]
@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