Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m42s
CI / backend-tests (pull_request) Successful in 7m40s
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
#41: per-category routing-radius decay через reusable ORS /matrix client (foot-walk время + per-category пороги + decay-curves), opt-in ?decay=routing, graceful straight-line fallback (analyze не 500 при ORS down) + length-guard durations==destinations. #44: +5 OSM-категорий (transformer/gas/water/sewerage/heat) via Overpass nwr; nearest_{water_main,substation,gas,heat}_m в analyze.utilities. #255bk: ST_AsGeoJSON(CAST(geom AS geometry)) в _get_cad_zouit_overlaps → geom_geojson. Closes #41 Closes #44
123 lines
4.8 KiB
Python
123 lines
4.8 KiB
Python
"""Тесты ORS-клиента (#41) — /matrix durations.
|
||
|
||
Сеть НЕ дёргается (ORS free tier 2000/день): подменяем httpx.Client на
|
||
MockTransport. Покрывает: парсинг durations, усечение до лимита, 429 → OrsUnavailableError,
|
||
нет ключа → OrsUnavailableError, None-маршруты.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from app.core.config import settings
|
||
from app.services.site_finder import ors_client
|
||
|
||
|
||
@pytest.fixture
|
||
def with_key(monkeypatch: pytest.MonkeyPatch):
|
||
"""Задать фиктивный ORS-ключ на время теста."""
|
||
monkeypatch.setattr(settings, "openrouteservice_api_key", "test-key-123")
|
||
yield
|
||
|
||
|
||
def _install_transport(monkeypatch: pytest.MonkeyPatch, handler) -> dict:
|
||
"""Подменить httpx.Client в модуле ors_client на MockTransport(handler).
|
||
|
||
Захватываем реальный httpx.Client ДО патча — иначе factory рекурсивно зовёт
|
||
сама себя (ors_client.httpx — общий модуль httpx).
|
||
"""
|
||
captured: dict = {}
|
||
real_client = httpx.Client
|
||
|
||
def client_factory(*_args, **kwargs):
|
||
kwargs.pop("transport", None)
|
||
return real_client(transport=httpx.MockTransport(handler), **kwargs)
|
||
|
||
monkeypatch.setattr(ors_client.httpx, "Client", client_factory)
|
||
return captured
|
||
|
||
|
||
def test_matrix_durations_parses_minutes(with_key, monkeypatch):
|
||
"""durations (секунды) → минуты, длина = числу destinations."""
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/foot-walking")
|
||
return httpx.Response(200, json={"durations": [[0.0, 300.0, 600.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
out = ors_client.matrix_durations_min(
|
||
60.6, 56.8, [(60.61, 56.81), (60.62, 56.82), (60.63, 56.83)]
|
||
)
|
||
assert out == [0.0, 5.0, 10.0]
|
||
|
||
|
||
def test_matrix_none_route_preserved(with_key, monkeypatch):
|
||
"""ORS вернул null (нет маршрута) → None в результате."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(200, json={"durations": [[120.0, None]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
out = ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
assert out == [2.0, None]
|
||
|
||
|
||
def test_matrix_no_key_raises():
|
||
"""Нет API-ключа → OrsUnavailableError (caller сделает straight-line fallback)."""
|
||
# settings по умолчанию пустой ключ (testing). Явно убедимся.
|
||
if settings.openrouteservice_api_key:
|
||
pytest.skip("ключ задан в окружении")
|
||
with pytest.raises(ors_client.OrsUnavailableError):
|
||
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)])
|
||
|
||
|
||
def test_matrix_429_raises_unavailable(with_key, monkeypatch):
|
||
"""429 (rate-limit) → OrsUnavailableError, не пробрасываем HTTPStatusError."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(429, json={"error": "rate limit"})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
with pytest.raises(ors_client.OrsUnavailableError, match="daily limit"):
|
||
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)])
|
||
|
||
|
||
def test_matrix_empty_destinations_returns_empty(with_key):
|
||
assert ors_client.matrix_durations_min(60.6, 56.8, []) == []
|
||
|
||
|
||
def test_matrix_invalid_profile_raises(with_key):
|
||
with pytest.raises(ors_client.OrsUnavailableError, match="profile"):
|
||
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)], profile="teleport")
|
||
|
||
|
||
def test_matrix_truncates_to_limit(with_key, monkeypatch):
|
||
""">MAX_MATRIX_DESTINATIONS усекается; body.locations = 1 source + лимит."""
|
||
seen: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
body = json.loads(request.content)
|
||
seen["n_locations"] = len(body["locations"])
|
||
n_dest = len(body["destinations"])
|
||
return httpx.Response(200, json={"durations": [[60.0] * n_dest]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
dests = [(60.0 + i * 0.001, 56.0) for i in range(ors_client.MAX_MATRIX_DESTINATIONS + 50)]
|
||
out = ors_client.matrix_durations_min(60.6, 56.8, dests)
|
||
# 1 source + MAX destinations
|
||
assert seen["n_locations"] == ors_client.MAX_MATRIX_DESTINATIONS + 1
|
||
assert len(out) == ors_client.MAX_MATRIX_DESTINATIONS
|
||
|
||
|
||
def test_matrix_bad_response_raises(with_key, monkeypatch):
|
||
"""Битый ответ без durations → OrsUnavailableError."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(200, json={"unexpected": True})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
with pytest.raises(ors_client.OrsUnavailableError):
|
||
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)])
|