"""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(" 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