All checks were successful
Закрывает 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>
410 lines
15 KiB
Python
410 lines
15 KiB
Python
"""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
|