All checks were successful
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>
160 lines
8.4 KiB
Python
160 lines
8.4 KiB
Python
"""ET-013 — integration-тесты endpoint ``/terrain/{layer}/{z}/{x}/{y}.png``
|
||
для z9-z11 (REQ-F-15; AC-16).
|
||
|
||
Тесты используют FastAPI TestClient против ``src.api.main:app``. Реальные
|
||
тайлы рельефа в репозиторий не коммитятся (PH-6 data live in ``data/terrain/``
|
||
на test-сервере). Поэтому:
|
||
|
||
* Если директория с тайлами недоступна — тесты ``IT-TILE-*`` помечаются
|
||
``skipped`` с пояснением.
|
||
* Регрессии «404 на невалидный zoom / неизвестный layer» работают всегда —
|
||
они не требуют исходных данных.
|
||
|
||
Покрытие тест-плана (`04-test-plan.yaml`):
|
||
- 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)
|
||
"""
|
||
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from src.api.main import TERRAIN_DIR, app
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
|
||
# Опционально каталог тайлов перекрывается через env. По умолчанию — берём
|
||
# тот же путь, что использует api (см. src/api/main.py TERRAIN_DIR).
|
||
TERRAIN_ROOT = Path(os.environ.get("TERRAIN_DIR", TERRAIN_DIR))
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def client() -> TestClient:
|
||
return TestClient(app)
|
||
|
||
|
||
def _find_sample_tile(layer: str, z: int):
|
||
"""Найти любую существующую (x, y) пару тайла для layer/z.
|
||
|
||
Возвращает None, если данных нет — тогда вызывающий тест помечается skipped.
|
||
"""
|
||
z_dir = TERRAIN_ROOT / layer / str(z)
|
||
if not z_dir.is_dir():
|
||
return None
|
||
for x_dir in sorted(z_dir.iterdir()):
|
||
if not x_dir.is_dir():
|
||
continue
|
||
try:
|
||
x = int(x_dir.name)
|
||
except ValueError:
|
||
continue
|
||
for y_file in sorted(x_dir.iterdir()):
|
||
if y_file.suffix != ".png":
|
||
continue
|
||
try:
|
||
y = int(y_file.stem)
|
||
except ValueError:
|
||
continue
|
||
return (x, y)
|
||
return None
|
||
|
||
|
||
def _maybe_skip(layer: str, z: int):
|
||
sample = _find_sample_tile(layer, z)
|
||
if sample is None:
|
||
pytest.skip(
|
||
f"PH-6 data not present: {TERRAIN_ROOT}/{layer}/{z}/ — "
|
||
"integration smoke skipped (см. TRZ REQ-F-15)."
|
||
)
|
||
return sample
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 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_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 "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):
|
||
"""IT-TILE-MISSING: hillshade-тайл с нереальными x/y → 404."""
|
||
resp = client.get("/terrain/hillshade/9/999999/999999.png")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
def test_invalid_zoom_returns_404(client: TestClient):
|
||
"""Доп. регрессия: zoom вне нарезанного диапазона → 404 (тайла нет на диске)."""
|
||
resp = client.get("/terrain/hillshade/99/0/0.png")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# IT-TILE-CACHE-HEADER — Cache-Control: immutable сохраняется (NFR-03, REQ-F-18)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_terrain_tile_cache_control_immutable(client: TestClient):
|
||
"""IT-TILE-CACHE-HEADER: тайл рельефа отдаётся с Cache-Control: immutable."""
|
||
x, y = _maybe_skip("hillshade", 9)
|
||
resp = client.get(f"/terrain/hillshade/9/{x}/{y}.png")
|
||
assert resp.status_code == 200
|
||
cache_control = resp.headers.get("cache-control", "")
|
||
assert "immutable" in cache_control, f"ожидался immutable в Cache-Control, факт: {cache_control}"
|
||
assert "max-age=31536000" in cache_control, (
|
||
f"ожидался max-age=31536000 в Cache-Control, факт: {cache_control}"
|
||
)
|