Files
enduro-trails/tests/integration/test_terrain_z9_tiles.py
claude-bot 099669deeb
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
fix(terrain): расширить whitelist endpoint'а на tri (ET-013 review F-1)
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>
2026-06-04 09:59:51 +00:00

160 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}"
)