work_item: ET-011
title: Скачивание трека из popup на карте
version: 1
generated_by: analyst

# Категории тестов:
# - unit         — изолированные функции (сборщик GPX, санитизатор имени)
# - integration  — FastAPI endpoint через TestClient
# - e2e          — Playwright, end-to-end в браузере
# UI-кейсы для визуальной/интерактивной проверки — см. 04b-ui-test-cases.md

tests:

  # ─── UNIT ─────────────────────────────────────────────────────

  - id: UT-01
    type: unit
    name: build_gpx — корректная структура GPX 1.1
    file: tests/api/test_gps_tracks_gpx_builder.py
    covers: [REQ-F-03, REQ-NF-03]
    steps:
      - Подать на вход искусственный трек (5 точек, name, description, activity_type="enduro", sources=["osm"], external_urls=["https://www.openstreetmap.org/way/1"]).
      - Получить строку GPX.
      - Распарсить через ElementTree.
    assertions:
      - root.tag == "{http://www.topografix.com/GPX/1/1}gpx"
      - root.attrib["version"] == "1.1"
      - root.attrib["creator"] == "Enduro Trails"
      - в metadata присутствует <name> с переданным именем
      - в metadata присутствует <link href="https://www.openstreetmap.org/way/1">
      - ровно один <trk>, ровно один <trkseg>
      - число <trkpt> == 5
      - у trkpt атрибуты lat и lon — float

  - id: UT-02
    type: unit
    name: build_gpx — пустые/NULL поля
    file: tests/api/test_gps_tracks_gpx_builder.py
    covers: [REQ-F-03]
    steps:
      - Трек с name=None, description=None, created_at=None, user=None, external_urls=[].
    assertions:
      - GPX валиден (по схеме)
      - <name> = "Без названия" или его аналог
      - элементы <desc>, <time>, <author>, <link> отсутствуют (а не пустые)

  - id: UT-03
    type: unit
    name: build_gpx — соответствие схеме XSD
    file: tests/api/test_gps_tracks_gpx_builder.py
    covers: [REQ-NF-03]
    steps:
      - Сгенерировать GPX из 3 разных треков (минимальный, типичный, с UTF-8).
      - Валидировать каждый через lxml.etree.XMLSchema (gpx.xsd закоммитить в tests/fixtures/).
    assertions:
      - schema.validate(tree) == True для всех 3 случаев

  - id: UT-04
    type: unit
    name: safe_filename — санитизация
    file: tests/api/test_gps_tracks_filename.py
    covers: [REQ-F-04]
    cases:
      - input: "По грязи к Чёрному озеру"
        expected_ascii_fallback: содержит только ASCII, длина ≤ 80
        expected_utf8: percent-encoded UTF-8 строка
      - input: "Trail/with:bad*chars?"
        expected_ascii: подчёркивания вместо запрещённых символов
      - input: ""
        track_id: 42
        expected: "track-42"
      - input: "X" * 200
        expected_length: ≤ 80

  - id: UT-05
    type: unit
    name: wkb_to_coords — повторное использование существующего парсера
    file: tests/api/test_gps_tracks_gpx_builder.py
    covers: [REQ-F-03]
    note: уже покрыто косвенно в ET-008, но добавить smoke-проверку на пограничный случай (2 точки).

  # ─── INTEGRATION ───────────────────────────────────────────────

  - id: IT-01
    type: integration
    name: GET /api/gps-tracks/{id}/download — happy path
    file: tests/api/test_gps_tracks_download.py
    covers: [REQ-F-02, AC-3]
    steps:
      - Инициализировать тестовую БД с одним треком (id=1, geom=LineString из 10 точек).
      - GET /api/gps-tracks/1/download через TestClient.
    assertions:
      - status_code == 200
      - response.headers["content-type"] == "application/gpx+xml"
      - "attachment" in response.headers["content-disposition"]
      - "filename*=UTF-8''" in response.headers["content-disposition"]
      - response.text.startswith("<?xml")
      - "<gpx" in response.text and 'version="1.1"' in response.text
      - response.text.count("<trkpt") == 10

  - id: IT-02
    type: integration
    name: GET /api/gps-tracks/{id}/download — 404 для несуществующего id
    file: tests/api/test_gps_tracks_download.py
    covers: [REQ-F-02, AC-7]
    steps:
      - GET /api/gps-tracks/99999999/download
    assertions:
      - status_code == 404
      - response.json()["detail"] упоминает не_найден / not_found / track_not_found

  - id: IT-03
    type: integration
    name: GET /api/gps-tracks/{id}/download — невалидный format
    file: tests/api/test_gps_tracks_download.py
    covers: [REQ-F-02, AC-8]
    steps:
      - GET /api/gps-tracks/1/download?format=fit
    assertions:
      - status_code == 400

  - id: IT-04
    type: integration
    name: Patho-трек > 200k точек → 413
    file: tests/api/test_gps_tracks_download.py
    covers: [REQ-NF-02, AC-9]
    steps:
      - Подложить в БД запись с points_count=300000 (можно фиктивную, geom не нужен — проверка идёт по points_count до сборки).
      - GET /api/gps-tracks/<id>/download
    assertions:
      - status_code == 413

  - id: IT-05
    type: integration
    name: Лицензионный фильтр — 403 для запрещённого источника (Q-1 conditional)
    file: tests/api/test_gps_tracks_download.py
    covers: [REQ-F-06, AC-11]
    enabled_if: "Owner закрыл Q-1 как 'только OSM'"
    steps:
      - Трек с sources=["wikiloc"], external_urls=["https://wikiloc.com/..."]
      - GET /api/gps-tracks/<id>/download
    assertions:
      - status_code == 403
      - response.json()["external_urls"] == ["https://wikiloc.com/..."]

  - id: IT-06
    type: integration
    name: UTF-8 имя файла в Content-Disposition
    file: tests/api/test_gps_tracks_download.py
    covers: [REQ-F-04, REQ-NF-05, AC-4]
    steps:
      - Трек с name="По грязи к Чёрному озеру"
      - GET .../download
    assertions:
      - "filename*=UTF-8''" в Content-Disposition
      - decoded UTF-8 имя == "По грязи к Чёрному озеру.gpx"
      - "filename=" (без звёздочки) — ASCII-fallback, без кириллицы

  - id: IT-07
    type: integration
    name: Валидация GPX-ответа по XSD
    file: tests/api/test_gps_tracks_download.py
    covers: [REQ-NF-03, AC-5]
    steps:
      - Скачать GPX через TestClient.
      - Валидировать ответ через lxml.etree.XMLSchema по gpx.xsd.
    assertions:
      - validation passes без warnings/errors

  - id: IT-08
    type: integration
    name: Регрессия — существующие GPS-эндпоинты живы
    file: tests/api/test_gps_tracks_endpoint.py
    covers: [AC-15]
    note: smoke-проверка, что добавление нового route не сломало GET /api/gps-tracks, /tiles/..., /health.

  # ─── E2E (Playwright, mounted browser) ─────────────────────────

  - id: E2E-01
    type: e2e
    name: Тап трека → popup → клик «Скачать» → файл в загрузках (desktop)
    file: tests/web/test_track_download.spec.ts
    covers: [REQ-F-01, REQ-F-05, AC-1, AC-2]
    viewport: desktop
    steps:
      - Открыть https://openclaw.mva154.duckdns.org/enduro/
      - Включить слой «Публичные треки» (раскрыть terrain-popup, поставить #public-tracks-cb).
      - Дождаться загрузки тайлов (~5000ms).
      - Кликнуть в координату с известным треком (либо использовать map.queryRenderedFeatures + map.click).
      - Дождаться появления popup (.maplibregl-popup .track-popup).
      - Ожидать кнопку с aria-label="Скачать GPX" внутри popup.
      - Кликнуть на кнопку и перехватить событие download через context.waitForEvent('download').
    assertions:
      - download.suggestedFilename().endsWith('.gpx')
      - размер файла > 100 байт
      - первые 100 байт содержат "<?xml" и "<gpx"

  - id: E2E-02
    type: e2e
    name: Mobile — popup и кнопка видны
    file: tests/web/test_track_download.spec.ts
    covers: [REQ-NF-04, AC-13]
    viewport: mobile (375x667)
    steps:
      - см. E2E-01, но с deviceScaleFactor=2, isMobile=true.
    assertions:
      - кнопка «Скачать» видима (visible) и имеет bounding box ≥ 32×32 px
      - popup не выходит за пределы viewport

  - id: E2E-03
    type: e2e
    name: Ошибка 404 — toast пользователю
    file: tests/web/test_track_download.spec.ts
    covers: [REQ-F-05, AC-7]
    steps:
      - Замокать ответ /api/gps-tracks/*/download через page.route() — вернуть 404.
      - Триггернуть download.
    assertions:
      - появляется #app-toast с текстом «Трек не найден» (либо аналог)

  - id: E2E-04
    type: e2e
    name: Лицензионный фильтр — toast «Источник запрещает» (conditional)
    file: tests/web/test_track_download.spec.ts
    covers: [REQ-F-06, AC-11]
    enabled_if: "Owner закрыл Q-1 как 'только OSM'"
    steps:
      - Замокать ответ /api/gps-tracks/*/download → 403 с body {"detail":"source_forbidden","external_urls":["https://wikiloc.com/x"]}.
    assertions:
      - toast содержит текст про «источник»
      - есть кликабельная ссылка / кнопка на wikiloc URL

# ─── Покрытие AC ────────────────────────────────────────────────

coverage_matrix:
  AC-1:  [E2E-01, E2E-02]
  AC-2:  [E2E-01]
  AC-3:  [IT-01]
  AC-4:  [UT-04, IT-06]
  AC-5:  [UT-03, IT-07]
  AC-6:  ['manual smoke (см. acceptance §AC-6)']
  AC-7:  [IT-02, E2E-03]
  AC-8:  [IT-03]
  AC-9:  [IT-04]
  AC-10: [UT-01]
  AC-11: [IT-05, E2E-04]
  AC-12: ['manual perf check, не блокирует merge']
  AC-13: [E2E-02]
  AC-14: ['покрывается визуально через UI test cases 04b']
  AC-15: [IT-08]
