fix(terrain): расширить whitelist endpoint'а на tri (ET-013 review F-1)
All checks were successful
CI / lint (push) Successful in 5s
CI / lint (pull_request) Successful in 5s
CI / test (push) Successful in 11s
CI / build (push) Successful in 3s
CI / test (pull_request) Successful in 10s
CI / build (pull_request) Successful in 2s

Reviewer'ом найден pre-existing P1: backend `terrain_tile` whitelist
не пропускал слой `tri`, хотя фронтенд (`onTerrainCheckbox`) шлёт
запросы на `/terrain/tri/{z}/{x}/{y}.png` для слоя «Перепады высот».

На test/prod-среде эти запросы перехватывает nginx (подтверждено
эмпирически — 404 идёт с сигнатурой `nginx/1.18.0 (Ubuntu)`, а не
с FastAPI JSON-detail), но в dev-режиме (`make dev` → FastAPI на
:5556 напрямую) endpoint обязан поддерживать `tri` нативно.

Изменения:
- `src/api/main.py:1252`: whitelist `("hypso", "hillshade")` →
  `("hypso", "hillshade", "tri")`. Ответ-контракт и заголовки
  идентичны существующим слоям; REQ-F-18 «API contract без изменений»
  не нарушен (поведение для уже-известных layer'ов не меняется,
  добавляется только поддержка нового layer'а).
- `tests/integration/test_terrain_z9_tiles.py`: новый параметризованный
  тест `test_known_terrain_layer_accepted_by_whitelist[hypso|hillshade|tri]`,
  фиксирующий регрессию F-1 (не требует локальных PNG-данных:
  для несуществующего файла ожидает `detail: "Tile not found"`,
  а не `"Unknown layer"`).
- `tests/integration/test_terrain_z9_tiles.py`: параметризация
  `test_terrain_tile_available_z9_z10_z11` по `(layer × zoom)` —
  6 кейсов вместо 3 (review F-2).
- `tests/integration/test_terrain_z9_tiles.py`: убран неиспользуемый
  `from __future__ import annotations` (review F-4); type-аннотации
  упрощены (Python 3.10+ нативно).
- `tests/integration/test_terrain_z9_tiles.py`: `test_unknown_terrain_layer_returns_404`
  усилен ассертом `detail == "Unknown layer"` (парность с whitelist-тестом).

Тесты: 17/17 unit PASS, 6/6 non-data-зависимых integration PASS,
6 layer×zoom кейсов SKIPPED (нет PH-6 данных в sandbox — корректное
поведение `_maybe_skip`).

Refs: ET-013, review F-1/F-2/F-4 (`docs/work-items/ET-013/12-review.md`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 09:59:51 +00:00
parent f6fc9be324
commit 099669deeb
3 changed files with 71 additions and 13 deletions

View File

@@ -5,7 +5,26 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
### Added
- ET-013 (review F-1 fix): Слой `tri` (Terrain Ruggedness Index) добавлен
в whitelist FastAPI-endpoint'а `GET /terrain/{layer}/{z}/{x}/{y}.png`
(`src/api/main.py`). На test/prod-среде nginx перехватывает
`/enduro/terrain/*` и отдаёт PNG напрямую с диска (подтверждено эмпирически
по 404-сигнатуре `nginx/1.18.0`), но в dev-режиме (`make dev`
FastAPI на :5556 без nginx) endpoint должен поддерживать `tri` нативно.
Изменение аддитивное: ответ-контракт и заголовки идентичны существующим
слоям (`hypso`, `hillshade`); REQ-F-18 «API contract без изменений»
не нарушен. Регрессия: integration-тест `test_known_terrain_layer_accepted_by_whitelist`
параметризован по `(hypso, hillshade, tri)` и проверяет, что для
заведомо отсутствующего файла возвращается `detail: "Tile not found"`,
а не `"Unknown layer"`. Refs: ET-013, review F-1.
### Changed
- ET-013 (review F-2 fix): Integration-тест
`tests/integration/test_terrain_z9_tiles.py` параметризован по
`(layer ∈ {hillshade, tri}) × (zoom ∈ {9, 10, 11})` — 6 кейсов
вместо 3, покрывает оба слоя на расширенном диапазоне зумов
(ранее покрывался только `hillshade`). Refs: ET-013, review F-2.
- ET-012: Слой публичных GPS-треков теперь виден с зума z=5 (раньше — с z=8).
Калибровка существующей tier-структуры `build_gps_mvt`/`_simplify_coords`
(ADR-016): для z≤5 фильтр `min_length=10 км`, `limit=1500`; для z=6 —

View File

@@ -1239,8 +1239,17 @@ TERRAIN_DIR = os.environ.get(
@app.get("/terrain/{layer}/{z}/{x}/{y}.png")
async def terrain_tile(layer: str, z: int, x: int, y: int):
"""Отдаёт растровые тайлы рельефа (hypso/hillshade)"""
if layer not in ("hypso", "hillshade"):
"""Отдаёт растровые тайлы рельефа (hypso/hillshade/tri).
ET-013: добавлен слой ``tri`` (Terrain Ruggedness Index) в whitelist.
Фронтенд (`src/web/app.js`, ``onTerrainCheckbox``) запрашивает
``/terrain/tri/{z}/{x}/{y}.png`` для слоя «Перепады высот». На
test/prod-среде эти запросы перехватывает nginx и отдаёт PNG
напрямую с диска, но в dev-режиме (``make dev`` → FastAPI на :5556
без nginx) endpoint должен поддерживать ``tri`` нативно.
См. review ET-013 F-1.
"""
if layer not in ("hypso", "hillshade", "tri"):
raise HTTPException(404, "Unknown layer")
tile_path = os.path.join(TERRAIN_DIR, layer, str(z), str(x), f"{y}.png")
if not os.path.exists(tile_path):

View File

@@ -11,13 +11,12 @@
они не требуют исходных данных.
Покрытие тест-плана (`04-test-plan.yaml`):
- IT-TILE-Z9-01, IT-TILE-Z10-01, IT-TILE-Z11-01
- IT-TILE-Z9-01, IT-TILE-Z10-01, IT-TILE-Z11-01 (по обоим слоям hillshade и tri — см. F-2)
- IT-TILE-INVALID-LAYER, IT-TILE-MISSING
- IT-TILE-CACHE-HEADER
- IT-TILE-TRI-WHITELIST: регрессия, что endpoint признаёт `tri` (см. review F-1)
"""
from __future__ import annotations
import os
from pathlib import Path
@@ -38,7 +37,7 @@ def client() -> TestClient:
return TestClient(app)
def _find_sample_tile(layer: str, z: int) -> tuple[int, int] | None:
def _find_sample_tile(layer: str, z: int):
"""Найти любую существующую (x, y) пару тайла для layer/z.
Возвращает None, если данных нет — тогда вызывающий тест помечается skipped.
@@ -64,7 +63,7 @@ def _find_sample_tile(layer: str, z: int) -> tuple[int, int] | None:
return None
def _maybe_skip(layer: str, z: int) -> tuple[int, int]:
def _maybe_skip(layer: str, z: int):
sample = _find_sample_tile(layer, z)
if sample is None:
pytest.skip(
@@ -75,29 +74,60 @@ def _maybe_skip(layer: str, z: int) -> tuple[int, int]:
# ──────────────────────────────────────────────────────────────────────────────
# IT-TILE-Z9 / Z10 / Z11 — hillshade доступен на расширенном диапазоне зумов
# IT-TILE-Z9 / Z10 / Z11 — hillshade и TRI доступны на расширенном диапазоне зумов
# (review F-2: параметризация по layer, чтобы покрыть оба слоя endpoint'а)
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("layer", ["hillshade", "tri"])
@pytest.mark.parametrize("zoom", [9, 10, 11])
def test_hillshade_tile_available_z9_z10_z11(client: TestClient, zoom: int):
"""IT-TILE-Z9/Z10/Z11-01: hillshade-тайл на z9-z11 отдаётся 200 PNG."""
x, y = _maybe_skip("hillshade", zoom)
resp = client.get(f"/terrain/hillshade/{zoom}/{x}/{y}.png")
def test_terrain_tile_available_z9_z10_z11(client: TestClient, layer: str, zoom: int):
"""IT-TILE-Z9/Z10/Z11-01: тайл рельефа (hillshade и tri) на z9-z11 отдаётся 200 PNG."""
x, y = _maybe_skip(layer, zoom)
resp = client.get(f"/terrain/{layer}/{zoom}/{x}/{y}.png")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert len(resp.content) > 0
# ──────────────────────────────────────────────────────────────────────────────
# IT-TILE-TRI-WHITELIST (review F-1) — endpoint признаёт слой `tri`
# Этот тест не зависит от наличия тайлов: для существующего слоя без файла на
# диске мы получаем 404 "Tile not found", а для несуществующего слоя — 404
# "Unknown layer". Различие проверяется по телу ответа.
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("layer", ["hypso", "hillshade", "tri"])
def test_known_terrain_layer_accepted_by_whitelist(client: TestClient, layer: str):
"""Регрессия F-1: каждый из (hypso, hillshade, tri) проходит whitelist."""
# x, y заведомо не существуют на диске → должны получить 404 "Tile not found",
# но НЕ "Unknown layer". Эта проверка работает без локальных PNG-данных.
resp = client.get(f"/terrain/{layer}/9/999999/999999.png")
assert resp.status_code == 404
detail = resp.json().get("detail", "")
assert detail != "Unknown layer", (
f"layer={layer!r} должен проходить whitelist, факт detail={detail!r}"
)
assert detail == "Tile not found", (
f"для несуществующего файла ожидался detail='Tile not found', факт={detail!r}"
)
# ──────────────────────────────────────────────────────────────────────────────
# Регрессии 404 — работают независимо от наличия данных
# ──────────────────────────────────────────────────────────────────────────────
def test_unknown_terrain_layer_returns_404(client: TestClient):
"""IT-TILE-INVALID-LAYER: неизвестный layer → 404."""
"""IT-TILE-INVALID-LAYER: неизвестный layer → 404 "Unknown layer".
Парный к ``test_known_terrain_layer_accepted_by_whitelist`` (F-1):
подтверждает, что whitelist всё ещё отсекает посторонние слои.
"""
resp = client.get("/terrain/unknown_layer/9/0/0.png")
assert resp.status_code == 404
assert resp.json().get("detail") == "Unknown layer"
def test_missing_terrain_tile_returns_404(client: TestClient):