fix(gps-tracks): address ET-011 review — JS UI tests + flat 403 contract
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

Закрывает 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>
This commit is contained in:
2026-06-03 23:01:19 +00:00
parent 716bff3126
commit 721b33a2f6
6 changed files with 484 additions and 15 deletions

View File

@@ -5,6 +5,7 @@ import os
from typing import Optional
from fastapi import APIRouter, HTTPException, Path, Query, Response
from fastapi.responses import JSONResponse
from src.api.gps_tracks.config import load_download_allowed_sources
from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db
@@ -380,10 +381,15 @@ def create_gps_router(
external_urls = json.loads(row["external_urls_json"] or "[]")
# ADR-015 §B1: разрешение по принципу ANY — хотя бы один разрешённый.
# ADR-015 §G: контракт ответа — одноуровневый JSON
# {"detail": "source_forbidden", "external_urls": [...]}.
# Используем JSONResponse напрямую вместо HTTPException(detail={...}),
# чтобы FastAPI не оборачивал dict в `{"detail": {...}}` (P2-01 в
# 12-review.md: контракт docs vs runtime разъезжался).
if not any(s in allowed_download_sources for s in sources):
raise HTTPException(
return JSONResponse(
status_code=403,
detail={
content={
"detail": "source_forbidden",
"external_urls": external_urls,
},